From 8db70bca19e8c6227d61de9da3ce450521ba6643 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Wed, 14 Apr 2021 08:33:27 +0300 Subject: [PATCH 01/43] Unskip heatmap suite and fixes flakiness (#96941) --- test/functional/apps/visualize/_heatmap_chart.ts | 3 +-- test/functional/apps/visualize/index.ts | 5 ----- test/functional/page_objects/visualize_editor_page.ts | 4 +--- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/test/functional/apps/visualize/_heatmap_chart.ts b/test/functional/apps/visualize/_heatmap_chart.ts index 79a9a6cbd5aca..660f45179631e 100644 --- a/test/functional/apps/visualize/_heatmap_chart.ts +++ b/test/functional/apps/visualize/_heatmap_chart.ts @@ -15,8 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const inspector = getService('inspector'); const PageObjects = getPageObjects(['visualize', 'visEditor', 'visChart', 'timePicker']); - // FLAKY: https://github.com/elastic/kibana/issues/95642 - describe.skip('heatmap chart', function indexPatternCreation() { + describe('heatmap chart', function indexPatternCreation() { const vizName1 = 'Visualization HeatmapChart'; before(async function () { diff --git a/test/functional/apps/visualize/index.ts b/test/functional/apps/visualize/index.ts index 0a3632e4aaa81..747494a690c7e 100644 --- a/test/functional/apps/visualize/index.ts +++ b/test/functional/apps/visualize/index.ts @@ -56,11 +56,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_point_series_options')); loadTestFile(require.resolve('./_vertical_bar_chart')); loadTestFile(require.resolve('./_vertical_bar_chart_nontimeindex')); - - // Test non-replaced vislib chart types - loadTestFile(require.resolve('./_gauge_chart')); - loadTestFile(require.resolve('./_heatmap_chart')); - loadTestFile(require.resolve('./_pie_chart')); }); describe('', function () { diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index 5f05d825dd0f4..97627556abc63 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -128,9 +128,7 @@ export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrP } public async changeHeatmapColorNumbers(value = 6) { - const input = await testSubjects.find(`heatmapColorsNumber`); - await input.clearValueWithKeyboard(); - await input.type(`${value}`); + await testSubjects.setValue('heatmapColorsNumber', `${value}`); } public async getBucketErrorMessage() { From f0b1b903d554942f6c2d8c954760b846723ffab7 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Wed, 14 Apr 2021 08:34:50 +0300 Subject: [PATCH 02/43] [Datatable] Fix filter cell flakiness (#96934) --- test/functional/apps/visualize/_data_table.ts | 18 ++++++++---------- .../page_objects/visualize_chart_page.ts | 5 +++-- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/test/functional/apps/visualize/_data_table.ts b/test/functional/apps/visualize/_data_table.ts index 96cbf97621b08..1ff5bdcc6da78 100644 --- a/test/functional/apps/visualize/_data_table.ts +++ b/test/functional/apps/visualize/_data_table.ts @@ -267,16 +267,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should apply correct filter', async () => { - await retry.try(async () => { - await PageObjects.visChart.filterOnTableCell(1, 3); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const data = await PageObjects.visChart.getTableVisContent(); - expect(data).to.be.eql([ - ['png', '1,373'], - ['gif', '918'], - ['Other', '445'], - ]); - }); + await PageObjects.visChart.filterOnTableCell(1, 3); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['png', '1,373'], + ['gif', '918'], + ['Other', '445'], + ]); }); }); diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index cd1c5cf318e63..7b69101b92475 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -419,12 +419,13 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr public async filterOnTableCell(columnIndex: number, rowIndex: number) { await retry.try(async () => { const cell = await dataGrid.getCellElement(rowIndex, columnIndex); - await cell.focus(); + await cell.click(); const filterBtn = await testSubjects.findDescendant( 'tbvChartCell__filterForCellValue', cell ); - await filterBtn.click(); + await common.sleep(2000); + filterBtn.click(); }); } From b0772471ce74b3656d8bdbf9e4ab4d2290fd3017 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Wed, 14 Apr 2021 03:52:12 -0400 Subject: [PATCH 03/43] [TSVB] Fix per-request caching of index patterns (#97043) --- .../common/__mocks__/index_patterns_utils.ts | 18 ++++++++++++ .../lib/cached_index_pattern_fetcher.test.ts | 28 +++++++++++++++++++ .../lib/cached_index_pattern_fetcher.ts | 2 +- 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/plugins/vis_type_timeseries/common/__mocks__/index_patterns_utils.ts diff --git a/src/plugins/vis_type_timeseries/common/__mocks__/index_patterns_utils.ts b/src/plugins/vis_type_timeseries/common/__mocks__/index_patterns_utils.ts new file mode 100644 index 0000000000000..9e41df3880419 --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/__mocks__/index_patterns_utils.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const mock = jest.requireActual('../index_patterns_utils'); + +jest.spyOn(mock, 'fetchIndexPattern'); + +export const { + isStringTypeIndexPattern, + getIndexPatternKey, + extractIndexPatternValues, + fetchIndexPattern, +} = mock; diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts index 3e6f8c2962d5a..813b0a22c0c37 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts @@ -7,11 +7,14 @@ */ import { IndexPattern, IndexPatternsService } from 'src/plugins/data/server'; +import { fetchIndexPattern } from '../../../../common/index_patterns_utils'; import { getCachedIndexPatternFetcher, CachedIndexPatternFetcher, } from './cached_index_pattern_fetcher'; +jest.mock('../../../../common/index_patterns_utils'); + describe('CachedIndexPatternFetcher', () => { let mockedIndices: IndexPattern[] | []; let cachedIndexPatternFetcher: CachedIndexPatternFetcher; @@ -25,6 +28,8 @@ describe('CachedIndexPatternFetcher', () => { find: jest.fn(() => Promise.resolve(mockedIndices || [])), } as unknown) as IndexPatternsService; + (fetchIndexPattern as jest.Mock).mockClear(); + cachedIndexPatternFetcher = getCachedIndexPatternFetcher(indexPatternsService); }); @@ -52,6 +57,14 @@ describe('CachedIndexPatternFetcher', () => { } `); }); + + test('should cache once', async () => { + await cachedIndexPatternFetcher('indexTitle'); + await cachedIndexPatternFetcher('indexTitle'); + await cachedIndexPatternFetcher('indexTitle'); + + expect(fetchIndexPattern as jest.Mock).toHaveBeenCalledTimes(1); + }); }); describe('object-based index', () => { @@ -86,5 +99,20 @@ describe('CachedIndexPatternFetcher', () => { } `); }); + + test('should cache once', async () => { + mockedIndices = [ + { + id: 'indexId', + title: 'indexTitle', + }, + ] as IndexPattern[]; + + await cachedIndexPatternFetcher({ id: 'indexId' }); + await cachedIndexPatternFetcher({ id: 'indexId' }); + await cachedIndexPatternFetcher({ id: 'indexId' }); + + expect(fetchIndexPattern as jest.Mock).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts index 68cbd93cdc614..b03fa973e9da9 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts @@ -23,7 +23,7 @@ export const getCachedIndexPatternFetcher = (indexPatternsService: IndexPatterns const fetchedIndex = fetchIndexPattern(indexPatternValue, indexPatternsService); - cache.set(indexPatternValue, fetchedIndex); + cache.set(key, fetchedIndex); return fetchedIndex; }; From 3a7f23efacfdc22f507a4a39118b117c2b38bbd4 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Wed, 14 Apr 2021 11:00:06 +0200 Subject: [PATCH 04/43] [Discover][DocViewer] Fix toggle columns from doc viewer table tab (#95748) --- .../doc_viewer/doc_viewer_tab.test.tsx | 43 +++++++++++++++++++ .../components/doc_viewer/doc_viewer_tab.tsx | 2 + 2 files changed, 45 insertions(+) create mode 100644 src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx new file mode 100644 index 0000000000000..a2434170acdd7 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { DocViewerTab } from './doc_viewer_tab'; +import { ElasticSearchHit } from '../../doc_views/doc_views_types'; + +describe('DocViewerTab', () => { + test('changing columns triggers an update', () => { + const props = { + title: 'test', + component: jest.fn(), + id: 1, + render: jest.fn(), + renderProps: { + hit: {} as ElasticSearchHit, + columns: ['test'], + }, + }; + + const wrapper = shallow(); + + const nextProps = { + ...props, + renderProps: { + hit: {} as ElasticSearchHit, + columns: ['test2'], + }, + }; + + const shouldUpdate = (wrapper!.instance() as DocViewerTab).shouldComponentUpdate(nextProps, { + hasError: false, + error: '', + }); + expect(shouldUpdate).toBe(true); + }); +}); diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx index 25454a3bad38a..1ad6500771d48 100644 --- a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx @@ -7,6 +7,7 @@ */ import React from 'react'; +import { isEqual } from 'lodash'; import { I18nProvider } from '@kbn/i18n/react'; import { DocViewRenderTab } from './doc_viewer_render_tab'; import { DocViewerError } from './doc_viewer_render_error'; @@ -46,6 +47,7 @@ export class DocViewerTab extends React.Component { return ( nextProps.renderProps.hit._id !== this.props.renderProps.hit._id || nextProps.id !== this.props.id || + !isEqual(nextProps.renderProps.columns, this.props.renderProps.columns) || nextState.hasError ); } From 8c8fcf16c49a27a13f9a7e1020bf2dddccce1807 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Wed, 14 Apr 2021 11:46:12 +0200 Subject: [PATCH 05/43] added missing optional chain for bracket notation (#96939) --- .../rollup/server/routes/api/jobs/register_delete_route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/rollup/server/routes/api/jobs/register_delete_route.ts b/x-pack/plugins/rollup/server/routes/api/jobs/register_delete_route.ts index f90a81f73823e..7e22b5c4ead10 100644 --- a/x-pack/plugins/rollup/server/routes/api/jobs/register_delete_route.ts +++ b/x-pack/plugins/rollup/server/routes/api/jobs/register_delete_route.ts @@ -37,7 +37,7 @@ export const registerDeleteRoute = ({ // Until then we'll modify the response here. if ( err?.meta && - err.body?.task_failures[0]?.reason?.reason?.includes( + err.body?.task_failures?.[0]?.reason?.reason?.includes( 'Job must be [STOPPED] before deletion' ) ) { From f4f49bc32e22589030c9e3b9a7b03d33728151da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Wed, 14 Apr 2021 12:32:40 +0200 Subject: [PATCH 06/43] [Data telemetry] Add Async Search to the tests (#96693) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../get_data_telemetry/get_data_telemetry.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts index c892f27905e0d..d2113dce9548f 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts @@ -46,6 +46,15 @@ describe('get_data_telemetry', () => { ).toStrictEqual([]); }); + test('should not include Async Search indices', () => { + expect( + buildDataTelemetryPayload([ + { name: '.async_search', docCount: 0 }, + { name: '.async-search', docCount: 0 }, + ]) + ).toStrictEqual([]); + }); + test('matches some indices and puts them in their own category', () => { expect( buildDataTelemetryPayload([ From 23e18b93eb6b75d02724f7cd197ea1877a91da05 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Wed, 14 Apr 2021 13:48:00 +0300 Subject: [PATCH 07/43] [TSVB] Enable brush for visualizations created with no index patterns (#96727) * [TSVB] Enable brush for visualizations created with no index patterns * Fix comments typo Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/timeseries_visualization.tsx | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/src/plugins/vis_type_timeseries/public/application/components/timeseries_visualization.tsx b/src/plugins/vis_type_timeseries/public/application/components/timeseries_visualization.tsx index 7fba2e1cb701f..13d06e1c9a18d 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/timeseries_visualization.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/timeseries_visualization.tsx @@ -59,22 +59,44 @@ function TimeseriesVisualization({ const indexPatternValue = model.index_pattern || ''; const { indexPatterns } = getDataStart(); const { indexPattern } = await fetchIndexPattern(indexPatternValue, indexPatterns); + let event; + // trigger applyFilter if no index pattern found, url drilldowns are supported only + // for the index pattern mode + if (indexPattern) { + const tables = indexPattern + ? await convertSeriesToDataTable(model, series, indexPattern) + : null; + const table = tables?.[model.series[0].id]; + + const range: [number, number] = [parseInt(gte, 10), parseInt(lte, 10)]; + event = { + data: { + table, + column: X_ACCESSOR_INDEX, + range, + timeFieldName: indexPattern?.timeFieldName, + }, + name: 'brush', + }; + } else { + event = { + name: 'applyFilter', + data: { + timeFieldName: '*', + filters: [ + { + range: { + '*': { + gte, + lte, + }, + }, + }, + ], + }, + }; + } - const tables = indexPattern - ? await convertSeriesToDataTable(model, series, indexPattern) - : null; - const table = tables?.[model.series[0].id]; - - const range: [number, number] = [parseInt(gte, 10), parseInt(lte, 10)]; - const event = { - data: { - table, - column: X_ACCESSOR_INDEX, - range, - timeFieldName: indexPattern?.timeFieldName, - }, - name: 'brush', - }; handlers.event(event); }, [handlers, model] From e361e216223bf0a6a43d5fb0cd37e6c217815481 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 14 Apr 2021 14:12:27 +0200 Subject: [PATCH 08/43] UI actions readme (#96925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: ✏️ improve UI actions plugin readme * docs: improve trigger description * docs: remove unnecessary comma * chore: 🤖 update autogenerated docs Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/developer/plugin-list.asciidoc | 29 +++++++--- src/plugins/ui_actions/README.asciidoc | 73 +++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 16 deletions(-) diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 0c40c2a8c4db9..353a77527d1d5 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -216,14 +216,27 @@ which also contains the timelion APIs and backend, look at the vis_type_timelion |<> -|An API for: - -- creating custom functionality (`actions`) -- creating custom user interaction events (`triggers`) -- attaching and detaching `actions` to `triggers`. -- emitting `trigger` events -- executing `actions` attached to a given `trigger`. -- exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. +|UI Actions plugins provides API to manage *triggers* and *actions*. + +*Trigger* is an abstract description of user's intent to perform an action +(like user clicking on a value inside chart). It allows us to do runtime +binding between code from different plugins. For, example one such +trigger is when somebody applies filters on dashboard; another one is when +somebody opens a Dashboard panel context menu. + +*Actions* are pieces of code that execute in response to a trigger. For example, +to the dashboard filtering trigger multiple actions can be attached. Once a user +filters on the dashboard all possible actions are displayed to the user in a +popup menu and the user has to chose one. + +In general this plugin provides: + +- Creating custom functionality (actions). +- Creating custom user interaction events (triggers). +- Attaching and detaching actions to triggers. +- Emitting trigger events. +- Executing actions attached to a given trigger. +- Exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. |{kib-repo}blob/{branch}/src/plugins/url_forwarding/README.md[urlForwarding] diff --git a/src/plugins/ui_actions/README.asciidoc b/src/plugins/ui_actions/README.asciidoc index 577aa2eae354b..27b3eae3a52a7 100644 --- a/src/plugins/ui_actions/README.asciidoc +++ b/src/plugins/ui_actions/README.asciidoc @@ -1,14 +1,71 @@ [[uiactions-plugin]] == UI Actions -An API for: - -- creating custom functionality (`actions`) -- creating custom user interaction events (`triggers`) -- attaching and detaching `actions` to `triggers`. -- emitting `trigger` events -- executing `actions` attached to a given `trigger`. -- exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. +UI Actions plugins provides API to manage *triggers* and *actions*. + +*Trigger* is an abstract description of user's intent to perform an action +(like user clicking on a value inside chart). It allows us to do runtime +binding between code from different plugins. For, example one such +trigger is when somebody applies filters on dashboard; another one is when +somebody opens a Dashboard panel context menu. + +*Actions* are pieces of code that execute in response to a trigger. For example, +to the dashboard filtering trigger multiple actions can be attached. Once a user +filters on the dashboard all possible actions are displayed to the user in a +popup menu and the user has to chose one. + +In general this plugin provides: + +- Creating custom functionality (actions). +- Creating custom user interaction events (triggers). +- Attaching and detaching actions to triggers. +- Emitting trigger events. +- Executing actions attached to a given trigger. +- Exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. + +=== Basic usage + +To get started, first you need to know a trigger you will attach your actions to. +You can either pick an existing one, or register your own one: + +[source,typescript jsx] +---- +plugins.uiActions.registerTrigger({ + id: 'MY_APP_PIE_CHART_CLICK', + title: 'Pie chart click', + description: 'When user clicks on a pie chart slice.', +}); +---- + +Now, when user clicks on a pie slice you need to "trigger" your trigger and +provide some context data: + +[source,typescript jsx] +---- +plugins.uiActions.getTrigger('MY_APP_PIE_CHART_CLICK').exec({ + /* Custom context data. */ +}); +---- + +Finally, your code or developers from other plugins can register UI actions that +listen for the above trigger and execute some code when the trigger is triggered. + +[source,typescript jsx] +---- +plugins.uiActions.registerAction({ + id: 'DO_SOMETHING', + isCompatible: async (context) => true, + execute: async (context) => { + // Do something. + }, +}); +plugins.uiActions.attachAction('MY_APP_PIE_CHART_CLICK', 'DO_SOMETHING'); +---- + +Now your `DO_SOMETHING` action will automatically execute when `MY_APP_PIE_CHART_CLICK` +trigger is triggered; or, if more than one compatible action is attached to +that trigger, user will be presented with a context menu popup to select one +action to execute. === Examples From 69f570f06aa0a4f7869bce56c9b0b25a50506214 Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Wed, 14 Apr 2021 15:21:11 +0300 Subject: [PATCH 09/43] [Usage collection] Usage counters (#96696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Alejandro Fernández Haro --- ...rver.indexpatternsserviceprovider.start.md | 4 +- src/plugins/data/server/server.api.md | 1 + .../server/__snapshots__/index.test.ts.snap | 21 -- ...emetry_application_usage_collector.test.ts | 2 +- .../cloud/cloud_provider_collector.test.ts | 14 +- .../server/collectors/core/index.test.ts | 2 +- .../server/collectors/index.ts | 4 + .../server/collectors/kibana/index.test.ts | 2 +- .../telemetry_management_collector.test.ts | 2 +- .../server/collectors/ops_stats/index.test.ts | 2 +- .../__fixtures__/ui_counter_saved_objects.ts | 51 ++++ .../usage_counter_saved_objects.ts | 104 +++++++ .../register_ui_counters_collector.test.ts | 264 +++++++++++++---- .../register_ui_counters_collector.ts | 156 ++++++++-- .../ui_counters/rollups/register_rollups.ts | 12 +- .../ui_counters/rollups/rollups.test.ts | 38 ++- .../collectors/ui_counters/rollups/rollups.ts | 16 + .../server/collectors/ui_metric/index.test.ts | 2 +- .../usage_counter_saved_objects.ts | 104 +++++++ .../server/collectors/usage_counters/index.ts | 10 + .../register_usage_counters_collector.test.ts | 55 ++++ .../register_usage_counters_collector.ts | 116 ++++++++ .../usage_counters/rollups/constants.ts | 22 ++ .../usage_counters/rollups/index.ts | 9 + .../rollups/register_rollups.ts | 21 ++ .../usage_counters/rollups/rollups.test.ts | 170 +++++++++++ .../usage_counters/rollups/rollups.ts | 73 +++++ .../server/{index.test.mocks.ts => mocks.ts} | 0 .../server/{index.test.ts => plugin.test.ts} | 66 ++++- .../kibana_usage_collection/server/plugin.ts | 20 +- src/plugins/telemetry/schema/oss_plugins.json | 47 +++ src/plugins/usage_collection/README.mdx | 95 ++++++ .../usage_collection/common/ui_counters.ts | 23 ++ .../server/collector/collector_set.ts | 32 +- .../server/collector/index.ts | 1 - src/plugins/usage_collection/server/config.ts | 5 + src/plugins/usage_collection/server/index.ts | 13 + src/plugins/usage_collection/server/mocks.ts | 67 ++++- src/plugins/usage_collection/server/plugin.ts | 85 +++++- .../server/report/store_report.test.ts | 84 ++++-- .../server/report/store_report.ts | 18 +- .../usage_collection/server/routes/index.ts | 6 +- .../server/routes/ui_counters.ts | 6 +- .../server/usage_collection.mock.ts | 58 ---- .../server/usage_counters/index.ts | 15 + .../usage_counters/saved_objects.test.ts | 71 +++++ .../server/usage_counters/saved_objects.ts | 86 ++++++ .../usage_counters/usage_counter.test.ts | 38 +++ .../server/usage_counters/usage_counter.ts | 48 +++ .../usage_counters_service.mock.ts | 40 +++ .../usage_counters_service.test.ts | 241 +++++++++++++++ .../usage_counters/usage_counters_service.ts | 185 ++++++++++++ .../register_usage_collector.test.ts | 6 +- .../register_timeseries_collector.test.ts | 2 +- .../register_vega_collector.test.ts | 2 +- .../register_visualizations_collector.test.ts | 2 +- .../telemetry/__fixtures__/ui_counters.ts | 8 + .../telemetry/__fixtures__/usage_counters.ts | 36 +++ .../apis/telemetry/telemetry_local.ts | 15 + .../apis/ui_counters/ui_counters.ts | 50 ++-- test/api_integration/config.js | 2 + .../saved_objects/ui_counters/data.json | 111 +++++++ .../saved_objects/ui_counters/data.json.gz | Bin 236 -> 0 bytes .../saved_objects/ui_counters/mappings.json | 9 + .../saved_objects/usage_counters/data.json | 89 ++++++ .../usage_counters/mappings.json | 276 ++++++++++++++++++ test/plugin_functional/config.ts | 3 + .../plugins/usage_collection/kibana.json | 9 + .../plugins/usage_collection/package.json | 14 + .../plugins/usage_collection/server/index.ts | 10 + .../plugins/usage_collection/server/plugin.ts | 43 +++ .../plugins/usage_collection/server/routes.ts | 24 ++ .../plugins/usage_collection/tsconfig.json | 18 ++ .../test_suites/usage_collection/index.ts | 15 + .../usage_collection/usage_counters.ts | 67 +++++ 75 files changed, 3120 insertions(+), 318 deletions(-) delete mode 100644 src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap create mode 100644 src/plugins/kibana_usage_collection/server/collectors/ui_counters/__fixtures__/ui_counter_saved_objects.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/ui_counters/__fixtures__/usage_counter_saved_objects.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/__fixtures__/usage_counter_saved_objects.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/index.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/register_usage_counters_collector.test.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/register_usage_counters_collector.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/constants.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/index.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/register_rollups.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/rollups.test.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/rollups.ts rename src/plugins/kibana_usage_collection/server/{index.test.mocks.ts => mocks.ts} (100%) rename src/plugins/kibana_usage_collection/server/{index.test.ts => plugin.test.ts} (59%) create mode 100644 src/plugins/usage_collection/common/ui_counters.ts delete mode 100644 src/plugins/usage_collection/server/usage_collection.mock.ts create mode 100644 src/plugins/usage_collection/server/usage_counters/index.ts create mode 100644 src/plugins/usage_collection/server/usage_counters/saved_objects.test.ts create mode 100644 src/plugins/usage_collection/server/usage_counters/saved_objects.ts create mode 100644 src/plugins/usage_collection/server/usage_counters/usage_counter.test.ts create mode 100644 src/plugins/usage_collection/server/usage_counters/usage_counter.ts create mode 100644 src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock.ts create mode 100644 src/plugins/usage_collection/server/usage_counters/usage_counters_service.test.ts create mode 100644 src/plugins/usage_collection/server/usage_counters/usage_counters_service.ts create mode 100644 test/api_integration/apis/telemetry/__fixtures__/usage_counters.ts create mode 100644 test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/data.json delete mode 100644 test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/data.json.gz create mode 100644 test/api_integration/fixtures/es_archiver/saved_objects/usage_counters/data.json create mode 100644 test/api_integration/fixtures/es_archiver/saved_objects/usage_counters/mappings.json create mode 100644 test/plugin_functional/plugins/usage_collection/kibana.json create mode 100644 test/plugin_functional/plugins/usage_collection/package.json create mode 100644 test/plugin_functional/plugins/usage_collection/server/index.ts create mode 100644 test/plugin_functional/plugins/usage_collection/server/plugin.ts create mode 100644 test/plugin_functional/plugins/usage_collection/server/routes.ts create mode 100644 test/plugin_functional/plugins/usage_collection/tsconfig.json create mode 100644 test/plugin_functional/test_suites/usage_collection/index.ts create mode 100644 test/plugin_functional/test_suites/usage_collection/usage_counters.ts diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsserviceprovider.start.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsserviceprovider.start.md index 88079bb2fa3cb..118b0104fbee6 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsserviceprovider.start.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsserviceprovider.start.md @@ -8,7 +8,7 @@ ```typescript start(core: CoreStart, { fieldFormats, logger }: IndexPatternsServiceStartDeps): { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: ElasticsearchClient) => Promise; }; ``` @@ -22,6 +22,6 @@ start(core: CoreStart, { fieldFormats, logger }: IndexPatternsServiceStartDeps): Returns: `{ - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: ElasticsearchClient) => Promise; }` diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 0ea3af60e9b5d..622356c4441ac 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -56,6 +56,7 @@ import { PublicMethodsOf } from '@kbn/utility-types'; import { RecursiveReadonly } from '@kbn/utility-types'; import { RequestAdapter } from 'src/plugins/inspector/common'; import { RequestHandlerContext } from 'src/core/server'; +import * as Rx from 'rxjs'; import { SavedObject } from 'kibana/server'; import { SavedObject as SavedObject_2 } from 'src/core/server'; import { SavedObjectsClientContract } from 'src/core/server'; diff --git a/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap b/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap deleted file mode 100644 index 939e90d2f2583..0000000000000 --- a/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`kibana_usage_collection Runs the setup method without issues 1`] = `true`; - -exports[`kibana_usage_collection Runs the setup method without issues 2`] = `false`; - -exports[`kibana_usage_collection Runs the setup method without issues 3`] = `true`; - -exports[`kibana_usage_collection Runs the setup method without issues 4`] = `false`; - -exports[`kibana_usage_collection Runs the setup method without issues 5`] = `false`; - -exports[`kibana_usage_collection Runs the setup method without issues 6`] = `false`; - -exports[`kibana_usage_collection Runs the setup method without issues 7`] = `false`; - -exports[`kibana_usage_collection Runs the setup method without issues 8`] = `true`; - -exports[`kibana_usage_collection Runs the setup method without issues 9`] = `false`; - -exports[`kibana_usage_collection Runs the setup method without issues 10`] = `true`; diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts index f1b21af5506e6..da4e1b101914f 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts @@ -10,7 +10,7 @@ import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../co import { Collector, createUsageCollectionSetupMock, -} from '../../../../usage_collection/server/usage_collection.mock'; +} from '../../../../usage_collection/server/mocks'; import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants'; import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; import { diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.test.ts index 1f7617a0e69ce..a2f08ddb465cc 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.test.ts @@ -12,25 +12,23 @@ import { Collector, createUsageCollectionSetupMock, createCollectorFetchContextMock, -} from '../../../../usage_collection/server/usage_collection.mock'; +} from '../../../../usage_collection/server/mocks'; import { registerCloudProviderUsageCollector } from './cloud_provider_collector'; describe('registerCloudProviderUsageCollector', () => { let collector: Collector; const logger = loggingSystemMock.createLogger(); - - const usageCollectionMock = createUsageCollectionSetupMock(); - usageCollectionMock.makeUsageCollector.mockImplementation((config) => { - collector = new Collector(logger, config); - return createUsageCollectionSetupMock().makeUsageCollector(config); - }); - const mockedFetchContext = createCollectorFetchContextMock(); beforeEach(() => { cloudDetailsMock.mockClear(); detectCloudServiceMock.mockClear(); + const usageCollectionMock = createUsageCollectionSetupMock(); + usageCollectionMock.makeUsageCollector.mockImplementation((config) => { + collector = new Collector(logger, config); + return createUsageCollectionSetupMock().makeUsageCollector(config); + }); registerCloudProviderUsageCollector(usageCollectionMock); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts index 4409442f4c70a..cbc38129fdddf 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts @@ -9,7 +9,7 @@ import { Collector, createUsageCollectionSetupMock, -} from '../../../../usage_collection/server/usage_collection.mock'; +} from '../../../../usage_collection/server/mocks'; import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; import { registerCoreUsageCollector } from '.'; import { coreUsageDataServiceMock, loggingSystemMock } from '../../../../../core/server/mocks'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/index.ts b/src/plugins/kibana_usage_collection/server/collectors/index.ts index 89e1e6e79482c..522860e58918c 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/index.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/index.ts @@ -20,3 +20,7 @@ export { registerUiCounterSavedObjectType, registerUiCountersRollups, } from './ui_counters'; +export { + registerUsageCountersRollups, + registerUsageCountersUsageCollector, +} from './usage_counters'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts index 1d0329cb01d69..e1afbfbcecc4e 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts @@ -15,7 +15,7 @@ import { Collector, createCollectorFetchContextMock, createUsageCollectionSetupMock, -} from '../../../../usage_collection/server/usage_collection.mock'; +} from '../../../../usage_collection/server/mocks'; import { registerKibanaUsageCollector } from './'; const logger = loggingSystemMock.createLogger(); diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.test.ts index a8ac778226082..cb0b1c045397d 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.test.ts @@ -11,7 +11,7 @@ import { Collector, createUsageCollectionSetupMock, createCollectorFetchContextMock, -} from '../../../../usage_collection/server/usage_collection.mock'; +} from '../../../../usage_collection/server/mocks'; import { registerManagementUsageCollector, diff --git a/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts index a90197e7a25ab..dfd6a93b7ea18 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts @@ -11,7 +11,7 @@ import { Collector, createUsageCollectionSetupMock, createCollectorFetchContextMock, -} from '../../../../usage_collection/server/usage_collection.mock'; +} from '../../../../usage_collection/server/mocks'; import { registerOpsStatsCollector } from './'; import { OpsMetrics } from '../../../../../core/server'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/__fixtures__/ui_counter_saved_objects.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/__fixtures__/ui_counter_saved_objects.ts new file mode 100644 index 0000000000000..ebc958c7be8c6 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/__fixtures__/ui_counter_saved_objects.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { UICounterSavedObject } from '../ui_counter_saved_object_type'; +export const rawUiCounters: UICounterSavedObject[] = [ + { + type: 'ui-counter', + id: 'Kibana_home:23102020:click:different_type', + attributes: { + count: 1, + }, + references: [], + updated_at: '2020-11-24T11:27:57.067Z', + version: 'WzI5NDRd', + }, + { + type: 'ui-counter', + id: 'Kibana_home:25102020:loaded:intersecting_event', + attributes: { + count: 1, + }, + references: [], + updated_at: '2020-10-25T11:27:57.067Z', + version: 'WzI5NDRd', + }, + { + type: 'ui-counter', + id: 'Kibana_home:23102020:loaded:intersecting_event', + attributes: { + count: 3, + }, + references: [], + updated_at: '2020-10-23T11:27:57.067Z', + version: 'WzI5NDRd', + }, + { + type: 'ui-counter', + id: 'Kibana_home:24112020:click:only_reported_in_ui_counters', + attributes: { + count: 1, + }, + references: [], + updated_at: '2020-11-24T11:27:57.067Z', + version: 'WzI5NDRd', + }, +]; diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/__fixtures__/usage_counter_saved_objects.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/__fixtures__/usage_counter_saved_objects.ts new file mode 100644 index 0000000000000..6b70a8c97e651 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/__fixtures__/usage_counter_saved_objects.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { UsageCountersSavedObject } from '../../../../../usage_collection/server'; + +export const rawUsageCounters: UsageCountersSavedObject[] = [ + { + type: 'usage-counters', + id: 'uiCounter:09042021:count:myApp:my_event', + attributes: { + count: 1, + counterName: 'myApp:my_event', + counterType: 'count', + domainId: 'uiCounter', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-09T08:17:57.693Z', + }, + { + type: 'usage-counters', + id: 'uiCounter:23102020:loaded:Kibana_home:intersecting_event', + attributes: { + count: 60, + counterName: 'Kibana_home:intersecting_event', + counterType: 'loaded', + domainId: 'uiCounter', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2020-10-23T11:27:57.067Z', + }, + { + type: 'usage-counters', + id: 'uiCounter:09042021:count:myApp:my_event_4457914848544', + attributes: { + count: 0, + counterName: 'myApp:my_event_4457914848544', + counterType: 'count', + domainId: 'uiCounter', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-09T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'uiCounter:09042021:count:myApp:my_event_malformed', + attributes: { + // @ts-expect-error + count: 'malformed', + counterName: 'myApp:my_event_malformed', + counterType: 'count', + domainId: 'uiCounter', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-09T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'anotherDomainId:09042021:count:some_event_name', + attributes: { + count: 4, + counterName: 'some_event_name', + counterType: 'count', + domainId: 'anotherDomainId', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-09T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'uiCounter:09042021:count:myApp:my_event_4457914848544_2', + attributes: { + count: 8, + counterName: 'myApp:my_event_4457914848544_2', + counterType: 'count', + domainId: 'uiCounter', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-09T08:18:03.031Z', + }, + { + type: 'usage-counters', + id: 'uiCounter:09042021:count:myApp:only_reported_in_usage_counters', + attributes: { + count: 1, + counterName: 'myApp:only_reported_in_usage_counters', + counterType: 'count', + domainId: 'uiCounter', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-09T08:18:03.031Z', + }, +]; diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/register_ui_counters_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/register_ui_counters_collector.test.ts index 7e84bc852c9b5..122e637d2b20c 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/register_ui_counters_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/register_ui_counters_collector.test.ts @@ -6,70 +6,208 @@ * Side Public License, v 1. */ -import { transformRawCounter } from './register_ui_counters_collector'; -import { UICounterSavedObject } from './ui_counter_saved_object_type'; +import { + transformRawUiCounterObject, + transformRawUsageCounterObject, + createFetchUiCounters, +} from './register_ui_counters_collector'; +import { BehaviorSubject } from 'rxjs'; +import { rawUiCounters } from './__fixtures__/ui_counter_saved_objects'; +import { rawUsageCounters } from './__fixtures__/usage_counter_saved_objects'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { UI_COUNTER_SAVED_OBJECT_TYPE } from './ui_counter_saved_object_type'; +import { USAGE_COUNTERS_SAVED_OBJECT_TYPE } from '../../../../usage_collection/server'; -describe('transformRawCounter', () => { - const mockRawUiCounters = [ - { - type: 'ui-counter', - id: 'Kibana_home:24112020:click:ingest_data_card_home_tutorial_directory', - attributes: { - count: 3, - }, - references: [], - updated_at: '2020-11-24T11:27:57.067Z', - version: 'WzI5LDFd', - }, - { - type: 'ui-counter', - id: 'Kibana_home:24112020:click:home_tutorial_directory', - attributes: { - count: 1, - }, - references: [], - updated_at: '2020-11-24T11:27:57.067Z', - version: 'WzI5NDRd', - }, - { - type: 'ui-counter', - id: 'Kibana_home:24112020:loaded:home_tutorial_directory', - attributes: { - count: 3, - }, - references: [], - updated_at: '2020-10-23T11:27:57.067Z', - version: 'WzI5NDRd', - }, - ] as UICounterSavedObject[]; +describe('transformRawUsageCounterObject', () => { + it('transforms usage counters savedObject raw entries', () => { + const result = rawUsageCounters.map(transformRawUsageCounterObject); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "appName": "myApp", + "counterType": "count", + "eventName": "my_event", + "fromTimestamp": "2021-04-09T00:00:00Z", + "lastUpdatedAt": "2021-04-09T08:17:57.693Z", + "total": 1, + }, + Object { + "appName": "Kibana_home", + "counterType": "loaded", + "eventName": "intersecting_event", + "fromTimestamp": "2020-10-23T00:00:00Z", + "lastUpdatedAt": "2020-10-23T11:27:57.067Z", + "total": 60, + }, + undefined, + undefined, + undefined, + Object { + "appName": "myApp", + "counterType": "count", + "eventName": "my_event_4457914848544_2", + "fromTimestamp": "2021-04-09T00:00:00Z", + "lastUpdatedAt": "2021-04-09T08:18:03.031Z", + "total": 8, + }, + Object { + "appName": "myApp", + "counterType": "count", + "eventName": "only_reported_in_usage_counters", + "fromTimestamp": "2021-04-09T00:00:00Z", + "lastUpdatedAt": "2021-04-09T08:18:03.031Z", + "total": 1, + }, + ] + `); + }); +}); + +describe('transformRawUiCounterObject', () => { + it('transforms ui counters savedObject raw entries', () => { + const result = rawUiCounters.map(transformRawUiCounterObject); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "appName": "Kibana_home", + "counterType": "click", + "eventName": "different_type", + "fromTimestamp": "2020-11-24T00:00:00Z", + "lastUpdatedAt": "2020-11-24T11:27:57.067Z", + "total": 1, + }, + Object { + "appName": "Kibana_home", + "counterType": "loaded", + "eventName": "intersecting_event", + "fromTimestamp": "2020-10-25T00:00:00Z", + "lastUpdatedAt": "2020-10-25T11:27:57.067Z", + "total": 1, + }, + Object { + "appName": "Kibana_home", + "counterType": "loaded", + "eventName": "intersecting_event", + "fromTimestamp": "2020-10-23T00:00:00Z", + "lastUpdatedAt": "2020-10-23T11:27:57.067Z", + "total": 3, + }, + Object { + "appName": "Kibana_home", + "counterType": "click", + "eventName": "only_reported_in_ui_counters", + "fromTimestamp": "2020-11-24T00:00:00Z", + "lastUpdatedAt": "2020-11-24T11:27:57.067Z", + "total": 1, + }, + ] + `); + }); +}); + +describe('createFetchUiCounters', () => { + let stopUsingUiCounterIndicies$: BehaviorSubject; + const soClientMock = savedObjectsClientMock.create(); + beforeEach(() => { + jest.clearAllMocks(); + stopUsingUiCounterIndicies$ = new BehaviorSubject(false); + }); + + it('does not query ui_counters saved objects if stopUsingUiCounterIndicies$ is complete', async () => { + // @ts-expect-error incomplete mock implementation + soClientMock.find.mockImplementation(async ({ type }) => { + switch (type) { + case USAGE_COUNTERS_SAVED_OBJECT_TYPE: + return { saved_objects: rawUsageCounters }; + default: + throw new Error(`unexpected type ${type}`); + } + }); + + stopUsingUiCounterIndicies$.complete(); + // @ts-expect-error incomplete mock implementation + const { dailyEvents } = await createFetchUiCounters(stopUsingUiCounterIndicies$)({ + soClient: soClientMock, + }); + + const transforemdUsageCounters = rawUsageCounters.map(transformRawUsageCounterObject); + expect(soClientMock.find).toBeCalledTimes(1); + expect(dailyEvents).toEqual(transforemdUsageCounters.filter(Boolean)); + }); + + it('merges saved objects from both ui_counters and usage_counters saved objects', async () => { + // @ts-expect-error incomplete mock implementation + soClientMock.find.mockImplementation(async ({ type }) => { + switch (type) { + case UI_COUNTER_SAVED_OBJECT_TYPE: + return { saved_objects: rawUiCounters }; + case USAGE_COUNTERS_SAVED_OBJECT_TYPE: + return { saved_objects: rawUsageCounters }; + default: + throw new Error(`unexpected type ${type}`); + } + }); + + // @ts-expect-error incomplete mock implementation + const { dailyEvents } = await createFetchUiCounters(stopUsingUiCounterIndicies$)({ + soClient: soClientMock, + }); + expect(dailyEvents).toHaveLength(7); + const intersectingEntry = dailyEvents.find( + ({ eventName, fromTimestamp }) => + eventName === 'intersecting_event' && fromTimestamp === '2020-10-23T00:00:00Z' + ); + + const onlyFromUICountersEntry = dailyEvents.find( + ({ eventName }) => eventName === 'only_reported_in_ui_counters' + ); + + const onlyFromUsageCountersEntry = dailyEvents.find( + ({ eventName }) => eventName === 'only_reported_in_usage_counters' + ); + + const invalidCountEntry = dailyEvents.find( + ({ eventName }) => eventName === 'my_event_malformed' + ); + + const zeroCountEntry = dailyEvents.find( + ({ eventName }) => eventName === 'my_event_4457914848544' + ); + + const nonUiCountersEntry = dailyEvents.find(({ eventName }) => eventName === 'some_event_name'); - it('transforms saved object raw entries', () => { - const result = mockRawUiCounters.map(transformRawCounter); - expect(result).toEqual([ - { - appName: 'Kibana_home', - eventName: 'ingest_data_card_home_tutorial_directory', - lastUpdatedAt: '2020-11-24T11:27:57.067Z', - fromTimestamp: '2020-11-24T00:00:00Z', - counterType: 'click', - total: 3, - }, - { - appName: 'Kibana_home', - eventName: 'home_tutorial_directory', - lastUpdatedAt: '2020-11-24T11:27:57.067Z', - fromTimestamp: '2020-11-24T00:00:00Z', - counterType: 'click', - total: 1, - }, - { - appName: 'Kibana_home', - eventName: 'home_tutorial_directory', - lastUpdatedAt: '2020-10-23T11:27:57.067Z', - fromTimestamp: '2020-10-23T00:00:00Z', - counterType: 'loaded', - total: 3, - }, - ]); + expect(invalidCountEntry).toBe(undefined); + expect(nonUiCountersEntry).toBe(undefined); + expect(zeroCountEntry).toBe(undefined); + expect(onlyFromUICountersEntry).toMatchInlineSnapshot(` + Object { + "appName": "Kibana_home", + "counterType": "click", + "eventName": "only_reported_in_ui_counters", + "fromTimestamp": "2020-11-24T00:00:00Z", + "lastUpdatedAt": "2020-11-24T11:27:57.067Z", + "total": 1, + } + `); + expect(onlyFromUsageCountersEntry).toMatchInlineSnapshot(` + Object { + "appName": "myApp", + "counterType": "count", + "eventName": "only_reported_in_usage_counters", + "fromTimestamp": "2021-04-09T00:00:00Z", + "lastUpdatedAt": "2021-04-09T08:18:03.031Z", + "total": 1, + } + `); + expect(intersectingEntry).toMatchInlineSnapshot(` + Object { + "appName": "Kibana_home", + "counterType": "loaded", + "eventName": "intersecting_event", + "fromTimestamp": "2020-10-23T00:00:00Z", + "lastUpdatedAt": "2020-10-23T11:27:57.067Z", + "total": 63, + } + `); }); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/register_ui_counters_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/register_ui_counters_collector.ts index dc3fac7382094..19190de45d96b 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/register_ui_counters_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/register_ui_counters_collector.ts @@ -7,13 +7,28 @@ */ import moment from 'moment'; -import { CollectorFetchContext, UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { mergeWith } from 'lodash'; +import type { Subject } from 'rxjs'; import { UICounterSavedObject, UICounterSavedObjectAttributes, UI_COUNTER_SAVED_OBJECT_TYPE, } from './ui_counter_saved_object_type'; +import { + CollectorFetchContext, + UsageCollectionSetup, + USAGE_COUNTERS_SAVED_OBJECT_TYPE, + UsageCountersSavedObject, + UsageCountersSavedObjectAttributes, + serializeCounterKey, +} from '../../../../usage_collection/server'; + +import { + deserializeUiCounterName, + serializeUiCounterName, +} from '../../../../usage_collection/common/ui_counters'; + interface UiCounterEvent { appName: string; eventName: string; @@ -27,12 +42,20 @@ export interface UiCountersUsage { dailyEvents: UiCounterEvent[]; } -export function transformRawCounter(rawUiCounter: UICounterSavedObject) { - const { id, attributes, updated_at: lastUpdatedAt } = rawUiCounter; +export function transformRawUiCounterObject( + rawUiCounter: UICounterSavedObject +): UiCounterEvent | undefined { + const { + id, + attributes: { count }, + updated_at: lastUpdatedAt, + } = rawUiCounter; + if (typeof count !== 'number' || count < 1) { + return; + } + const [appName, , counterType, ...restId] = id.split(':'); const eventName = restId.join(':'); - const counterTotal: unknown = attributes.count; - const total = typeof counterTotal === 'number' ? counterTotal : 0; const fromTimestamp = moment(lastUpdatedAt).utc().startOf('day').format(); return { @@ -41,11 +64,110 @@ export function transformRawCounter(rawUiCounter: UICounterSavedObject) { lastUpdatedAt, fromTimestamp, counterType, - total, + total: count, + }; +} + +export function transformRawUsageCounterObject( + rawUsageCounter: UsageCountersSavedObject +): UiCounterEvent | undefined { + const { + attributes: { count, counterName, counterType, domainId }, + updated_at: lastUpdatedAt, + } = rawUsageCounter; + + if (domainId !== 'uiCounter' || typeof count !== 'number' || count < 1) { + return; + } + + const fromTimestamp = moment(lastUpdatedAt).utc().startOf('day').format(); + const { appName, eventName } = deserializeUiCounterName(counterName); + + return { + appName, + eventName, + lastUpdatedAt, + fromTimestamp, + counterType, + total: count, }; } -export function registerUiCountersUsageCollector(usageCollection: UsageCollectionSetup) { +export const createFetchUiCounters = (stopUsingUiCounterIndicies$: Subject) => + async function fetchUiCounters({ soClient }: CollectorFetchContext) { + const { + saved_objects: rawUsageCounters, + } = await soClient.find({ + type: USAGE_COUNTERS_SAVED_OBJECT_TYPE, + fields: ['count', 'counterName', 'counterType', 'domainId'], + filter: `${USAGE_COUNTERS_SAVED_OBJECT_TYPE}.attributes.domainId: uiCounter`, + perPage: 10000, + }); + + const skipFetchingUiCounters = stopUsingUiCounterIndicies$.isStopped; + const result = + skipFetchingUiCounters || + (await soClient.find({ + type: UI_COUNTER_SAVED_OBJECT_TYPE, + fields: ['count'], + perPage: 10000, + })); + + const rawUiCounters = typeof result === 'object' ? result.saved_objects : []; + const dailyEventsFromUiCounters = rawUiCounters.reduce((acc, raw) => { + try { + const event = transformRawUiCounterObject(raw); + if (event) { + const { appName, eventName, counterType } = event; + const key = serializeCounterKey({ + domainId: 'uiCounter', + counterName: serializeUiCounterName({ appName, eventName }), + counterType, + date: event.lastUpdatedAt, + }); + + acc[key] = event; + } + } catch (_) { + // swallow error; allows sending successfully transformed objects. + } + return acc; + }, {} as Record); + + const dailyEventsFromUsageCounters = rawUsageCounters.reduce((acc, raw) => { + try { + const event = transformRawUsageCounterObject(raw); + if (event) { + acc[raw.id] = event; + } + } catch (_) { + // swallow error; allows sending successfully transformed objects. + } + return acc; + }, {} as Record); + + const mergedDailyCounters = mergeWith( + dailyEventsFromUsageCounters, + dailyEventsFromUiCounters, + (value: UiCounterEvent | undefined, srcValue: UiCounterEvent): UiCounterEvent => { + if (!value) { + return srcValue; + } + + return { + ...srcValue, + total: srcValue.total + value.total, + }; + } + ); + + return { dailyEvents: Object.values(mergedDailyCounters) }; + }; + +export function registerUiCountersUsageCollector( + usageCollection: UsageCollectionSetup, + stopUsingUiCounterIndicies$: Subject +) { const collector = usageCollection.makeUsageCollector({ type: 'ui_counters', schema: { @@ -76,25 +198,7 @@ export function registerUiCountersUsageCollector(usageCollection: UsageCollectio }, }, }, - fetch: async ({ soClient }: CollectorFetchContext) => { - const { saved_objects: rawUiCounters } = await soClient.find({ - type: UI_COUNTER_SAVED_OBJECT_TYPE, - fields: ['count'], - perPage: 10000, - }); - - return { - dailyEvents: rawUiCounters.reduce((acc, raw) => { - try { - const aggEvent = transformRawCounter(raw); - acc.push(aggEvent); - } catch (_) { - // swallow error; allows sending successfully transformed objects. - } - return acc; - }, [] as UiCounterEvent[]), - }; - }, + fetch: createFetchUiCounters(stopUsingUiCounterIndicies$), isReady: () => true, }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/register_rollups.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/register_rollups.ts index 9595101efb63b..55da239d8ef2a 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/register_rollups.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/register_rollups.ts @@ -6,16 +6,20 @@ * Side Public License, v 1. */ -import { timer } from 'rxjs'; +import { Subject, timer } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; import { Logger, ISavedObjectsRepository } from 'kibana/server'; import { ROLL_INDICES_INTERVAL, ROLL_INDICES_START } from './constants'; import { rollUiCounterIndices } from './rollups'; export function registerUiCountersRollups( logger: Logger, + stopRollingUiCounterIndicies$: Subject, getSavedObjectsClient: () => ISavedObjectsRepository | undefined ) { - timer(ROLL_INDICES_START, ROLL_INDICES_INTERVAL).subscribe(() => - rollUiCounterIndices(logger, getSavedObjectsClient()) - ); + timer(ROLL_INDICES_START, ROLL_INDICES_INTERVAL) + .pipe(takeUntil(stopRollingUiCounterIndicies$)) + .subscribe(() => + rollUiCounterIndices(logger, stopRollingUiCounterIndicies$, getSavedObjectsClient()) + ); } diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/rollups.test.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/rollups.test.ts index 5cb91f7f898c1..f69ddde6a65bd 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/rollups.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/rollups.test.ts @@ -7,9 +7,11 @@ */ import moment from 'moment'; +import * as Rx from 'rxjs'; import { isSavedObjectOlderThan, rollUiCounterIndices } from './rollups'; import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../../core/server/mocks'; import { SavedObjectsFindResult } from 'kibana/server'; + import { UICounterSavedObjectAttributes, UI_COUNTER_SAVED_OBJECT_TYPE, @@ -70,14 +72,18 @@ describe('isSavedObjectOlderThan', () => { describe('rollUiCounterIndices', () => { let logger: ReturnType; let savedObjectClient: ReturnType; + let stopUsingUiCounterIndicies$: Rx.Subject; beforeEach(() => { logger = loggingSystemMock.createLogger(); savedObjectClient = savedObjectsRepositoryMock.create(); + stopUsingUiCounterIndicies$ = new Rx.Subject(); }); it('returns undefined if no savedObjectsClient initialised yet', async () => { - await expect(rollUiCounterIndices(logger, undefined)).resolves.toBe(undefined); + await expect( + rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, undefined) + ).resolves.toBe(undefined); expect(logger.warn).toHaveBeenCalledTimes(0); }); @@ -90,11 +96,27 @@ describe('rollUiCounterIndices', () => { throw new Error(`Unexpected type [${type}]`); } }); - await expect(rollUiCounterIndices(logger, savedObjectClient)).resolves.toEqual([]); + await expect( + rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, savedObjectClient) + ).resolves.toEqual([]); expect(savedObjectClient.find).toBeCalled(); expect(savedObjectClient.delete).not.toBeCalled(); expect(logger.warn).toHaveBeenCalledTimes(0); }); + it('calls Subject complete() on empty saved objects', async () => { + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case UI_COUNTER_SAVED_OBJECT_TYPE: + return { saved_objects: [], total: 0, page, per_page: perPage }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect( + rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, savedObjectClient) + ).resolves.toEqual([]); + expect(stopUsingUiCounterIndicies$.isStopped).toBe(true); + }); it(`deletes documents older than ${UI_COUNTERS_KEEP_DOCS_FOR_DAYS} days`, async () => { const mockSavedObjects = [ @@ -111,7 +133,9 @@ describe('rollUiCounterIndices', () => { throw new Error(`Unexpected type [${type}]`); } }); - await expect(rollUiCounterIndices(logger, savedObjectClient)).resolves.toHaveLength(2); + await expect( + rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, savedObjectClient) + ).resolves.toHaveLength(2); expect(savedObjectClient.find).toBeCalled(); expect(savedObjectClient.delete).toHaveBeenCalledTimes(2); expect(savedObjectClient.delete).toHaveBeenNthCalledWith( @@ -131,7 +155,9 @@ describe('rollUiCounterIndices', () => { savedObjectClient.find.mockImplementation(async () => { throw new Error(`Expected error!`); }); - await expect(rollUiCounterIndices(logger, savedObjectClient)).resolves.toEqual(undefined); + await expect( + rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, savedObjectClient) + ).resolves.toEqual(undefined); expect(savedObjectClient.find).toBeCalled(); expect(savedObjectClient.delete).not.toBeCalled(); expect(logger.warn).toHaveBeenCalledTimes(2); @@ -151,7 +177,9 @@ describe('rollUiCounterIndices', () => { savedObjectClient.delete.mockImplementation(async () => { throw new Error(`Expected error!`); }); - await expect(rollUiCounterIndices(logger, savedObjectClient)).resolves.toEqual(undefined); + await expect( + rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, savedObjectClient) + ).resolves.toEqual(undefined); expect(savedObjectClient.find).toBeCalled(); expect(savedObjectClient.delete).toHaveBeenCalledTimes(1); expect(savedObjectClient.delete).toHaveBeenNthCalledWith( diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/rollups.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/rollups.ts index 3a092f845c3a3..79e7d3e07ba46 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/rollups.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/rollups.ts @@ -8,6 +8,7 @@ import { ISavedObjectsRepository, Logger } from 'kibana/server'; import moment from 'moment'; +import type { Subject } from 'rxjs'; import { UI_COUNTERS_KEEP_DOCS_FOR_DAYS } from './constants'; import { @@ -38,6 +39,7 @@ export function isSavedObjectOlderThan({ export async function rollUiCounterIndices( logger: Logger, + stopUsingUiCounterIndicies$: Subject, savedObjectsClient?: ISavedObjectsRepository ) { if (!savedObjectsClient) { @@ -54,6 +56,20 @@ export async function rollUiCounterIndices( } ); + if (rawUiCounterDocs.length === 0) { + /** + * @deprecated 7.13 to be removed in 8.0.0 + * Stop triggering rollups when we've rolled up all documents. + * + * This Saved Object registry is no longer used. + * Migration from one SO registry to another is not yet supported. + * In a future release we can remove this piece of code and + * migrate any docs to the Usage Counters Saved object. + */ + + stopUsingUiCounterIndicies$.complete(); + } + const docsToDelete = rawUiCounterDocs.filter((doc) => isSavedObjectOlderThan({ numberOfDays: UI_COUNTERS_KEEP_DOCS_FOR_DAYS, diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts index 77413cc7d7d9d..51ecbf736bfc1 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts @@ -11,7 +11,7 @@ import { Collector, createUsageCollectionSetupMock, createCollectorFetchContextMock, -} from '../../../../usage_collection/server/usage_collection.mock'; +} from '../../../../usage_collection/server/mocks'; import { registerUiMetricUsageCollector } from './'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/__fixtures__/usage_counter_saved_objects.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/__fixtures__/usage_counter_saved_objects.ts new file mode 100644 index 0000000000000..d0a45fb86b1f8 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/__fixtures__/usage_counter_saved_objects.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { UsageCountersSavedObject } from '../../../../../usage_collection/server'; + +export const rawUsageCounters: UsageCountersSavedObject[] = [ + { + type: 'usage-counters', + id: 'uiCounter:09042021:count:myApp:my_event', + attributes: { + count: 13, + counterName: 'my_event', + counterType: 'count', + domainId: 'uiCounter', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-09T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'anotherDomainId:09042021:count:some_event_name', + attributes: { + count: 4, + counterName: 'some_event_name', + counterType: 'count', + domainId: 'anotherDomainId', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-09T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'anotherDomainId:09042021:count:some_event_name', + attributes: { + count: 4, + counterName: 'some_event_name', + counterType: 'count', + domainId: 'anotherDomainId', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-11T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'anotherDomainId2:09042021:count:some_event_name', + attributes: { + count: 1, + counterName: 'some_event_name', + counterType: 'count', + domainId: 'anotherDomainId2', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-20T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'anotherDomainId2:09042021:count:malformed_event', + attributes: { + // @ts-expect-error + count: 'malformed', + counterName: 'malformed_event', + counterType: 'count', + domainId: 'anotherDomainId2', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-20T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'anotherDomainId2:09042021:custom_type:some_event_name', + attributes: { + count: 3, + counterName: 'some_event_name', + counterType: 'custom_type', + domainId: 'anotherDomainId2', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-20T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'anotherDomainId3:09042021:custom_type:zero_count', + attributes: { + count: 0, + counterName: 'zero_count', + counterType: 'custom_type', + domainId: 'anotherDomainId3', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-20T08:18:03.030Z', + }, +]; diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/index.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/index.ts new file mode 100644 index 0000000000000..1873fae42e54a --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/index.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { registerUsageCountersUsageCollector } from './register_usage_counters_collector'; +export { registerUsageCountersRollups } from './rollups'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/register_usage_counters_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/register_usage_counters_collector.test.ts new file mode 100644 index 0000000000000..945eb007fe23f --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/register_usage_counters_collector.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { transformRawCounter } from './register_usage_counters_collector'; +import { rawUsageCounters } from './__fixtures__/usage_counter_saved_objects'; + +describe('transformRawCounter', () => { + it('transforms saved object raw entries', () => { + const result = rawUsageCounters.map(transformRawCounter); + expect(result).toMatchInlineSnapshot(` + Array [ + undefined, + Object { + "counterName": "some_event_name", + "counterType": "count", + "domainId": "anotherDomainId", + "fromTimestamp": "2021-04-09T00:00:00Z", + "lastUpdatedAt": "2021-04-09T08:18:03.030Z", + "total": 4, + }, + Object { + "counterName": "some_event_name", + "counterType": "count", + "domainId": "anotherDomainId", + "fromTimestamp": "2021-04-11T00:00:00Z", + "lastUpdatedAt": "2021-04-11T08:18:03.030Z", + "total": 4, + }, + Object { + "counterName": "some_event_name", + "counterType": "count", + "domainId": "anotherDomainId2", + "fromTimestamp": "2021-04-20T00:00:00Z", + "lastUpdatedAt": "2021-04-20T08:18:03.030Z", + "total": 1, + }, + undefined, + Object { + "counterName": "some_event_name", + "counterType": "custom_type", + "domainId": "anotherDomainId2", + "fromTimestamp": "2021-04-20T00:00:00Z", + "lastUpdatedAt": "2021-04-20T08:18:03.030Z", + "total": 3, + }, + undefined, + ] + `); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/register_usage_counters_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/register_usage_counters_collector.ts new file mode 100644 index 0000000000000..9c6db00fb3597 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/register_usage_counters_collector.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import moment from 'moment'; +import { + CollectorFetchContext, + UsageCollectionSetup, + USAGE_COUNTERS_SAVED_OBJECT_TYPE, + UsageCountersSavedObject, + UsageCountersSavedObjectAttributes, +} from '../../../../usage_collection/server'; + +interface UsageCounterEvent { + domainId: string; + counterName: string; + counterType: string; + lastUpdatedAt?: string; + fromTimestamp?: string; + total: number; +} + +export interface UiCountersUsage { + dailyEvents: UsageCounterEvent[]; +} + +export function transformRawCounter( + rawUsageCounter: UsageCountersSavedObject +): UsageCounterEvent | undefined { + const { + attributes: { count, counterName, counterType, domainId }, + updated_at: lastUpdatedAt, + } = rawUsageCounter; + const fromTimestamp = moment(lastUpdatedAt).utc().startOf('day').format(); + + if (domainId === 'uiCounter' || typeof count !== 'number' || count < 1) { + return; + } + + return { + domainId, + counterName, + counterType, + lastUpdatedAt, + fromTimestamp, + total: count, + }; +} + +export function registerUsageCountersUsageCollector(usageCollection: UsageCollectionSetup) { + const collector = usageCollection.makeUsageCollector({ + type: 'usage_counters', + schema: { + dailyEvents: { + type: 'array', + items: { + domainId: { + type: 'keyword', + _meta: { description: 'Domain name of the metric (ie plugin name).' }, + }, + counterName: { + type: 'keyword', + _meta: { description: 'Name of the counter that happened.' }, + }, + lastUpdatedAt: { + type: 'date', + _meta: { description: 'Time at which the metric was last updated.' }, + }, + fromTimestamp: { + type: 'date', + _meta: { description: 'Time at which the metric was captured.' }, + }, + counterType: { + type: 'keyword', + _meta: { description: 'The type of counter used.' }, + }, + total: { + type: 'integer', + _meta: { description: 'The total number of times the event happened.' }, + }, + }, + }, + }, + fetch: async ({ soClient }: CollectorFetchContext) => { + const { + saved_objects: rawUsageCounters, + } = await soClient.find({ + type: USAGE_COUNTERS_SAVED_OBJECT_TYPE, + fields: ['count', 'counterName', 'counterType', 'domainId'], + filter: `NOT ${USAGE_COUNTERS_SAVED_OBJECT_TYPE}.attributes.domainId: uiCounter`, + perPage: 10000, + }); + + return { + dailyEvents: rawUsageCounters.reduce((acc, rawUsageCounter) => { + try { + const event = transformRawCounter(rawUsageCounter); + if (event) { + acc.push(event); + } + } catch (_) { + // swallow error; allows sending successfully transformed objects. + } + return acc; + }, [] as UsageCounterEvent[]), + }; + }, + isReady: () => true, + }); + + usageCollection.registerCollector(collector); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/constants.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/constants.ts new file mode 100644 index 0000000000000..1c1ca3f466df2 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/constants.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Roll indices every 24h + */ +export const ROLL_INDICES_INTERVAL = 24 * 60 * 60 * 1000; + +/** + * Start rolling indices after 5 minutes up + */ +export const ROLL_INDICES_START = 5 * 60 * 1000; + +/** + * Number of days to keep the Usage counters saved object documents + */ +export const USAGE_COUNTERS_KEEP_DOCS_FOR_DAYS = 5; diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/index.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/index.ts new file mode 100644 index 0000000000000..bf15f4d875860 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { registerUsageCountersRollups } from './register_rollups'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/register_rollups.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/register_rollups.ts new file mode 100644 index 0000000000000..30ad993d54a8e --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/register_rollups.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { timer } from 'rxjs'; +import { Logger, ISavedObjectsRepository } from 'kibana/server'; +import { ROLL_INDICES_INTERVAL, ROLL_INDICES_START } from './constants'; +import { rollUsageCountersIndices } from './rollups'; + +export function registerUsageCountersRollups( + logger: Logger, + getSavedObjectsClient: () => ISavedObjectsRepository | undefined +) { + timer(ROLL_INDICES_START, ROLL_INDICES_INTERVAL).subscribe(() => + rollUsageCountersIndices(logger, getSavedObjectsClient()) + ); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/rollups.test.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/rollups.test.ts new file mode 100644 index 0000000000000..c6cdaae20a8bc --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/rollups.test.ts @@ -0,0 +1,170 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import moment from 'moment'; +import { isSavedObjectOlderThan, rollUsageCountersIndices } from './rollups'; +import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../../core/server/mocks'; +import { SavedObjectsFindResult } from '../../../../../../core/server'; + +import { + UsageCountersSavedObjectAttributes, + USAGE_COUNTERS_SAVED_OBJECT_TYPE, +} from '../../../../../usage_collection/server'; + +import { USAGE_COUNTERS_KEEP_DOCS_FOR_DAYS } from './constants'; + +const createMockSavedObjectDoc = (updatedAt: moment.Moment, id: string) => + ({ + id, + type: 'usage-counter', + attributes: { + count: 3, + counterName: 'testName', + counterType: 'count', + domainId: 'testDomain', + }, + references: [], + updated_at: updatedAt.format(), + version: 'WzI5LDFd', + score: 0, + } as SavedObjectsFindResult); + +describe('isSavedObjectOlderThan', () => { + it(`returns true if doc is older than x days`, () => { + const numberOfDays = 1; + const startDate = moment().format(); + const doc = createMockSavedObjectDoc(moment().subtract(2, 'days'), 'some-id'); + const result = isSavedObjectOlderThan({ + numberOfDays, + startDate, + doc, + }); + expect(result).toBe(true); + }); + + it(`returns false if doc is exactly x days old`, () => { + const numberOfDays = 1; + const startDate = moment().format(); + const doc = createMockSavedObjectDoc(moment().subtract(1, 'days'), 'some-id'); + const result = isSavedObjectOlderThan({ + numberOfDays, + startDate, + doc, + }); + expect(result).toBe(false); + }); + + it(`returns false if doc is younger than x days`, () => { + const numberOfDays = 2; + const startDate = moment().format(); + const doc = createMockSavedObjectDoc(moment().subtract(1, 'days'), 'some-id'); + const result = isSavedObjectOlderThan({ + numberOfDays, + startDate, + doc, + }); + expect(result).toBe(false); + }); +}); + +describe('rollUsageCountersIndices', () => { + let logger: ReturnType; + let savedObjectClient: ReturnType; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + savedObjectClient = savedObjectsRepositoryMock.create(); + }); + + it('returns undefined if no savedObjectsClient initialised yet', async () => { + await expect(rollUsageCountersIndices(logger, undefined)).resolves.toBe(undefined); + expect(logger.warn).toHaveBeenCalledTimes(0); + }); + + it('does not delete any documents on empty saved objects', async () => { + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case USAGE_COUNTERS_SAVED_OBJECT_TYPE: + return { saved_objects: [], total: 0, page, per_page: perPage }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollUsageCountersIndices(logger, savedObjectClient)).resolves.toEqual([]); + expect(savedObjectClient.find).toBeCalled(); + expect(savedObjectClient.delete).not.toBeCalled(); + expect(logger.warn).toHaveBeenCalledTimes(0); + }); + + it(`deletes documents older than ${USAGE_COUNTERS_KEEP_DOCS_FOR_DAYS} days`, async () => { + const mockSavedObjects = [ + createMockSavedObjectDoc(moment().subtract(5, 'days'), 'doc-id-1'), + createMockSavedObjectDoc(moment().subtract(9, 'days'), 'doc-id-1'), + createMockSavedObjectDoc(moment().subtract(1, 'days'), 'doc-id-2'), + createMockSavedObjectDoc(moment().subtract(6, 'days'), 'doc-id-3'), + ]; + + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case USAGE_COUNTERS_SAVED_OBJECT_TYPE: + return { saved_objects: mockSavedObjects, total: 0, page, per_page: perPage }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollUsageCountersIndices(logger, savedObjectClient)).resolves.toHaveLength(2); + expect(savedObjectClient.find).toBeCalled(); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(2); + expect(savedObjectClient.delete).toHaveBeenNthCalledWith( + 1, + USAGE_COUNTERS_SAVED_OBJECT_TYPE, + 'doc-id-1' + ); + expect(savedObjectClient.delete).toHaveBeenNthCalledWith( + 2, + USAGE_COUNTERS_SAVED_OBJECT_TYPE, + 'doc-id-3' + ); + expect(logger.warn).toHaveBeenCalledTimes(0); + }); + + it(`logs warnings on savedObject.find failure`, async () => { + savedObjectClient.find.mockImplementation(async () => { + throw new Error(`Expected error!`); + }); + await expect(rollUsageCountersIndices(logger, savedObjectClient)).resolves.toEqual(undefined); + expect(savedObjectClient.find).toBeCalled(); + expect(savedObjectClient.delete).not.toBeCalled(); + expect(logger.warn).toHaveBeenCalledTimes(2); + }); + + it(`logs warnings on savedObject.delete failure`, async () => { + const mockSavedObjects = [createMockSavedObjectDoc(moment().subtract(7, 'days'), 'doc-id-1')]; + + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case USAGE_COUNTERS_SAVED_OBJECT_TYPE: + return { saved_objects: mockSavedObjects, total: 0, page, per_page: perPage }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + savedObjectClient.delete.mockImplementation(async () => { + throw new Error(`Expected error!`); + }); + await expect(rollUsageCountersIndices(logger, savedObjectClient)).resolves.toEqual(undefined); + expect(savedObjectClient.find).toBeCalled(); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(1); + expect(savedObjectClient.delete).toHaveBeenNthCalledWith( + 1, + USAGE_COUNTERS_SAVED_OBJECT_TYPE, + 'doc-id-1' + ); + expect(logger.warn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/rollups.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/rollups.ts new file mode 100644 index 0000000000000..c07ea37536f2d --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/rollups.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ISavedObjectsRepository, Logger } from 'kibana/server'; +import moment from 'moment'; + +import { USAGE_COUNTERS_KEEP_DOCS_FOR_DAYS } from './constants'; + +import { + UsageCountersSavedObject, + USAGE_COUNTERS_SAVED_OBJECT_TYPE, +} from '../../../../../usage_collection/server'; + +export function isSavedObjectOlderThan({ + numberOfDays, + startDate, + doc, +}: { + numberOfDays: number; + startDate: moment.Moment | string | number; + doc: Pick; +}): boolean { + const { updated_at: updatedAt } = doc; + const today = moment(startDate).startOf('day'); + const updateDay = moment(updatedAt).startOf('day'); + + const diffInDays = today.diff(updateDay, 'days'); + if (diffInDays > numberOfDays) { + return true; + } + + return false; +} + +export async function rollUsageCountersIndices( + logger: Logger, + savedObjectsClient?: ISavedObjectsRepository +) { + if (!savedObjectsClient) { + return; + } + + const now = moment(); + + try { + const { + saved_objects: rawUiCounterDocs, + } = await savedObjectsClient.find({ + type: USAGE_COUNTERS_SAVED_OBJECT_TYPE, + perPage: 1000, // Process 1000 at a time as a compromise of speed and overload + }); + + const docsToDelete = rawUiCounterDocs.filter((doc) => + isSavedObjectOlderThan({ + numberOfDays: USAGE_COUNTERS_KEEP_DOCS_FOR_DAYS, + startDate: now, + doc, + }) + ); + + return await Promise.all( + docsToDelete.map(({ id }) => savedObjectsClient.delete(USAGE_COUNTERS_SAVED_OBJECT_TYPE, id)) + ); + } catch (err) { + logger.warn(`Failed to rollup Usage Counters saved objects.`); + logger.warn(err); + } +} diff --git a/src/plugins/kibana_usage_collection/server/index.test.mocks.ts b/src/plugins/kibana_usage_collection/server/mocks.ts similarity index 100% rename from src/plugins/kibana_usage_collection/server/index.test.mocks.ts rename to src/plugins/kibana_usage_collection/server/mocks.ts diff --git a/src/plugins/kibana_usage_collection/server/index.test.ts b/src/plugins/kibana_usage_collection/server/plugin.test.ts similarity index 59% rename from src/plugins/kibana_usage_collection/server/index.test.ts rename to src/plugins/kibana_usage_collection/server/plugin.test.ts index b4c52f8353d79..86204ed30e656 100644 --- a/src/plugins/kibana_usage_collection/server/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.test.ts @@ -14,8 +14,8 @@ import { import { CollectorOptions, createUsageCollectionSetupMock, -} from '../../usage_collection/server/usage_collection.mock'; -import { cloudDetailsMock } from './index.test.mocks'; +} from '../../usage_collection/server/mocks'; +import { cloudDetailsMock } from './mocks'; import { plugin } from './'; @@ -38,13 +38,67 @@ describe('kibana_usage_collection', () => { cloudDetailsMock.mockClear(); }); - test('Runs the setup method without issues', () => { + test('Runs the setup method without issues', async () => { const coreSetup = coreMock.createSetup(); expect(pluginInstance.setup(coreSetup, { usageCollection })).toBe(undefined); - usageCollectors.forEach(({ isReady }) => { - expect(isReady()).toMatchSnapshot(); // Some should return false at this stage - }); + + await expect( + Promise.all( + usageCollectors.map(async (usageCollector) => { + const isReady = await usageCollector.isReady(); + const type = usageCollector.type; + return { type, isReady }; + }) + ) + ).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "isReady": true, + "type": "ui_counters", + }, + Object { + "isReady": true, + "type": "usage_counters", + }, + Object { + "isReady": false, + "type": "kibana_stats", + }, + Object { + "isReady": true, + "type": "kibana", + }, + Object { + "isReady": false, + "type": "stack_management", + }, + Object { + "isReady": false, + "type": "ui_metric", + }, + Object { + "isReady": false, + "type": "application_usage", + }, + Object { + "isReady": false, + "type": "cloud_provider", + }, + Object { + "isReady": true, + "type": "csp", + }, + Object { + "isReady": false, + "type": "core", + }, + Object { + "isReady": true, + "type": "localization", + }, + ] + `); }); test('Runs the start method without issues', () => { diff --git a/src/plugins/kibana_usage_collection/server/plugin.ts b/src/plugins/kibana_usage_collection/server/plugin.ts index 74d2d281ff8f6..a27b8dff57b67 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.ts @@ -35,6 +35,8 @@ import { registerUiCountersUsageCollector, registerUiCounterSavedObjectType, registerUiCountersRollups, + registerUsageCountersRollups, + registerUsageCountersUsageCollector, } from './collectors'; interface KibanaUsageCollectionPluginsDepsSetup { @@ -50,18 +52,23 @@ export class KibanaUsageCollectionPlugin implements Plugin { private uiSettingsClient?: IUiSettingsClient; private metric$: Subject; private coreUsageData?: CoreUsageDataStart; + private stopUsingUiCounterIndicies$: Subject; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); this.legacyConfig$ = initializerContext.config.legacy.globalConfig$; this.metric$ = new Subject(); + this.stopUsingUiCounterIndicies$ = new Subject(); } public setup(coreSetup: CoreSetup, { usageCollection }: KibanaUsageCollectionPluginsDepsSetup) { + usageCollection.createUsageCounter('uiCounters'); + this.registerUsageCollectors( usageCollection, coreSetup, this.metric$, + this.stopUsingUiCounterIndicies$, coreSetup.savedObjects.registerType.bind(coreSetup.savedObjects) ); } @@ -77,12 +84,14 @@ export class KibanaUsageCollectionPlugin implements Plugin { public stop() { this.metric$.complete(); + this.stopUsingUiCounterIndicies$.complete(); } private registerUsageCollectors( usageCollection: UsageCollectionSetup, coreSetup: CoreSetup, metric$: Subject, + stopUsingUiCounterIndicies$: Subject, registerType: SavedObjectsRegisterType ) { const getSavedObjectsClient = () => this.savedObjectsClient; @@ -90,8 +99,15 @@ export class KibanaUsageCollectionPlugin implements Plugin { const getCoreUsageDataService = () => this.coreUsageData!; registerUiCounterSavedObjectType(coreSetup.savedObjects); - registerUiCountersRollups(this.logger.get('ui-counters'), getSavedObjectsClient); - registerUiCountersUsageCollector(usageCollection); + registerUiCountersRollups( + this.logger.get('ui-counters'), + stopUsingUiCounterIndicies$, + getSavedObjectsClient + ); + registerUiCountersUsageCollector(usageCollection, stopUsingUiCounterIndicies$); + + registerUsageCountersRollups(this.logger.get('usage-counters-rollup'), getSavedObjectsClient); + registerUsageCountersUsageCollector(usageCollection); registerOpsStatsCollector(usageCollection, metric$); registerKibanaUsageCollector(usageCollection, this.legacyConfig$); diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 41b75824e992d..56b7d98deaef8 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -9308,6 +9308,53 @@ } } }, + "usage_counters": { + "properties": { + "dailyEvents": { + "type": "array", + "items": { + "properties": { + "domainId": { + "type": "keyword", + "_meta": { + "description": "Domain name of the metric (ie plugin name)." + } + }, + "counterName": { + "type": "keyword", + "_meta": { + "description": "Name of the counter that happened." + } + }, + "lastUpdatedAt": { + "type": "date", + "_meta": { + "description": "Time at which the metric was last updated." + } + }, + "fromTimestamp": { + "type": "date", + "_meta": { + "description": "Time at which the metric was captured." + } + }, + "counterType": { + "type": "keyword", + "_meta": { + "description": "The type of counter used." + } + }, + "total": { + "type": "integer", + "_meta": { + "description": "The total number of times the event happened." + } + } + } + } + } + } + }, "telemetry": { "properties": { "opt_in_status": { diff --git a/src/plugins/usage_collection/README.mdx b/src/plugins/usage_collection/README.mdx index 04e1e0fbb5006..a6f6f6c8e5971 100644 --- a/src/plugins/usage_collection/README.mdx +++ b/src/plugins/usage_collection/README.mdx @@ -20,6 +20,7 @@ The way to report the usage of any feature depends on whether the actions to tra In any case, to use any of these APIs, the plugin must optionally require the plugin `usageCollection`: + ```json // plugin/kibana.json { @@ -112,6 +113,100 @@ Not an API as such. However, Data Telemetry collects the usage of known patterns This collector does not report the name of the indices nor any content. It only provides stats about usage of known shippers/ingest tools. +#### Usage Counters + +Usage counters allows plugins to report user triggered events from the server. This api has feature parity with UI Counters on the `public` plugin side of usage_collection. + +Usage counters provide instrumentation on the server to count triggered events such as "api called", "threshold reached", and miscellaneous events count. + +It is useful for gathering _semi-aggregated_ events with a per day granularity. +This allows tracking trends in usage and provides enough granularity for this type of telemetry to provide insights such as +- "How many times this threshold has been reached?" +- "What is the trend in usage of this api?" +- "How frequent are users hitting this error per day?" +- "What is the success rate of this operation?" +- "Which option is being selected the most/least?" + +##### How to use it + +To create a usage counter for your plugin, use the API `usageCollection.createUsageCounter` as follows: + +```ts +// server/plugin.ts +import type { Plugin, CoreStart } from '../../../core/server'; +import type { UsageCollectionSetup, UsageCounter } from '../../../plugins/usage_collection/server'; + +export class MyPlugin implements Plugin { + private usageCounter?: UsageCounter; + public setup( + core: CoreStart, + { usageCollection }: { usageCollection?: UsageCollectionSetup } + ) { + + /** + * Create a usage counter for this plugin. Domain ID must be unique. + * It is advised to use the plugin name as the domain ID for most cases. + */ + this.usageCounter = usageCollection?.createUsageCounter(''); + try { + doSomeOperation(); + this.usageCounter?.incrementCounter({ + counterName: 'doSomeOperation_success', + incrementBy: 1, + }); + } catch (err) { + this.usageCounter?.incrementCounter({ + counterName: 'doSomeOperation_error', + counterType: 'error', + incrementBy: 1, + }); + logger.error(err); + } + } +} +``` + +Pass the created `usageCounter` around in your service to instrument usage. + +That's all you need to do! The Usage counters service will handle piping these counters all the way to the telemetry service. + +##### Telemetry reported usage + +Usage counters are reported inside the telemetry usage payload under `stack_stats.kibana.plugins.usage_counters`. + +```ts +{ + usage_counters: { + dailyEvents: [ + { + domainId: '', + counterName: 'doSomeOperation_success', + counterType: 'count', + lastUpdatedAt: '2021-11-20T11:43:00.961Z', + fromTimestamp: '2021-11-20T00:00:00Z', + total: 3, + }, + { + domainId: '', + counterName: 'doSomeOperation_success', + counterType: 'count', + lastUpdatedAt: '2021-11-21T10:30:00.961Z', + fromTimestamp: '2021-11-21T00:00:00Z', + total: 5, + }, + { + domainId: '', + counterName: 'doSomeOperation_error', + counterType: 'error', + lastUpdatedAt: '2021-11-20T11:43:00.961Z', + fromTimestamp: '2021-11-20T00:00:00Z', + total: 1, + }, + ], + }, +} +``` + #### Custom collector In many cases, plugins need to report the custom usage of a feature. In this cases, the plugins must complete the following 2 steps in the `setup` lifecycle step: diff --git a/src/plugins/usage_collection/common/ui_counters.ts b/src/plugins/usage_collection/common/ui_counters.ts new file mode 100644 index 0000000000000..3ed6e44aee419 --- /dev/null +++ b/src/plugins/usage_collection/common/ui_counters.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const serializeUiCounterName = ({ + appName, + eventName, +}: { + appName: string; + eventName: string; +}) => { + return `${appName}:${eventName}`; +}; + +export const deserializeUiCounterName = (key: string) => { + const [appName, ...restKey] = key.split(':'); + const eventName = restKey.join(':'); + return { appName, eventName }; +}; diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index 32a58a6657eec..4de5691eaaa70 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -25,22 +25,6 @@ interface CollectorSetConfig { collectors?: AnyCollector[]; } -/** - * Public interface of the CollectorSet (makes it easier to mock only the public methods) - */ -export type CollectorSetPublic = Pick< - CollectorSet, - | 'makeStatsCollector' - | 'makeUsageCollector' - | 'registerCollector' - | 'getCollectorByType' - | 'areAllCollectorsReady' - | 'bulkFetch' - | 'bulkFetchUsage' - | 'toObject' - | 'toApiFieldNames' ->; - export class CollectorSet { private _waitingForAllCollectorsTimestamp?: number; private readonly logger: Logger; @@ -215,19 +199,19 @@ export class CollectorSet { * Convert an array of fetched stats results into key/object * @param statsData Array of fetched stats results */ - public toObject, T = unknown>( + public toObject = , T = unknown>( statsData: Array<{ type: string; result: T }> = [] - ): Result { + ): Result => { return Object.fromEntries(statsData.map(({ type, result }) => [type, result])) as Result; - } + }; /** * Rename fields to use API conventions * @param apiData Data to be normalized */ - public toApiFieldNames( + public toApiFieldNames = ( apiData: Record | unknown[] - ): Record | unknown[] { + ): Record | unknown[] => { // handle array and return early, or return a reduced object if (Array.isArray(apiData)) { return apiData.map((value) => this.getValueOrRecurse(value)); @@ -244,14 +228,14 @@ export class CollectorSet { return [newName, this.getValueOrRecurse(value)]; }) ); - } + }; - private getValueOrRecurse(value: unknown) { + private getValueOrRecurse = (value: unknown) => { if (Array.isArray(value) || (typeof value === 'object' && value !== null)) { return this.toApiFieldNames(value as Record | unknown[]); // recurse } return value; - } + }; private makeCollectorSetFromArray = (collectors: AnyCollector[]) => { return new CollectorSet({ diff --git a/src/plugins/usage_collection/server/collector/index.ts b/src/plugins/usage_collection/server/collector/index.ts index d5e0d95659e58..594455f70fdf8 100644 --- a/src/plugins/usage_collection/server/collector/index.ts +++ b/src/plugins/usage_collection/server/collector/index.ts @@ -7,7 +7,6 @@ */ export { CollectorSet } from './collector_set'; -export type { CollectorSetPublic } from './collector_set'; export { Collector } from './collector'; export type { AllowedSchemaTypes, diff --git a/src/plugins/usage_collection/server/config.ts b/src/plugins/usage_collection/server/config.ts index ff6ea8424ba61..cd6f6b9d81396 100644 --- a/src/plugins/usage_collection/server/config.ts +++ b/src/plugins/usage_collection/server/config.ts @@ -11,6 +11,11 @@ import { PluginConfigDescriptor } from 'src/core/server'; import { DEFAULT_MAXIMUM_WAIT_TIME_FOR_ALL_COLLECTORS_IN_S } from '../common/constants'; export const configSchema = schema.object({ + usageCounters: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + retryCount: schema.number({ defaultValue: 1 }), + bufferDuration: schema.duration({ defaultValue: '5s' }), + }), uiCounters: schema.object({ enabled: schema.boolean({ defaultValue: true }), debug: schema.boolean({ defaultValue: schema.contextRef('dev') }), diff --git a/src/plugins/usage_collection/server/index.ts b/src/plugins/usage_collection/server/index.ts index dd9e6644a827d..b5441a8b7b34d 100644 --- a/src/plugins/usage_collection/server/index.ts +++ b/src/plugins/usage_collection/server/index.ts @@ -18,6 +18,19 @@ export type { UsageCollectorOptions, CollectorFetchContext, } from './collector'; + +export type { + UsageCountersSavedObject, + UsageCountersSavedObjectAttributes, + IncrementCounterParams, +} from './usage_counters'; + +export { + USAGE_COUNTERS_SAVED_OBJECT_TYPE, + serializeCounterKey, + UsageCounter, +} from './usage_counters'; + export type { UsageCollectionSetup } from './plugin'; export { config } from './config'; export const plugin = (initializerContext: PluginInitializerContext) => diff --git a/src/plugins/usage_collection/server/mocks.ts b/src/plugins/usage_collection/server/mocks.ts index e5ad102263626..b84fa0f0aab70 100644 --- a/src/plugins/usage_collection/server/mocks.ts +++ b/src/plugins/usage_collection/server/mocks.ts @@ -6,20 +6,61 @@ * Side Public License, v 1. */ -import { loggingSystemMock } from '../../../core/server/mocks'; -import { UsageCollectionSetup } from './plugin'; -import { CollectorSet } from './collector'; -export { Collector, createCollectorFetchContextMock } from './usage_collection.mock'; - -const createSetupContract = () => { - return { - ...new CollectorSet({ - logger: loggingSystemMock.createLogger(), - maximumWaitTimeForAllCollectorsInS: 1, - }), - } as UsageCollectionSetup; +import { + elasticsearchServiceMock, + httpServerMock, + loggingSystemMock, + savedObjectsClientMock, +} from '../../../../src/core/server/mocks'; + +import { CollectorOptions, Collector, CollectorSet } from './collector'; +import { UsageCollectionSetup, CollectorFetchContext } from './index'; + +export type { CollectorOptions }; +export { Collector }; + +export const createUsageCollectionSetupMock = () => { + const collectorSet = new CollectorSet({ + logger: loggingSystemMock.createLogger(), + maximumWaitTimeForAllCollectorsInS: 1, + }); + + const usageCollectionSetupMock: jest.Mocked = { + createUsageCounter: jest.fn(), + getUsageCounterByType: jest.fn(), + areAllCollectorsReady: jest.fn().mockImplementation(collectorSet.areAllCollectorsReady), + bulkFetch: jest.fn().mockImplementation(collectorSet.bulkFetch), + getCollectorByType: jest.fn().mockImplementation(collectorSet.getCollectorByType), + toApiFieldNames: jest.fn().mockImplementation(collectorSet.toApiFieldNames), + toObject: jest.fn().mockImplementation(collectorSet.toObject), + makeStatsCollector: jest.fn().mockImplementation(collectorSet.makeStatsCollector), + makeUsageCollector: jest.fn().mockImplementation(collectorSet.makeUsageCollector), + registerCollector: jest.fn().mockImplementation(collectorSet.registerCollector), + }; + + usageCollectionSetupMock.areAllCollectorsReady.mockResolvedValue(true); + return usageCollectionSetupMock; }; +export function createCollectorFetchContextMock(): jest.Mocked> { + const collectorFetchClientsMock: jest.Mocked> = { + esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, + soClient: savedObjectsClientMock.create(), + }; + return collectorFetchClientsMock; +} + +export function createCollectorFetchContextWithKibanaMock(): jest.Mocked< + CollectorFetchContext +> { + const collectorFetchClientsMock: jest.Mocked> = { + esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, + soClient: savedObjectsClientMock.create(), + kibanaRequest: httpServerMock.createKibanaRequest(), + }; + return collectorFetchClientsMock; +} + export const usageCollectionPluginMock = { - createSetupContract, + createSetupContract: createUsageCollectionSetupMock, }; diff --git a/src/plugins/usage_collection/server/plugin.ts b/src/plugins/usage_collection/server/plugin.ts index a44365ae9be9a..37d7327aed662 100644 --- a/src/plugins/usage_collection/server/plugin.ts +++ b/src/plugins/usage_collection/server/plugin.ts @@ -15,30 +15,78 @@ import { Plugin, } from 'src/core/server'; import { ConfigType } from './config'; -import { CollectorSet, CollectorSetPublic } from './collector'; +import { CollectorSet } from './collector'; import { setupRoutes } from './routes'; -export type UsageCollectionSetup = CollectorSetPublic; -export class UsageCollectionPlugin implements Plugin { +import { UsageCountersService } from './usage_counters'; +import type { UsageCountersServiceSetup } from './usage_counters'; + +export interface UsageCollectionSetup { + /** + * Creates and registers a usage counter to collect daily aggregated plugin counter events + */ + createUsageCounter: UsageCountersServiceSetup['createUsageCounter']; + /** + * Returns a usage counter by type + */ + getUsageCounterByType: UsageCountersServiceSetup['getUsageCounterByType']; + /** + * Creates a usage collector to collect plugin telemetry data. + * registerCollector must be called to connect the created collecter with the service. + */ + makeUsageCollector: CollectorSet['makeUsageCollector']; + /** + * Register a usage collector or a stats collector. + * Used to connect the created collector to telemetry. + */ + registerCollector: CollectorSet['registerCollector']; + /** + * Returns a usage collector by type + */ + getCollectorByType: CollectorSet['getCollectorByType']; + /* internal: telemetry use */ + areAllCollectorsReady: CollectorSet['areAllCollectorsReady']; + /* internal: telemetry use */ + bulkFetch: CollectorSet['bulkFetch']; + /* internal: telemetry use */ + toObject: CollectorSet['toObject']; + /* internal: monitoring use */ + toApiFieldNames: CollectorSet['toApiFieldNames']; + /* internal: telemtery and monitoring use */ + makeStatsCollector: CollectorSet['makeStatsCollector']; +} + +export class UsageCollectionPlugin implements Plugin { private readonly logger: Logger; private savedObjects?: ISavedObjectsRepository; + private usageCountersService?: UsageCountersService; + constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); } - public setup(core: CoreSetup) { + public setup(core: CoreSetup): UsageCollectionSetup { const config = this.initializerContext.config.get(); const collectorSet = new CollectorSet({ - logger: this.logger.get('collector-set'), + logger: this.logger.get('usage-collection', 'collector-set'), maximumWaitTimeForAllCollectorsInS: config.maximumWaitTimeForAllCollectorsInS, }); - const globalConfig = this.initializerContext.config.legacy.get(); + this.usageCountersService = new UsageCountersService({ + logger: this.logger.get('usage-collection', 'usage-counters-service'), + retryCount: config.usageCounters.retryCount, + bufferDurationMs: config.usageCounters.bufferDuration.asMilliseconds(), + }); + + const { createUsageCounter, getUsageCounterByType } = this.usageCountersService.setup(core); + const uiCountersUsageCounter = createUsageCounter('uiCounter'); + const globalConfig = this.initializerContext.config.legacy.get(); const router = core.http.createRouter(); setupRoutes({ router, + uiCountersUsageCounter, getSavedObjects: () => this.savedObjects, collectorSet, config: { @@ -52,15 +100,38 @@ export class UsageCollectionPlugin implements Plugin { overallStatus$: core.status.overall$, }); - return collectorSet; + return { + areAllCollectorsReady: collectorSet.areAllCollectorsReady, + bulkFetch: collectorSet.bulkFetch, + getCollectorByType: collectorSet.getCollectorByType, + makeStatsCollector: collectorSet.makeStatsCollector, + makeUsageCollector: collectorSet.makeUsageCollector, + registerCollector: collectorSet.registerCollector, + toApiFieldNames: collectorSet.toApiFieldNames, + toObject: collectorSet.toObject, + createUsageCounter, + getUsageCounterByType, + }; } public start({ savedObjects }: CoreStart) { this.logger.debug('Starting plugin'); + const config = this.initializerContext.config.get(); + if (!this.usageCountersService) { + throw new Error('plugin setup must be called first.'); + } + this.savedObjects = savedObjects.createInternalRepository(); + if (config.usageCounters.enabled) { + this.usageCountersService.start({ savedObjects }); + } else { + // call stop() to complete observers. + this.usageCountersService.stop(); + } } public stop() { this.logger.debug('Stopping plugin'); + this.usageCountersService?.stop(); } } diff --git a/src/plugins/usage_collection/server/report/store_report.test.ts b/src/plugins/usage_collection/server/report/store_report.test.ts index dfcdd1f8e7e42..08fdec4ae804f 100644 --- a/src/plugins/usage_collection/server/report/store_report.test.ts +++ b/src/plugins/usage_collection/server/report/store_report.test.ts @@ -12,11 +12,11 @@ import { savedObjectsRepositoryMock } from '../../../../core/server/mocks'; import { storeReport } from './store_report'; import { ReportSchemaType } from './schema'; import { METRIC_TYPE } from '@kbn/analytics'; -import moment from 'moment'; +import { usageCountersServiceMock } from '../usage_counters/usage_counters_service.mock'; describe('store_report', () => { - const momentTimestamp = moment(); - const date = momentTimestamp.format('DDMMYYYY'); + const usageCountersServiceSetup = usageCountersServiceMock.createSetupContract(); + const uiCountersUsageCounter = usageCountersServiceSetup.createUsageCounter('uiCounter'); let repository: ReturnType; @@ -64,34 +64,56 @@ describe('store_report', () => { }, }, }; - await storeReport(repository, report); + await storeReport(repository, uiCountersUsageCounter, report); - expect(repository.create).toHaveBeenCalledWith( - 'ui-metric', - { count: 1 }, - { - id: 'key-user-agent:test-user-agent', - overwrite: true, - } - ); - expect(repository.incrementCounter).toHaveBeenNthCalledWith( - 1, - 'ui-metric', - 'test-app-name:test-event-name', - [{ fieldName: 'count', incrementBy: 3 }] - ); - expect(repository.incrementCounter).toHaveBeenNthCalledWith( - 2, - 'ui-counter', - `test-app-name:${date}:${METRIC_TYPE.LOADED}:test-event-name`, - [{ fieldName: 'count', incrementBy: 1 }] - ); - expect(repository.incrementCounter).toHaveBeenNthCalledWith( - 3, - 'ui-counter', - `test-app-name:${date}:${METRIC_TYPE.CLICK}:test-event-name`, - [{ fieldName: 'count', incrementBy: 2 }] - ); + expect(repository.create.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "ui-metric", + Object { + "count": 1, + }, + Object { + "id": "key-user-agent:test-user-agent", + "overwrite": true, + }, + ], + ] + `); + + expect(repository.incrementCounter.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "ui-metric", + "test-app-name:test-event-name", + Array [ + Object { + "fieldName": "count", + "incrementBy": 3, + }, + ], + ], + ] + `); + expect((uiCountersUsageCounter.incrementCounter as jest.Mock).mock.calls) + .toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "counterName": "test-app-name:test-event-name", + "counterType": "loaded", + "incrementBy": 1, + }, + ], + Array [ + Object { + "counterName": "test-app-name:test-event-name", + "counterType": "click", + "incrementBy": 2, + }, + ], + ] + `); expect(storeApplicationUsageMock).toHaveBeenCalledTimes(1); expect(storeApplicationUsageMock).toHaveBeenCalledWith( @@ -108,7 +130,7 @@ describe('store_report', () => { uiCounter: void 0, application_usage: void 0, }; - await storeReport(repository, report); + await storeReport(repository, uiCountersUsageCounter, report); expect(repository.bulkCreate).not.toHaveBeenCalled(); expect(repository.incrementCounter).not.toHaveBeenCalled(); diff --git a/src/plugins/usage_collection/server/report/store_report.ts b/src/plugins/usage_collection/server/report/store_report.ts index 0545a54792d45..1647fb8893be1 100644 --- a/src/plugins/usage_collection/server/report/store_report.ts +++ b/src/plugins/usage_collection/server/report/store_report.ts @@ -11,9 +11,12 @@ import moment from 'moment'; import { chain, sumBy } from 'lodash'; import { ReportSchemaType } from './schema'; import { storeApplicationUsage } from './store_application_usage'; +import { UsageCounter } from '../usage_counters'; +import { serializeUiCounterName } from '../../common/ui_counters'; export async function storeReport( internalRepository: ISavedObjectsRepository, + uiCountersUsageCounter: UsageCounter, report: ReportSchemaType ) { const uiCounters = report.uiCounter ? Object.entries(report.uiCounter) : []; @@ -21,7 +24,6 @@ export async function storeReport( const appUsages = report.application_usage ? Object.values(report.application_usage) : []; const momentTimestamp = moment(); - const date = momentTimestamp.format('DDMMYYYY'); const timestamp = momentTimestamp.toDate(); return Promise.allSettled([ @@ -55,14 +57,14 @@ export async function storeReport( }) .value(), // UI Counters - ...uiCounters.map(async ([key, metric]) => { + ...uiCounters.map(async ([, metric]) => { const { appName, eventName, total, type } = metric; - const savedObjectId = `${appName}:${date}:${type}:${eventName}`; - return [ - await internalRepository.incrementCounter('ui-counter', savedObjectId, [ - { fieldName: 'count', incrementBy: total }, - ]), - ]; + const counterName = serializeUiCounterName({ appName, eventName }); + uiCountersUsageCounter.incrementCounter({ + counterName, + counterType: type, + incrementBy: total, + }); }), // Application Usage storeApplicationUsage(internalRepository, appUsages, timestamp), diff --git a/src/plugins/usage_collection/server/routes/index.ts b/src/plugins/usage_collection/server/routes/index.ts index 0e17ebcbfd695..20949224c0f6d 100644 --- a/src/plugins/usage_collection/server/routes/index.ts +++ b/src/plugins/usage_collection/server/routes/index.ts @@ -16,14 +16,16 @@ import { Observable } from 'rxjs'; import { CollectorSet } from '../collector'; import { registerUiCountersRoute } from './ui_counters'; import { registerStatsRoute } from './stats'; - +import type { UsageCounter } from '../usage_counters'; export function setupRoutes({ router, + uiCountersUsageCounter, getSavedObjects, ...rest }: { router: IRouter; getSavedObjects: () => ISavedObjectsRepository | undefined; + uiCountersUsageCounter: UsageCounter; config: { allowAnonymous: boolean; kibanaIndex: string; @@ -39,6 +41,6 @@ export function setupRoutes({ metrics: MetricsServiceSetup; overallStatus$: Observable; }) { - registerUiCountersRoute(router, getSavedObjects); + registerUiCountersRoute(router, getSavedObjects, uiCountersUsageCounter); registerStatsRoute({ router, ...rest }); } diff --git a/src/plugins/usage_collection/server/routes/ui_counters.ts b/src/plugins/usage_collection/server/routes/ui_counters.ts index 07983ba1d65ca..c03541b1032b6 100644 --- a/src/plugins/usage_collection/server/routes/ui_counters.ts +++ b/src/plugins/usage_collection/server/routes/ui_counters.ts @@ -9,10 +9,12 @@ import { schema } from '@kbn/config-schema'; import { IRouter, ISavedObjectsRepository } from 'src/core/server'; import { storeReport, reportSchema } from '../report'; +import { UsageCounter } from '../usage_counters'; export function registerUiCountersRoute( router: IRouter, - getSavedObjects: () => ISavedObjectsRepository | undefined + getSavedObjects: () => ISavedObjectsRepository | undefined, + uiCountersUsageCounter: UsageCounter ) { router.post( { @@ -30,7 +32,7 @@ export function registerUiCountersRoute( if (!internalRepository) { throw Error(`The saved objects client hasn't been initialised yet`); } - await storeReport(internalRepository, report); + await storeReport(internalRepository, uiCountersUsageCounter, report); return res.ok({ body: { status: 'ok' } }); } catch (error) { return res.ok({ body: { status: 'fail' } }); diff --git a/src/plugins/usage_collection/server/usage_collection.mock.ts b/src/plugins/usage_collection/server/usage_collection.mock.ts deleted file mode 100644 index 7e3f4273bbea8..0000000000000 --- a/src/plugins/usage_collection/server/usage_collection.mock.ts +++ /dev/null @@ -1,58 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { - elasticsearchServiceMock, - httpServerMock, - loggingSystemMock, - savedObjectsClientMock, -} from '../../../../src/core/server/mocks'; - -import { CollectorOptions, Collector, UsageCollector } from './collector'; -import { UsageCollectionSetup, CollectorFetchContext } from './index'; - -export type { CollectorOptions }; -export { Collector }; - -const logger = loggingSystemMock.createLogger(); - -export const createUsageCollectionSetupMock = () => { - const usageCollectionSetupMock: jest.Mocked = { - areAllCollectorsReady: jest.fn(), - bulkFetch: jest.fn(), - bulkFetchUsage: jest.fn(), - getCollectorByType: jest.fn(), - toApiFieldNames: jest.fn(), - toObject: jest.fn(), - makeStatsCollector: jest.fn().mockImplementation((cfg) => new Collector(logger, cfg)), - makeUsageCollector: jest.fn().mockImplementation((cfg) => new UsageCollector(logger, cfg)), - registerCollector: jest.fn(), - }; - - usageCollectionSetupMock.areAllCollectorsReady.mockResolvedValue(true); - return usageCollectionSetupMock; -}; - -export function createCollectorFetchContextMock(): jest.Mocked> { - const collectorFetchClientsMock: jest.Mocked> = { - esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - soClient: savedObjectsClientMock.create(), - }; - return collectorFetchClientsMock; -} - -export function createCollectorFetchContextWithKibanaMock(): jest.Mocked< - CollectorFetchContext -> { - const collectorFetchClientsMock: jest.Mocked> = { - esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - soClient: savedObjectsClientMock.create(), - kibanaRequest: httpServerMock.createKibanaRequest(), - }; - return collectorFetchClientsMock; -} diff --git a/src/plugins/usage_collection/server/usage_counters/index.ts b/src/plugins/usage_collection/server/usage_counters/index.ts new file mode 100644 index 0000000000000..dc1d1f5b43edf --- /dev/null +++ b/src/plugins/usage_collection/server/usage_counters/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { UsageCountersServiceSetup } from './usage_counters_service'; +export type { UsageCountersSavedObjectAttributes, UsageCountersSavedObject } from './saved_objects'; +export type { IncrementCounterParams } from './usage_counter'; + +export { UsageCountersService } from './usage_counters_service'; +export { UsageCounter } from './usage_counter'; +export { USAGE_COUNTERS_SAVED_OBJECT_TYPE, serializeCounterKey } from './saved_objects'; diff --git a/src/plugins/usage_collection/server/usage_counters/saved_objects.test.ts b/src/plugins/usage_collection/server/usage_counters/saved_objects.test.ts new file mode 100644 index 0000000000000..f857d449312e6 --- /dev/null +++ b/src/plugins/usage_collection/server/usage_counters/saved_objects.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { serializeCounterKey, storeCounter } from './saved_objects'; +import { savedObjectsRepositoryMock } from '../../../../core/server/mocks'; +import { CounterMetric } from './usage_counter'; +import moment from 'moment'; + +describe('counterKey', () => { + test('#serializeCounterKey returns a serialized string', () => { + const result = serializeCounterKey({ + domainId: 'a', + counterName: 'b', + counterType: 'c', + date: moment('09042021', 'DDMMYYYY'), + }); + + expect(result).toMatchInlineSnapshot(`"a:09042021:c:b"`); + }); +}); + +describe('storeCounter', () => { + const internalRepository = savedObjectsRepositoryMock.create(); + + const mockNow = 1617954426939; + + beforeEach(() => { + jest.spyOn(moment, 'now').mockReturnValue(mockNow); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('stores counter in a saved object', async () => { + const counterMetric: CounterMetric = { + domainId: 'a', + counterName: 'b', + counterType: 'c', + incrementBy: 13, + }; + + await storeCounter(counterMetric, internalRepository); + + expect(internalRepository.incrementCounter).toBeCalledTimes(1); + expect(internalRepository.incrementCounter.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "usage-counters", + "a:09042021:c:b", + Array [ + Object { + "fieldName": "count", + "incrementBy": 13, + }, + ], + Object { + "upsertAttributes": Object { + "counterName": "b", + "counterType": "c", + "domainId": "a", + }, + }, + ] + `); + }); +}); diff --git a/src/plugins/usage_collection/server/usage_counters/saved_objects.ts b/src/plugins/usage_collection/server/usage_counters/saved_objects.ts new file mode 100644 index 0000000000000..6c585d756e8c1 --- /dev/null +++ b/src/plugins/usage_collection/server/usage_counters/saved_objects.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + SavedObject, + SavedObjectsRepository, + SavedObjectAttributes, + SavedObjectsServiceSetup, +} from 'kibana/server'; +import moment from 'moment'; +import { CounterMetric } from './usage_counter'; + +export interface UsageCountersSavedObjectAttributes extends SavedObjectAttributes { + domainId: string; + counterName: string; + counterType: string; + count: number; +} + +export type UsageCountersSavedObject = SavedObject; + +export const USAGE_COUNTERS_SAVED_OBJECT_TYPE = 'usage-counters'; + +export const registerUsageCountersSavedObjectType = ( + savedObjectsSetup: SavedObjectsServiceSetup +) => { + savedObjectsSetup.registerType({ + name: USAGE_COUNTERS_SAVED_OBJECT_TYPE, + hidden: false, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: { + domainId: { type: 'keyword' }, + }, + }, + }); +}; + +export interface SerializeCounterParams { + domainId: string; + counterName: string; + counterType: string; + date: moment.MomentInput; +} + +export const serializeCounterKey = ({ + domainId, + counterName, + counterType, + date, +}: SerializeCounterParams) => { + const dayDate = moment(date).format('DDMMYYYY'); + return `${domainId}:${dayDate}:${counterType}:${counterName}`; +}; + +export const storeCounter = async ( + counterMetric: CounterMetric, + internalRepository: Pick +) => { + const { counterName, counterType, domainId, incrementBy } = counterMetric; + const key = serializeCounterKey({ + date: moment.now(), + domainId, + counterName, + counterType, + }); + + return await internalRepository.incrementCounter( + USAGE_COUNTERS_SAVED_OBJECT_TYPE, + key, + [{ fieldName: 'count', incrementBy }], + { + upsertAttributes: { + domainId, + counterName, + counterType, + }, + } + ); +}; diff --git a/src/plugins/usage_collection/server/usage_counters/usage_counter.test.ts b/src/plugins/usage_collection/server/usage_counters/usage_counter.test.ts new file mode 100644 index 0000000000000..3602ff1a29376 --- /dev/null +++ b/src/plugins/usage_collection/server/usage_counters/usage_counter.test.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { UsageCounter, CounterMetric } from './usage_counter'; +import * as Rx from 'rxjs'; +import * as rxOp from 'rxjs/operators'; + +describe('UsageCounter', () => { + const domainId = 'test-domain-id'; + const counter$ = new Rx.Subject(); + const usageCounter = new UsageCounter({ domainId, counter$ }); + + afterAll(() => { + counter$.complete(); + }); + + describe('#incrementCounter', () => { + it('#incrementCounter calls counter$.next', async () => { + const result = counter$.pipe(rxOp.take(1), rxOp.toArray()).toPromise(); + usageCounter.incrementCounter({ counterName: 'test', counterType: 'type', incrementBy: 13 }); + await expect(result).resolves.toEqual([ + { counterName: 'test', counterType: 'type', domainId: 'test-domain-id', incrementBy: 13 }, + ]); + }); + + it('passes default configs to counter$', async () => { + const result = counter$.pipe(rxOp.take(1), rxOp.toArray()).toPromise(); + usageCounter.incrementCounter({ counterName: 'test' }); + await expect(result).resolves.toEqual([ + { counterName: 'test', counterType: 'count', domainId: 'test-domain-id', incrementBy: 1 }, + ]); + }); + }); +}); diff --git a/src/plugins/usage_collection/server/usage_counters/usage_counter.ts b/src/plugins/usage_collection/server/usage_counters/usage_counter.ts new file mode 100644 index 0000000000000..af00ad04149b7 --- /dev/null +++ b/src/plugins/usage_collection/server/usage_counters/usage_counter.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Rx from 'rxjs'; + +export interface CounterMetric { + domainId: string; + counterName: string; + counterType: string; + incrementBy: number; +} + +export interface UsageCounterDeps { + domainId: string; + counter$: Rx.Subject; +} + +export interface IncrementCounterParams { + counterName: string; + counterType?: string; + incrementBy?: number; +} + +export class UsageCounter { + private domainId: string; + private counter$: Rx.Subject; + + constructor({ domainId, counter$ }: UsageCounterDeps) { + this.domainId = domainId; + this.counter$ = counter$; + } + + public incrementCounter = (params: IncrementCounterParams) => { + const { counterName, counterType = 'count', incrementBy = 1 } = params; + + this.counter$.next({ + counterName, + domainId: this.domainId, + counterType, + incrementBy, + }); + }; +} diff --git a/src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock.ts b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock.ts new file mode 100644 index 0000000000000..beb67d1eb2607 --- /dev/null +++ b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { UsageCountersService, UsageCountersServiceSetup } from './usage_counters_service'; +import type { UsageCounter } from './usage_counter'; + +const createSetupContractMock = () => { + const setupContract: jest.Mocked = { + createUsageCounter: jest.fn(), + getUsageCounterByType: jest.fn(), + }; + + setupContract.createUsageCounter.mockReturnValue(({ + incrementCounter: jest.fn(), + } as unknown) as jest.Mocked); + + return setupContract; +}; + +const createUsageCountersServiceMock = () => { + const mocked: jest.Mocked> = { + setup: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + }; + + mocked.setup.mockReturnValue(createSetupContractMock()); + return mocked; +}; + +export const usageCountersServiceMock = { + create: createUsageCountersServiceMock, + createSetupContract: createSetupContractMock, +}; diff --git a/src/plugins/usage_collection/server/usage_counters/usage_counters_service.test.ts b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.test.ts new file mode 100644 index 0000000000000..c800bce6390c9 --- /dev/null +++ b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.test.ts @@ -0,0 +1,241 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* eslint-disable dot-notation */ +import { UsageCountersService } from './usage_counters_service'; +import { loggingSystemMock, coreMock } from '../../../../core/server/mocks'; +import * as rxOp from 'rxjs/operators'; +import moment from 'moment'; + +const tick = () => { + jest.useRealTimers(); + return new Promise((resolve) => setTimeout(resolve, 1)); +}; + +describe('UsageCountersService', () => { + const retryCount = 1; + const bufferDurationMs = 100; + const mockNow = 1617954426939; + const logger = loggingSystemMock.createLogger(); + const coreSetup = coreMock.createSetup(); + const coreStart = coreMock.createStart(); + + beforeEach(() => { + jest.spyOn(moment, 'now').mockReturnValue(mockNow); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('stores data in cache during setup', async () => { + const usageCountersService = new UsageCountersService({ logger, retryCount, bufferDurationMs }); + const { createUsageCounter } = usageCountersService.setup(coreSetup); + + const usageCounter = createUsageCounter('test-counter'); + + usageCounter.incrementCounter({ counterName: 'counterA' }); + usageCounter.incrementCounter({ counterName: 'counterA' }); + + const dataInSourcePromise = usageCountersService['source$'].pipe(rxOp.toArray()).toPromise(); + usageCountersService['flushCache$'].next(); + usageCountersService['source$'].complete(); + await expect(dataInSourcePromise).resolves.toHaveLength(2); + }); + + it('registers savedObject type during setup', () => { + const usageCountersService = new UsageCountersService({ logger, retryCount, bufferDurationMs }); + usageCountersService.setup(coreSetup); + expect(coreSetup.savedObjects.registerType).toBeCalledTimes(1); + }); + + it('flushes cached data on start', async () => { + const usageCountersService = new UsageCountersService({ logger, retryCount, bufferDurationMs }); + + const mockRepository = coreStart.savedObjects.createInternalRepository(); + const mockIncrementCounter = jest.fn(); + mockRepository.incrementCounter = mockIncrementCounter; + + coreStart.savedObjects.createInternalRepository.mockReturnValue(mockRepository); + const { createUsageCounter } = usageCountersService.setup(coreSetup); + + const usageCounter = createUsageCounter('test-counter'); + + usageCounter.incrementCounter({ counterName: 'counterA' }); + usageCounter.incrementCounter({ counterName: 'counterA' }); + + const dataInSourcePromise = usageCountersService['source$'].pipe(rxOp.toArray()).toPromise(); + usageCountersService.start(coreStart); + usageCountersService['source$'].complete(); + + await expect(dataInSourcePromise).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "counterName": "counterA", + "counterType": "count", + "domainId": "test-counter", + "incrementBy": 1, + }, + Object { + "counterName": "counterA", + "counterType": "count", + "domainId": "test-counter", + "incrementBy": 1, + }, + ] + `); + }); + + it('buffers data into savedObject', async () => { + const usageCountersService = new UsageCountersService({ logger, retryCount, bufferDurationMs }); + + const mockRepository = coreStart.savedObjects.createInternalRepository(); + const mockIncrementCounter = jest.fn().mockResolvedValue('success'); + mockRepository.incrementCounter = mockIncrementCounter; + + coreStart.savedObjects.createInternalRepository.mockReturnValue(mockRepository); + const { createUsageCounter } = usageCountersService.setup(coreSetup); + jest.useFakeTimers('modern'); + const usageCounter = createUsageCounter('test-counter'); + + usageCounter.incrementCounter({ counterName: 'counterA' }); + usageCounter.incrementCounter({ counterName: 'counterA' }); + + usageCountersService.start(coreStart); + usageCounter.incrementCounter({ counterName: 'counterA' }); + usageCounter.incrementCounter({ counterName: 'counterB' }); + jest.runOnlyPendingTimers(); + expect(mockIncrementCounter).toBeCalledTimes(2); + expect(mockIncrementCounter.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "usage-counters", + "test-counter:09042021:count:counterA", + Array [ + Object { + "fieldName": "count", + "incrementBy": 3, + }, + ], + Object { + "upsertAttributes": Object { + "counterName": "counterA", + "counterType": "count", + "domainId": "test-counter", + }, + }, + ], + Array [ + "usage-counters", + "test-counter:09042021:count:counterB", + Array [ + Object { + "fieldName": "count", + "incrementBy": 1, + }, + ], + Object { + "upsertAttributes": Object { + "counterName": "counterB", + "counterType": "count", + "domainId": "test-counter", + }, + }, + ], + ] + `); + }); + + it('retries errors by `retryCount` times before failing to store', async () => { + const usageCountersService = new UsageCountersService({ + logger, + retryCount: 1, + bufferDurationMs, + }); + + const mockRepository = coreStart.savedObjects.createInternalRepository(); + const mockError = new Error('failed.'); + const mockIncrementCounter = jest.fn().mockImplementation((_, key) => { + switch (key) { + case 'test-counter:09042021:count:counterA': + throw mockError; + case 'test-counter:09042021:count:counterB': + return 'pass'; + default: + throw new Error(`unknown key ${key}`); + } + }); + + mockRepository.incrementCounter = mockIncrementCounter; + + coreStart.savedObjects.createInternalRepository.mockReturnValue(mockRepository); + const { createUsageCounter } = usageCountersService.setup(coreSetup); + jest.useFakeTimers('modern'); + const usageCounter = createUsageCounter('test-counter'); + + usageCountersService.start(coreStart); + usageCounter.incrementCounter({ counterName: 'counterA' }); + usageCounter.incrementCounter({ counterName: 'counterB' }); + jest.runOnlyPendingTimers(); + + // wait for retries to kick in on next scheduler call + await tick(); + // number of incrementCounter calls + number of retries + expect(mockIncrementCounter).toBeCalledTimes(2 + 1); + expect(logger.debug).toHaveBeenNthCalledWith(1, 'Store counters into savedObjects', [ + mockError, + 'pass', + ]); + }); + + it('buffers counters within `bufferDurationMs` time', async () => { + const usageCountersService = new UsageCountersService({ + logger, + retryCount, + bufferDurationMs: 30000, + }); + + const mockRepository = coreStart.savedObjects.createInternalRepository(); + const mockIncrementCounter = jest.fn().mockImplementation((_data, key, counter) => { + expect(counter).toHaveLength(1); + return { key, incrementBy: counter[0].incrementBy }; + }); + + mockRepository.incrementCounter = mockIncrementCounter; + + coreStart.savedObjects.createInternalRepository.mockReturnValue(mockRepository); + + const { createUsageCounter } = usageCountersService.setup(coreSetup); + jest.useFakeTimers('modern'); + const usageCounter = createUsageCounter('test-counter'); + + usageCountersService.start(coreStart); + usageCounter.incrementCounter({ counterName: 'counterA' }); + usageCounter.incrementCounter({ counterName: 'counterA' }); + jest.advanceTimersByTime(30000); + + usageCounter.incrementCounter({ counterName: 'counterA' }); + jest.runOnlyPendingTimers(); + + // wait for debounce to kick in on next scheduler call + await tick(); + expect(mockIncrementCounter).toBeCalledTimes(2); + expect(mockIncrementCounter.mock.results.map(({ value }) => value)).toMatchInlineSnapshot(` + Array [ + Object { + "incrementBy": 2, + "key": "test-counter:09042021:count:counterA", + }, + Object { + "incrementBy": 1, + "key": "test-counter:09042021:count:counterA", + }, + ] + `); + }); +}); diff --git a/src/plugins/usage_collection/server/usage_counters/usage_counters_service.ts b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.ts new file mode 100644 index 0000000000000..88ca9f6358926 --- /dev/null +++ b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.ts @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Rx from 'rxjs'; +import * as rxOp from 'rxjs/operators'; +import { + SavedObjectsRepository, + SavedObjectsServiceSetup, + SavedObjectsServiceStart, +} from 'src/core/server'; +import type { Logger } from 'src/core/server'; + +import moment from 'moment'; +import { CounterMetric, UsageCounter } from './usage_counter'; +import { + registerUsageCountersSavedObjectType, + storeCounter, + serializeCounterKey, +} from './saved_objects'; + +export interface UsageCountersServiceDeps { + logger: Logger; + retryCount: number; + bufferDurationMs: number; +} + +export interface UsageCountersServiceSetup { + createUsageCounter: (type: string) => UsageCounter; + getUsageCounterByType: (type: string) => UsageCounter | undefined; +} + +/* internal */ +export interface UsageCountersServiceSetupDeps { + savedObjects: SavedObjectsServiceSetup; +} + +/* internal */ +export interface UsageCountersServiceStartDeps { + savedObjects: SavedObjectsServiceStart; +} + +export class UsageCountersService { + private readonly stop$ = new Rx.Subject(); + private readonly retryCount: number; + private readonly bufferDurationMs: number; + + private readonly counterSets = new Map(); + private readonly source$ = new Rx.Subject(); + private readonly counter$ = this.source$.pipe(rxOp.multicast(new Rx.Subject()), rxOp.refCount()); + private readonly flushCache$ = new Rx.Subject(); + + private readonly stopCaching$ = new Rx.Subject(); + + private readonly logger: Logger; + + constructor({ logger, retryCount, bufferDurationMs }: UsageCountersServiceDeps) { + this.logger = logger; + this.retryCount = retryCount; + this.bufferDurationMs = bufferDurationMs; + } + + public setup = (core: UsageCountersServiceSetupDeps): UsageCountersServiceSetup => { + const cache$ = new Rx.ReplaySubject(); + const storingCache$ = new Rx.BehaviorSubject(false); + // flush cache data from cache -> source + this.flushCache$ + .pipe( + rxOp.exhaustMap(() => cache$), + rxOp.takeUntil(this.stop$) + ) + .subscribe((data) => { + storingCache$.next(true); + this.source$.next(data); + }); + + // store data into cache when not paused + storingCache$ + .pipe( + rxOp.distinctUntilChanged(), + rxOp.switchMap((isStoring) => (isStoring ? Rx.EMPTY : this.source$)), + rxOp.takeUntil(Rx.merge(this.stopCaching$, this.stop$)) + ) + .subscribe((data) => { + cache$.next(data); + storingCache$.next(false); + }); + + registerUsageCountersSavedObjectType(core.savedObjects); + + return { + createUsageCounter: this.createUsageCounter, + getUsageCounterByType: this.getUsageCounterByType, + }; + }; + + public start = ({ savedObjects }: UsageCountersServiceStartDeps): void => { + this.stopCaching$.next(); + const internalRepository = savedObjects.createInternalRepository(); + this.counter$ + .pipe( + /* buffer source events every ${bufferDurationMs} */ + rxOp.bufferTime(this.bufferDurationMs), + /** + * bufferTime will trigger every ${bufferDurationMs} + * regardless if source emitted anything or not. + * using filter will stop cut the pipe short + */ + rxOp.filter((counters) => Array.isArray(counters) && counters.length > 0), + rxOp.map((counters) => Object.values(this.mergeCounters(counters))), + rxOp.takeUntil(this.stop$), + rxOp.concatMap((counters) => this.storeDate$(counters, internalRepository)) + ) + .subscribe((results) => { + this.logger.debug('Store counters into savedObjects', results); + }); + + this.flushCache$.next(); + }; + + public stop = () => { + this.stop$.next(); + }; + + private storeDate$( + counters: CounterMetric[], + internalRepository: Pick + ) { + return Rx.forkJoin( + counters.map((counter) => + Rx.defer(() => storeCounter(counter, internalRepository)).pipe( + rxOp.retry(this.retryCount), + rxOp.catchError((error) => { + this.logger.warn(error); + return Rx.of(error); + }) + ) + ) + ); + } + + private createUsageCounter = (type: string): UsageCounter => { + if (this.counterSets.get(type)) { + throw new Error(`Usage counter set "${type}" already exists.`); + } + + const counterSet = new UsageCounter({ + domainId: type, + counter$: this.source$, + }); + + this.counterSets.set(type, counterSet); + + return counterSet; + }; + + private getUsageCounterByType = (type: string): UsageCounter | undefined => { + return this.counterSets.get(type); + }; + + private mergeCounters = (counters: CounterMetric[]): Record => { + const date = moment.now(); + return counters.reduce((acc, counter) => { + const { counterName, domainId, counterType } = counter; + const key = serializeCounterKey({ domainId, counterName, counterType, date }); + const existingCounter = acc[key]; + if (!existingCounter) { + acc[key] = counter; + return acc; + } + return { + ...acc, + [key]: { + ...existingCounter, + ...counter, + incrementBy: existingCounter.incrementBy + counter.incrementBy, + }, + }; + }, {} as Record); + }; +} diff --git a/src/plugins/vis_type_table/server/usage_collector/register_usage_collector.test.ts b/src/plugins/vis_type_table/server/usage_collector/register_usage_collector.test.ts index b87e6d54733af..e045788897b61 100644 --- a/src/plugins/vis_type_table/server/usage_collector/register_usage_collector.test.ts +++ b/src/plugins/vis_type_table/server/usage_collector/register_usage_collector.test.ts @@ -10,8 +10,10 @@ jest.mock('./get_stats', () => ({ getStats: jest.fn().mockResolvedValue({ somestat: 1 }), })); -import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/usage_collection.mock'; -import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; +import { + createUsageCollectionSetupMock, + createCollectorFetchContextMock, +} from 'src/plugins/usage_collection/server/mocks'; import { registerVisTypeTableUsageCollector } from './register_usage_collector'; import { getStats } from './get_stats'; diff --git a/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.test.ts b/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.test.ts index 2612a3882af2d..726ad972ab8d1 100644 --- a/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.test.ts +++ b/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.test.ts @@ -8,7 +8,7 @@ import { of } from 'rxjs'; import { mockStats, mockGetStats } from './get_usage_collector.mock'; -import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/usage_collection.mock'; +import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/mocks'; import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; import { registerTimeseriesUsageCollector } from './register_timeseries_collector'; import { ConfigObservable } from '../types'; diff --git a/src/plugins/vis_type_vega/server/usage_collector/register_vega_collector.test.ts b/src/plugins/vis_type_vega/server/usage_collector/register_vega_collector.test.ts index 9db1b7657f444..7933da3e675f6 100644 --- a/src/plugins/vis_type_vega/server/usage_collector/register_vega_collector.test.ts +++ b/src/plugins/vis_type_vega/server/usage_collector/register_vega_collector.test.ts @@ -8,7 +8,7 @@ import { of } from 'rxjs'; import { mockStats, mockGetStats } from './get_usage_collector.mock'; -import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/usage_collection.mock'; +import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/mocks'; import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; import { HomeServerPluginSetup } from '../../../home/server'; import { registerVegaUsageCollector } from './register_vega_collector'; diff --git a/src/plugins/visualizations/server/usage_collector/register_visualizations_collector.test.ts b/src/plugins/visualizations/server/usage_collector/register_visualizations_collector.test.ts index 743ec29fe9af7..a3617631f734b 100644 --- a/src/plugins/visualizations/server/usage_collector/register_visualizations_collector.test.ts +++ b/src/plugins/visualizations/server/usage_collector/register_visualizations_collector.test.ts @@ -8,7 +8,7 @@ import { of } from 'rxjs'; import { mockStats, mockGetStats } from './get_usage_collector.mock'; -import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/usage_collection.mock'; +import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/mocks'; import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; import { registerVisualizationsCollector } from './register_visualizations_collector'; diff --git a/test/api_integration/apis/telemetry/__fixtures__/ui_counters.ts b/test/api_integration/apis/telemetry/__fixtures__/ui_counters.ts index 762b917918202..07a11f3876d86 100644 --- a/test/api_integration/apis/telemetry/__fixtures__/ui_counters.ts +++ b/test/api_integration/apis/telemetry/__fixtures__/ui_counters.ts @@ -8,6 +8,14 @@ export const basicUiCounters = { dailyEvents: [ + { + appName: 'myApp', + eventName: 'some_app_event', + lastUpdatedAt: '2021-11-20T11:43:00.961Z', + fromTimestamp: '2021-11-20T00:00:00Z', + counterType: 'count', + total: 2, + }, { appName: 'myApp', eventName: 'my_event_885082425109579', diff --git a/test/api_integration/apis/telemetry/__fixtures__/usage_counters.ts b/test/api_integration/apis/telemetry/__fixtures__/usage_counters.ts new file mode 100644 index 0000000000000..988bc2e77528d --- /dev/null +++ b/test/api_integration/apis/telemetry/__fixtures__/usage_counters.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const basicUsageCounters = { + dailyEvents: [ + { + domainId: 'anotherDomainId', + counterName: 'some_event_name', + counterType: 'count', + lastUpdatedAt: '2021-11-20T11:43:00.961Z', + fromTimestamp: '2021-11-20T00:00:00Z', + total: 3, + }, + { + domainId: 'anotherDomainId', + counterName: 'some_event_name', + counterType: 'count', + lastUpdatedAt: '2021-04-09T11:43:00.961Z', + fromTimestamp: '2021-04-09T00:00:00Z', + total: 2, + }, + { + domainId: 'anotherDomainId2', + counterName: 'some_event_name', + counterType: 'count', + lastUpdatedAt: '2021-04-20T08:18:03.030Z', + fromTimestamp: '2021-04-20T00:00:00Z', + total: 1, + }, + ], +}; diff --git a/test/api_integration/apis/telemetry/telemetry_local.ts b/test/api_integration/apis/telemetry/telemetry_local.ts index d0a09ee58d335..9b92576c84b3a 100644 --- a/test/api_integration/apis/telemetry/telemetry_local.ts +++ b/test/api_integration/apis/telemetry/telemetry_local.ts @@ -9,6 +9,7 @@ import expect from '@kbn/expect'; import supertestAsPromised from 'supertest-as-promised'; import { basicUiCounters } from './__fixtures__/ui_counters'; +import { basicUsageCounters } from './__fixtures__/usage_counters'; import type { FtrProviderContext } from '../../ftr_provider_context'; import type { SavedObject } from '../../../../src/core/server'; import ossRootTelemetrySchema from '../../../../src/plugins/telemetry/schema/oss_root.json'; @@ -153,6 +154,20 @@ export default function ({ getService }: FtrProviderContext) { }); }); + describe('Usage Counters telemetry', () => { + before('Add UI Counters saved objects', () => + esArchiver.load('saved_objects/usage_counters') + ); + after('cleanup saved objects changes', () => + esArchiver.unload('saved_objects/usage_counters') + ); + + it('returns usage counters aggregated by day', async () => { + const stats = await retrieveTelemetry(supertest); + expect(stats.stack_stats.kibana.plugins.usage_counters).to.eql(basicUsageCounters); + }); + }); + describe('application usage limits', () => { function createSavedObject(viewId?: string) { return supertest diff --git a/test/api_integration/apis/ui_counters/ui_counters.ts b/test/api_integration/apis/ui_counters/ui_counters.ts index 2d55e224f31ce..aa201eb6a96ff 100644 --- a/test/api_integration/apis/ui_counters/ui_counters.ts +++ b/test/api_integration/apis/ui_counters/ui_counters.ts @@ -7,11 +7,10 @@ */ import expect from '@kbn/expect'; -import { ReportManager, METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; +import { ReportManager, METRIC_TYPE, UiCounterMetricType, Report } from '@kbn/analytics'; import moment from 'moment'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { SavedObject } from '../../../../src/core/server'; -import { UICounterSavedObjectAttributes } from '../../../../src/plugins/kibana_usage_collection/server/collectors/ui_counters/ui_counter_saved_object_type'; +import { UsageCountersSavedObject } from '../../../../src/plugins/usage_collection/server'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -24,10 +23,22 @@ export default function ({ getService }: FtrProviderContext) { count, }); + const sendReport = async (report: Report) => { + await supertest + .post('/api/ui_counters/_report') + .set('kbn-xsrf', 'kibana') + .set('content-type', 'application/json') + .send({ report }) + .expect(200); + + // wait for SO to index data into ES + await new Promise((res) => setTimeout(res, 5 * 1000)); + }; + const getCounterById = ( - savedObjects: Array>, + savedObjects: UsageCountersSavedObject[], targetId: string - ): SavedObject => { + ): UsageCountersSavedObject => { const savedObject = savedObjects.find(({ id }: { id: string }) => id === targetId); if (!savedObject) { throw new Error(`Unable to find savedObject id ${targetId}`); @@ -40,30 +51,25 @@ export default function ({ getService }: FtrProviderContext) { const dayDate = moment().format('DDMMYYYY'); before(async () => await esArchiver.emptyKibanaIndex()); - it('stores ui counter events in savedObjects', async () => { + it('stores ui counter events in usage counters savedObjects', async () => { const reportManager = new ReportManager(); const { report } = reportManager.assignReports([ createUiCounterEvent('my_event', METRIC_TYPE.COUNT), ]); - await supertest - .post('/api/ui_counters/_report') - .set('kbn-xsrf', 'kibana') - .set('content-type', 'application/json') - .send({ report }) - .expect(200); + await sendReport(report); const { body: { saved_objects: savedObjects }, } = await supertest - .get('/api/saved_objects/_find?type=ui-counter') + .get('/api/saved_objects/_find?type=usage-counters') .set('kbn-xsrf', 'kibana') .expect(200); const countTypeEvent = getCounterById( savedObjects, - `myApp:${dayDate}:${METRIC_TYPE.COUNT}:my_event` + `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:my_event` ); expect(countTypeEvent.attributes.count).to.eql(1); }); @@ -78,35 +84,31 @@ export default function ({ getService }: FtrProviderContext) { createUiCounterEvent(`${uniqueEventName}_2`, METRIC_TYPE.COUNT), createUiCounterEvent(uniqueEventName, METRIC_TYPE.CLICK, 2), ]); - await supertest - .post('/api/ui_counters/_report') - .set('kbn-xsrf', 'kibana') - .set('content-type', 'application/json') - .send({ report }) - .expect(200); + + await sendReport(report); const { body: { saved_objects: savedObjects }, } = await supertest - .get('/api/saved_objects/_find?type=ui-counter&fields=count') + .get('/api/saved_objects/_find?type=usage-counters&fields=count') .set('kbn-xsrf', 'kibana') .expect(200); const countTypeEvent = getCounterById( savedObjects, - `myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}` + `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}` ); expect(countTypeEvent.attributes.count).to.eql(1); const clickTypeEvent = getCounterById( savedObjects, - `myApp:${dayDate}:${METRIC_TYPE.CLICK}:${uniqueEventName}` + `uiCounter:${dayDate}:${METRIC_TYPE.CLICK}:myApp:${uniqueEventName}` ); expect(clickTypeEvent.attributes.count).to.eql(2); const secondEvent = getCounterById( savedObjects, - `myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}_2` + `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}_2` ); expect(secondEvent.attributes.count).to.eql(1); }); diff --git a/test/api_integration/config.js b/test/api_integration/config.js index 1c19dd24fa96b..7bbace4c60570 100644 --- a/test/api_integration/config.js +++ b/test/api_integration/config.js @@ -31,6 +31,8 @@ export default async function ({ readConfigFile }) { '--server.xsrf.disableProtection=true', '--server.compression.referrerWhitelist=["some-host.com"]', `--savedObjects.maxImportExportSize=10001`, + // for testing set buffer duration to 0 to immediately flush counters into saved objects. + '--usageCollection.usageCounters.bufferDuration=0', ], }, }; diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/data.json b/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/data.json new file mode 100644 index 0000000000000..80071fe422780 --- /dev/null +++ b/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/data.json @@ -0,0 +1,111 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "ui-counter:myApp:30112020:loaded:my_event_885082425109579", + "source": { + "ui-counter": { + "count": 1 + }, + "type": "ui-counter", + "updated_at": "2020-11-30T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "ui-counter:myApp:30112020:count:my_event_885082425109579_2", + "source": { + "ui-counter": { + "count": 1 + }, + "type": "ui-counter", + "updated_at": "2020-11-30T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "ui-counter:myApp:30112020:count:my_event_885082425109579_2", + "source": { + "ui-counter": { + "count": 1 + }, + "type": "ui-counter", + "updated_at": "2020-10-28T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "ui-counter:myApp:30112020:click:my_event_885082425109579", + "source": { + "ui-counter": { + "count": 2 + }, + "type": "ui-counter", + "updated_at": "2020-11-30T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "ui-counter:myApp:30112020:click:my_event_885082425109579", + "source": { + "ui-counter": { + "count": 2 + }, + "type": "ui-counter", + "updated_at": "2020-11-30T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "uiCounter:09042021:count:myApp:some_app_event", + "source": { + "usage-counters": { + "count": 2, + "domainId": "uiCounter", + "counterName": "myApp:some_app_event", + "counterType": "count" + }, + "type": "usage-counters", + "updated_at": "2021-11-20T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "anotherDomainId:09042021:count:some_event_name", + "source": { + "usage-counters": { + "count": 2, + "domainId": "anotherDomainId", + "counterName": "some_event_name", + "counterType": "count" + }, + "type": "usage-counters", + "updated_at": "2021-11-20T11:43:00.961Z" + } + } +} + diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/data.json.gz b/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/data.json.gz deleted file mode 100644 index 3f42c777260b3bb8c9892f0b4e7c1ed0f18292ed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 236 zcmVQOZ*BnXld%qhFc5}!o`Q6yXaMqJu++`}+TP?Von^e4lhfqX_qj)CCC~=tX558Es+9vX<)Z1mUGT zi(1Sg$EAa&q=hzhr&@j;4o$-&KxDvxS6WCVEzMQ0>Ml>y1X32W1R+cI+0y2wOfof+Hf2BMuN|J3NtDK6!3Uo;Pk8 m%#1(glCys@znBbAmVPmrsw^%W{3W*ei+KQ7tJo%F1ONd3YHSDq diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/mappings.json b/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/mappings.json index 926fd5d79faa0..39902f8a9211a 100644 --- a/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/mappings.json +++ b/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/mappings.json @@ -35,6 +35,15 @@ } } }, + "usage-counters": { + "dynamic": false, + "properties": { + "domainId": { + "type": "keyword", + "ignore_above": 256 + } + } + }, "dashboard": { "properties": { "description": { diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/usage_counters/data.json b/test/api_integration/fixtures/es_archiver/saved_objects/usage_counters/data.json new file mode 100644 index 0000000000000..16e0364b24fda --- /dev/null +++ b/test/api_integration/fixtures/es_archiver/saved_objects/usage_counters/data.json @@ -0,0 +1,89 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "uiCounter:20112020:count:myApp:some_app_event", + "source": { + "usage-counters": { + "count": 2, + "domainId": "uiCounter", + "counterName": "myApp:some_app_event", + "counterType": "count" + }, + "type": "usage-counters", + "updated_at": "2021-11-20T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "anotherDomainId:20112020:count:some_event_name", + "source": { + "usage-counters": { + "count": 3, + "domainId": "anotherDomainId", + "counterName": "some_event_name", + "counterType": "count" + }, + "type": "usage-counters", + "updated_at": "2021-11-20T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "anotherDomainId:09042021:count:some_event_name", + "source": { + "usage-counters": { + "count": 2, + "domainId": "anotherDomainId", + "counterName": "some_event_name", + "counterType": "count" + }, + "type": "usage-counters", + "updated_at": "2021-04-09T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "anotherDomainId2:09042021:count:some_event_name", + "source": { + "usage-counters": { + "count": 1, + "domainId": "anotherDomainId2", + "counterName": "some_event_name", + "counterType": "count" + }, + "type": "usage-counters", + "updated_at": "2021-04-20T08:18:03.030Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "anotherDomainId3:09042021:custom_type:zero_count", + "source": { + "usage-counters": { + "count": 0, + "domainId": "anotherDomainId3", + "counterName": "zero_count", + "counterType": "custom_type" + }, + "type": "usage-counters", + "updated_at": "2021-04-20T08:18:03.030Z" + } + } +} diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/usage_counters/mappings.json b/test/api_integration/fixtures/es_archiver/saved_objects/usage_counters/mappings.json new file mode 100644 index 0000000000000..14ed147b2da8e --- /dev/null +++ b/test/api_integration/fixtures/es_archiver/saved_objects/usage_counters/mappings.json @@ -0,0 +1,276 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "number_of_replicas": "1" + } + }, + "mappings": { + "dynamic": "strict", + "properties": { + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "usage-counters": { + "dynamic": false, + "properties": { + "domainId": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "namespace": { + "type": "keyword" + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + } + } +} diff --git a/test/plugin_functional/config.ts b/test/plugin_functional/config.ts index 1651e213ee82d..d21a157975ac8 100644 --- a/test/plugin_functional/config.ts +++ b/test/plugin_functional/config.ts @@ -21,6 +21,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { testFiles: [ + require.resolve('./test_suites/usage_collection'), require.resolve('./test_suites/core'), require.resolve('./test_suites/custom_visualizations'), require.resolve('./test_suites/panel_actions'), @@ -59,6 +60,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { '--corePluginDeprecations.oldProperty=hello', '--corePluginDeprecations.secret=100', '--corePluginDeprecations.noLongerUsed=still_using', + // for testing set buffer duration to 0 to immediately flush counters into saved objects. + '--usageCollection.usageCounters.bufferDuration=0', ...plugins.map( (pluginDir) => `--plugin-path=${path.resolve(__dirname, 'plugins', pluginDir)}` ), diff --git a/test/plugin_functional/plugins/usage_collection/kibana.json b/test/plugin_functional/plugins/usage_collection/kibana.json new file mode 100644 index 0000000000000..c98e3b95d389c --- /dev/null +++ b/test/plugin_functional/plugins/usage_collection/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "usageCollectionTestPlugin", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["usageCollectionTestPlugin"], + "requiredPlugins": ["usageCollection"], + "server": true, + "ui": false +} diff --git a/test/plugin_functional/plugins/usage_collection/package.json b/test/plugin_functional/plugins/usage_collection/package.json new file mode 100644 index 0000000000000..33289bd8d727f --- /dev/null +++ b/test/plugin_functional/plugins/usage_collection/package.json @@ -0,0 +1,14 @@ +{ + "name": "usage_collection_test_plugin", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/usage_collection", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "SSPL-1.0 OR Elastic License 2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../../node_modules/.bin/tsc" + } +} diff --git a/test/plugin_functional/plugins/usage_collection/server/index.ts b/test/plugin_functional/plugins/usage_collection/server/index.ts new file mode 100644 index 0000000000000..172f8491a1a40 --- /dev/null +++ b/test/plugin_functional/plugins/usage_collection/server/index.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { UsageCollectionTestPlugin } from './plugin'; +export const plugin = () => new UsageCollectionTestPlugin(); diff --git a/test/plugin_functional/plugins/usage_collection/server/plugin.ts b/test/plugin_functional/plugins/usage_collection/server/plugin.ts new file mode 100644 index 0000000000000..523fbcfe058dc --- /dev/null +++ b/test/plugin_functional/plugins/usage_collection/server/plugin.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Plugin, CoreSetup } from 'kibana/server'; +import { + UsageCollectionSetup, + UsageCounter, +} from '../../../../../src/plugins/usage_collection/server'; +import { registerRoutes } from './routes'; + +export interface TestPluginDepsSetup { + usageCollection: UsageCollectionSetup; +} + +export class UsageCollectionTestPlugin implements Plugin { + private usageCounter?: UsageCounter; + + public setup(core: CoreSetup, { usageCollection }: TestPluginDepsSetup) { + const usageCounter = usageCollection.createUsageCounter('usageCollectionTestPlugin'); + + registerRoutes(core.http, usageCounter); + usageCounter.incrementCounter({ + counterName: 'duringSetup', + incrementBy: 10, + }); + usageCounter.incrementCounter({ counterName: 'duringSetup' }); + this.usageCounter = usageCounter; + } + + public start() { + if (!this.usageCounter) { + throw new Error('this.usageCounter is expected to be defined during setup.'); + } + this.usageCounter.incrementCounter({ counterName: 'duringStart' }); + } + + public stop() {} +} diff --git a/test/plugin_functional/plugins/usage_collection/server/routes.ts b/test/plugin_functional/plugins/usage_collection/server/routes.ts new file mode 100644 index 0000000000000..e67e454512779 --- /dev/null +++ b/test/plugin_functional/plugins/usage_collection/server/routes.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { HttpServiceSetup } from 'kibana/server'; +import { UsageCounter } from '../../../../../src/plugins/usage_collection/server'; + +export function registerRoutes(http: HttpServiceSetup, usageCounter: UsageCounter) { + const router = http.createRouter(); + router.get( + { + path: '/api/usage_collection_test_plugin', + validate: false, + }, + async (context, req, res) => { + usageCounter.incrementCounter({ counterName: 'routeAccessed' }); + return res.ok(); + } + ); +} diff --git a/test/plugin_functional/plugins/usage_collection/tsconfig.json b/test/plugin_functional/plugins/usage_collection/tsconfig.json new file mode 100644 index 0000000000000..3d9d8ca9451d4 --- /dev/null +++ b/test/plugin_functional/plugins/usage_collection/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "../../../../typings/**/*", + ], + "exclude": [], + "references": [ + { "path": "../../../../src/core/tsconfig.json" } + ] +} diff --git a/test/plugin_functional/test_suites/usage_collection/index.ts b/test/plugin_functional/test_suites/usage_collection/index.ts new file mode 100644 index 0000000000000..201b7b04ff222 --- /dev/null +++ b/test/plugin_functional/test_suites/usage_collection/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ loadTestFile }: PluginFunctionalProviderContext) { + describe('usage collection', function () { + loadTestFile(require.resolve('./usage_counters')); + }); +} diff --git a/test/plugin_functional/test_suites/usage_collection/usage_counters.ts b/test/plugin_functional/test_suites/usage_collection/usage_counters.ts new file mode 100644 index 0000000000000..f1591165b8d65 --- /dev/null +++ b/test/plugin_functional/test_suites/usage_collection/usage_counters.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; +import { + UsageCountersSavedObject, + serializeCounterKey, +} from '../../../../src/plugins/usage_collection/server/usage_counters'; + +export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + + async function getSavedObjectCounters() { + // wait until ES indexes the counter SavedObject; + await new Promise((res) => setTimeout(res, 7 * 1000)); + + return await supertest + .get('/api/saved_objects/_find?type=usage-counters') + .set('kbn-xsrf', 'true') + .expect(200) + .then(({ body }) => { + expect(body.total).to.above(1); + return (body.saved_objects as UsageCountersSavedObject[]).reduce((acc, savedObj) => { + const { count, counterName, domainId } = savedObj.attributes; + if (domainId === 'usageCollectionTestPlugin') { + acc[counterName] = count; + } + + return acc; + }, {} as Record); + }); + } + + describe('Usage Counters service', () => { + before(async () => { + const key = serializeCounterKey({ + counterName: 'routeAccessed', + counterType: 'count', + domainId: 'usageCollectionTestPlugin', + date: Date.now(), + }); + + await supertest.delete(`/api/saved_objects/usage-counters/${key}`).set('kbn-xsrf', 'true'); + }); + + it('stores usage counters sent during start and setup', async () => { + const { duringSetup, duringStart, routeAccessed } = await getSavedObjectCounters(); + + expect(duringSetup).to.be(11); + expect(duringStart).to.be(1); + expect(routeAccessed).to.be(undefined); + }); + + it('stores usage counters triggered by runtime activities', async () => { + await supertest.get('/api/usage_collection_test_plugin').set('kbn-xsrf', 'true').expect(200); + + const { routeAccessed } = await getSavedObjectCounters(); + expect(routeAccessed).to.be(1); + }); + }); +} From 3b7ef07eca9ed07c0823c3a73905e2f3dd74e780 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Wed, 14 Apr 2021 15:32:44 +0300 Subject: [PATCH 10/43] [TSVB] Field validation should not be performed on string indexes. (#97052) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/lib/vis_data/get_interval_and_timefield.ts | 4 +++- .../vis_data/request_processors/annotations/date_histogram.js | 4 +++- .../lib/vis_data/request_processors/annotations/query.js | 4 +++- .../lib/vis_data/request_processors/annotations/top_hits.js | 4 +++- .../lib/vis_data/request_processors/series/date_histogram.js | 2 +- .../lib/vis_data/request_processors/table/date_histogram.js | 2 +- 6 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.ts index e3d0cec1a6939..1d35a9fd28e61 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.ts @@ -19,7 +19,9 @@ export function getIntervalAndTimefield( (series.override_index_pattern ? series.series_time_field : panel.time_field) || index.indexPattern?.timeFieldName; - validateField(timeField!, index); + if (panel.use_kibana_indexes) { + validateField(timeField!, index); + } let interval = panel.interval; let maxBars = panel.max_bars; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js index 48b35d0db5086..bfb3e0f218460 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js @@ -27,7 +27,9 @@ export function dateHistogram( const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); const timeField = annotation.time_field || annotationIndex.indexPattern?.timeFieldName || ''; - validateField(timeField, annotationIndex); + if (panel.use_kibana_indexes) { + validateField(timeField, annotationIndex); + } const { bucketSize, intervalString } = getBucketSize( req, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js index 3be567dfe1f40..fcad23b9170a7 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js @@ -24,7 +24,9 @@ export function query( const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); const timeField = (annotation.time_field || annotationIndex.indexPattern?.timeFieldName) ?? ''; - validateField(timeField, annotationIndex); + if (panel.use_kibana_indexes) { + validateField(timeField, annotationIndex); + } const { bucketSize } = getBucketSize(req, 'auto', capabilities, barTargetUiSettings); const { from, to } = getTimerange(req); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js index 447cfdbc8c6e4..b85eb39c18ba6 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js @@ -14,7 +14,9 @@ export function topHits(req, panel, annotation, esQueryConfig, annotationIndex) const fields = (annotation.fields && annotation.fields.split(/[,\s]+/)) || []; const timeField = annotation.time_field || annotationIndex.indexPattern?.timeFieldName || ''; - validateField(timeField, annotationIndex); + if (panel.use_kibana_indexes) { + validateField(timeField, annotationIndex); + } overwrite(doc, `aggs.${annotation.id}.aggs.hits.top_hits`, { sort: [ diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js index 41ed472c31936..29cf3f274dc24 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js @@ -67,7 +67,7 @@ export function dateHistogram( intervalString, bucketSize, seriesId: series.id, - index: seriesIndex.indexPattern?.id, + index: panel.use_kibana_indexes ? seriesIndex.indexPattern?.id : undefined, }); return next(doc); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js index 4840e625383ca..f0989cf0fa08b 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js @@ -23,7 +23,7 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti const meta = { timeField, - index: seriesIndex.indexPattern?.id, + index: panel.use_kibana_indexes ? seriesIndex.indexPattern?.id : undefined, }; const getDateHistogramForLastBucketMode = () => { From bcc1acb1ddbb9c46333557dc15e8c41b5cd45a8a Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Wed, 14 Apr 2021 15:33:41 +0300 Subject: [PATCH 11/43] [TSVB][performance] remove visPayloadSchema.validate (#97091) * [TSVB][performance] remove visPayloadSchema.validate Part of: #97061 * Update vis.ts --- src/plugins/vis_type_timeseries/server/routes/vis.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/plugins/vis_type_timeseries/server/routes/vis.ts b/src/plugins/vis_type_timeseries/server/routes/vis.ts index 733face97cb4a..b2f27ab3c4861 100644 --- a/src/plugins/vis_type_timeseries/server/routes/vis.ts +++ b/src/plugins/vis_type_timeseries/server/routes/vis.ts @@ -9,7 +9,6 @@ import { schema } from '@kbn/config-schema'; import { ensureNoUnsafeProperties } from '@kbn/std'; import { getVisData } from '../lib/get_vis_data'; -import { visPayloadSchema } from '../../common/vis_schema'; import { ROUTES } from '../../common/constants'; import { Framework } from '../plugin'; import type { VisTypeTimeseriesRouter } from '../types'; @@ -34,14 +33,6 @@ export const visDataRoutes = (router: VisTypeTimeseriesRouter, framework: Framew }); } - try { - visPayloadSchema.validate(request.body); - } catch (error) { - framework.logger.debug( - `Request validation error: ${error.message}. This most likely means your TSVB visualization contains outdated configuration. You can report this problem under https://github.com/elastic/kibana/issues/new?template=Bug_report.md` - ); - } - const results = await getVisData(requestContext, request, framework); return response.ok({ body: results }); } From 1630c14a152b2b9737757c80c144e509bd7cad1c Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Wed, 14 Apr 2021 08:14:12 -0500 Subject: [PATCH 12/43] [Workplace Search] Remove shadows from Source overview panels (#97055) --- .../content_sources/components/overview.tsx | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index dc925e21460da..a5a2d8ab73d94 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -230,7 +230,12 @@ export const Overview: React.FC = () => { {groups.map((group, index) => ( - + {group.name} @@ -248,7 +253,7 @@ export const Overview: React.FC = () => {

{CONFIGURATION_TITLE}

- + {details.map((detail, index) => ( {

{DOCUMENT_PERMISSIONS_TITLE}

- + @@ -298,7 +303,7 @@ export const Overview: React.FC = () => {

{DOCUMENT_PERMISSIONS_TITLE}

- + @@ -329,7 +334,7 @@ export const Overview: React.FC = () => { ); const sourceStatus = ( - +
{STATUS_HEADER} @@ -353,7 +358,7 @@ export const Overview: React.FC = () => { ); const permissionsStatus = ( - +
{STATUS_HEADING} @@ -389,7 +394,7 @@ export const Overview: React.FC = () => { ); const credentials = ( - +
{CREDENTIALS_TITLE} @@ -409,7 +414,7 @@ export const Overview: React.FC = () => { title: string; children: React.ReactNode; }) => ( - +
{DOCUMENTATION_LINK_TITLE} @@ -424,7 +429,7 @@ export const Overview: React.FC = () => { ); const documentPermssionsLicenseLocked = ( - + From 366a537d37467dca4af4fac75d4a1a0f19a6e79d Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Wed, 14 Apr 2021 08:25:18 -0500 Subject: [PATCH 13/43] [Workplace Search] Add breadcrumbs to Role mappings (#97051) * Update Workplace Search nav to align with App Search * Add constants to shared * [App Search] Use shared constants * [Workplace Search] Add breadcrumbs to Role mappings * Enable shouldShowActiveForSubroutes --- .../app_search/components/role_mappings/constants.ts | 9 --------- .../components/role_mappings/role_mapping.tsx | 8 +++++--- .../applications/shared/role_mapping/constants.ts | 10 ++++++++++ .../workplace_search/components/layout/nav.tsx | 4 +++- .../public/applications/workplace_search/constants.ts | 2 +- .../views/role_mappings/role_mapping.tsx | 10 +++++++++- .../views/role_mappings/role_mappings.tsx | 2 ++ 7 files changed, 30 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts index 1fed750a86dc4..2f9ff707f9631 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts @@ -18,15 +18,6 @@ export const UPDATE_ROLE_MAPPING = i18n.translate( { defaultMessage: 'Update role mapping' } ); -export const ADD_ROLE_MAPPING_TITLE = i18n.translate( - 'xpack.enterpriseSearch.appSearch.roleMapping.newRoleMappingTitle', - { defaultMessage: 'Add role mapping' } -); -export const MANAGE_ROLE_MAPPING_TITLE = i18n.translate( - 'xpack.enterpriseSearch.appSearch.roleMapping.manageRoleMappingTitle', - { defaultMessage: 'Manage role mapping' } -); - export const EMPTY_ROLE_MAPPINGS_BODY = i18n.translate( 'xpack.enterpriseSearch.appSearch.roleMapping.emptyRoleMappingsBody', { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx index 47c0eb2483ec1..610ceae8856f2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx @@ -33,7 +33,11 @@ import { DeleteMappingCallout, RoleSelector, } from '../../../shared/role_mapping'; -import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; +import { + ROLE_MAPPINGS_TITLE, + ADD_ROLE_MAPPING_TITLE, + MANAGE_ROLE_MAPPING_TITLE, +} from '../../../shared/role_mapping/constants'; import { AppLogic } from '../../app_logic'; import { roleHasScopedEngines } from '../../utils/role/has_scoped_engines'; @@ -42,8 +46,6 @@ import { Engine } from '../engine/types'; import { SAVE_ROLE_MAPPING, UPDATE_ROLE_MAPPING, - ADD_ROLE_MAPPING_TITLE, - MANAGE_ROLE_MAPPING_TITLE, ADVANCED_ROLE_TYPES, STANDARD_ROLE_TYPES, ADVANCED_ROLE_SELECTORS_TITLE, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts index 8abab6d060a96..a172fbae18d8f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts @@ -108,6 +108,16 @@ export const ROLE_MAPPINGS_TITLE = i18n.translate( } ); +export const ADD_ROLE_MAPPING_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.newRoleMappingTitle', + { defaultMessage: 'Add role mapping' } +); + +export const MANAGE_ROLE_MAPPING_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.manageRoleMappingTitle', + { defaultMessage: 'Manage role mapping' } +); + export const EMPTY_ROLE_MAPPINGS_TITLE = i18n.translate( 'xpack.enterpriseSearch.roleMapping.emptyRoleMappingsTitle', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index f2edc04a5661c..51cdcc688e682 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -42,7 +42,9 @@ export const WorkplaceSearchNav: React.FC = ({ {NAV.GROUPS} - {NAV.ROLE_MAPPINGS} + + {NAV.ROLE_MAPPINGS} + {NAV.SECURITY} {NAV.SETTINGS} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index d771673506761..9f758cacdfce3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -40,7 +40,7 @@ export const NAV = { defaultMessage: 'Content', }), ROLE_MAPPINGS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.roleMappings', { - defaultMessage: 'Role Mappings', + defaultMessage: 'Users & roles', }), SECURITY: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.security', { defaultMessage: 'Security', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx index d69e94b20444e..fb366883601a6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx @@ -24,13 +24,19 @@ import { import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../shared/flash_messages'; +import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { Loading } from '../../../shared/loading'; import { AttributeSelector, DeleteMappingCallout, RoleSelector, } from '../../../shared/role_mapping'; -import { ROLE_LABEL } from '../../../shared/role_mapping/constants'; +import { + ROLE_LABEL, + ROLE_MAPPINGS_TITLE, + ADD_ROLE_MAPPING_TITLE, + MANAGE_ROLE_MAPPING_TITLE, +} from '../../../shared/role_mapping/constants'; import { ViewContentHeader } from '../../components/shared/view_content_header'; import { Role } from '../../types'; @@ -105,6 +111,7 @@ export const RoleMapping: React.FC = ({ isNew }) => { const hasGroupAssignment = selectedGroups.size > 0 || includeInAllGroups; + const TITLE = isNew ? ADD_ROLE_MAPPING_TITLE : MANAGE_ROLE_MAPPING_TITLE; const SAVE_ROLE_MAPPING_LABEL = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.roleMapping.saveRoleMappingButtonMessage', { @@ -121,6 +128,7 @@ export const RoleMapping: React.FC = ({ isNew }) => { return ( <> +
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx index 0e3533d48a5a9..9ec0dfc0acefc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx @@ -12,6 +12,7 @@ import { useActions, useValues } from 'kea'; import { EuiEmptyPrompt, EuiPanel } from '@elastic/eui'; import { FlashMessages } from '../../../shared/flash_messages'; +import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { Loading } from '../../../shared/loading'; import { AddRoleMappingButton, RoleMappingsTable } from '../../../shared/role_mapping'; import { @@ -61,6 +62,7 @@ export const RoleMappings: React.FC = () => { return ( <> +
From e36650de70b38d6cd6c26c24e8bdd67834327ed4 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 14 Apr 2021 14:38:10 +0100 Subject: [PATCH 14/43] chore(NA): moving @kbn/config-schema into bazel (#96273) * chore(NA): moving @kbn/config-schema into bazel * chore(NA): correctly format packages for the new bazel standards * chore(NA): correctly maps srcs into source_files * chore(NA): remove config-schema dep from legacy built packages package.jsons * chore(NA): include kbn/config-schema in the list of bazel packages to be built * chore(NA): change import to fix typechecking * chore(NA): remove dependency on new package built by bazel * chore(NA): be more explicit about incremental setting * chore(NA): include pretty in the args for ts_project rule * docs(NA): include package migration completion in the developer getting started Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../monorepo-packages.asciidoc | 2 +- package.json | 2 +- packages/BUILD.bazel | 3 +- packages/kbn-cli-dev-mode/package.json | 1 - packages/kbn-config-schema/BUILD.bazel | 86 +++++++++++++++++++ packages/kbn-config-schema/package.json | 10 +-- packages/kbn-config-schema/tsconfig.json | 8 +- packages/kbn-config/package.json | 1 - packages/kbn-legacy-logging/package.json | 3 +- packages/kbn-server-http-tools/package.json | 1 - packages/kbn-utils/package.json | 3 - src/core/server/server.api.md | 2 +- .../vis_type_timeseries/common/vis_schema.ts | 2 +- x-pack/package.json | 1 - yarn.lock | 2 +- 15 files changed, 101 insertions(+), 26 deletions(-) create mode 100644 packages/kbn-config-schema/BUILD.bazel diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 655a491f8b3ca..88a142e5b53c0 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -63,5 +63,5 @@ yarn kbn watch-bazel - @elastic/datemath - @kbn/apm-utils - +- @kbn/config-schema diff --git a/package.json b/package.json index c1f2a3b3cf132..1d31aa627129c 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "@kbn/apm-config-loader": "link:packages/kbn-apm-config-loader", "@kbn/apm-utils": "link:bazel-bin/packages/kbn-apm-utils/npm_module", "@kbn/config": "link:packages/kbn-config", - "@kbn/config-schema": "link:packages/kbn-config-schema", + "@kbn/config-schema": "link:bazel-bin/packages/kbn-config-schema/npm_module", "@kbn/crypto": "link:packages/kbn-crypto", "@kbn/i18n": "link:packages/kbn-i18n", "@kbn/interpreter": "link:packages/kbn-interpreter", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 3944c2356badc..aa66c96764718 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -4,6 +4,7 @@ filegroup( name = "build", srcs = [ "//packages/elastic-datemath:build", - "//packages/kbn-apm-utils:build" + "//packages/kbn-apm-utils:build", + "//packages/kbn-config-schema:build" ], ) diff --git a/packages/kbn-cli-dev-mode/package.json b/packages/kbn-cli-dev-mode/package.json index 2ee9831e96084..1ea319ef3601c 100644 --- a/packages/kbn-cli-dev-mode/package.json +++ b/packages/kbn-cli-dev-mode/package.json @@ -15,7 +15,6 @@ }, "dependencies": { "@kbn/config": "link:../kbn-config", - "@kbn/config-schema": "link:../kbn-config-schema", "@kbn/logging": "link:../kbn-logging", "@kbn/server-http-tools": "link:../kbn-server-http-tools", "@kbn/optimizer": "link:../kbn-optimizer", diff --git a/packages/kbn-config-schema/BUILD.bazel b/packages/kbn-config-schema/BUILD.bazel new file mode 100644 index 0000000000000..5dcbd9e5a802a --- /dev/null +++ b/packages/kbn-config-schema/BUILD.bazel @@ -0,0 +1,86 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-config-schema" +PKG_REQUIRE_NAME = "@kbn/config-schema" + +SOURCE_FILES = glob([ + "src/**/*.ts", + "types/joi.d.ts" +]) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +SRC_DEPS = [ + "@npm//joi", + "@npm//lodash", + "@npm//moment", + "@npm//tsd", + "@npm//type-detect", +] + +TYPES_DEPS = [ + "@npm//@types/jest", + "@npm//@types/joi", + "@npm//@types/lodash", + "@npm//@types/node", + "@npm//@types/type-detect", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = [], + deps = [":tsc"] + DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + srcs = NPM_MODULE_EXTRA_FILES, + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-config-schema/package.json b/packages/kbn-config-schema/package.json index a47dee88db588..85b52f5d75533 100644 --- a/packages/kbn-config-schema/package.json +++ b/packages/kbn-config-schema/package.json @@ -1,12 +1,8 @@ { "name": "@kbn/config-schema", - "main": "./target/out/index.js", - "types": "./target/types/index.d.ts", + "main": "./target/index.js", + "types": "./target/index.d.ts", "version": "1.0.0", "license": "SSPL-1.0 OR Elastic License 2.0", - "private": true, - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build" - } + "private": true } \ No newline at end of file diff --git a/packages/kbn-config-schema/tsconfig.json b/packages/kbn-config-schema/tsconfig.json index d33683acded16..5490f37a943fc 100644 --- a/packages/kbn-config-schema/tsconfig.json +++ b/packages/kbn-config-schema/tsconfig.json @@ -1,14 +1,14 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, - "outDir": "./target/out", - "declarationDir": "./target/types", - "stripInternal": true, "declaration": true, "declarationMap": true, + "incremental": true, + "outDir": "target", + "rootDir": "src", "sourceMap": true, "sourceRoot": "../../../../../packages/kbn-config-schema/src", + "stripInternal": true, "types": [ "jest", "node" diff --git a/packages/kbn-config/package.json b/packages/kbn-config/package.json index e71175034787a..8093b6ac0d211 100644 --- a/packages/kbn-config/package.json +++ b/packages/kbn-config/package.json @@ -11,7 +11,6 @@ }, "dependencies": { "@elastic/safer-lodash-set": "link:../elastic-safer-lodash-set", - "@kbn/config-schema": "link:../kbn-config-schema", "@kbn/logging": "link:../kbn-logging", "@kbn/std": "link:../kbn-std" }, diff --git a/packages/kbn-legacy-logging/package.json b/packages/kbn-legacy-logging/package.json index 96edeccad6658..9450fd39607ea 100644 --- a/packages/kbn-legacy-logging/package.json +++ b/packages/kbn-legacy-logging/package.json @@ -11,7 +11,6 @@ "kbn:watch": "yarn build --watch" }, "dependencies": { - "@kbn/utils": "link:../kbn-utils", - "@kbn/config-schema": "link:../kbn-config-schema" + "@kbn/utils": "link:../kbn-utils" } } diff --git a/packages/kbn-server-http-tools/package.json b/packages/kbn-server-http-tools/package.json index 6c65a0dd6e475..24f8f8d67dfd7 100644 --- a/packages/kbn-server-http-tools/package.json +++ b/packages/kbn-server-http-tools/package.json @@ -11,7 +11,6 @@ "kbn:watch": "yarn build --watch" }, "dependencies": { - "@kbn/config-schema": "link:../kbn-config-schema", "@kbn/crypto": "link:../kbn-crypto", "@kbn/std": "link:../kbn-std" }, diff --git a/packages/kbn-utils/package.json b/packages/kbn-utils/package.json index b6bb7759c40ef..2c3c0c11b65ab 100644 --- a/packages/kbn-utils/package.json +++ b/packages/kbn-utils/package.json @@ -9,8 +9,5 @@ "build": "rm -rf target && ../../node_modules/.bin/tsc", "kbn:bootstrap": "yarn build", "kbn:watch": "yarn build --watch" - }, - "dependencies": { - "@kbn/config-schema": "link:../kbn-config-schema" } } \ No newline at end of file diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 53b2eb8610418..05af684053f39 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -363,7 +363,7 @@ export const config: { healthCheck: import("@kbn/config-schema").ObjectType<{ delay: Type; }>; - ignoreVersionMismatch: import("@kbn/config-schema/target/types/types").ConditionalType; + ignoreVersionMismatch: import("@kbn/config-schema/target/types").ConditionalType; }>; }; logging: { diff --git a/src/plugins/vis_type_timeseries/common/vis_schema.ts b/src/plugins/vis_type_timeseries/common/vis_schema.ts index 9fb7644b0fd16..d31fed4639ffe 100644 --- a/src/plugins/vis_type_timeseries/common/vis_schema.ts +++ b/src/plugins/vis_type_timeseries/common/vis_schema.ts @@ -7,7 +7,7 @@ */ import { schema } from '@kbn/config-schema'; -import { TypeOptions } from '@kbn/config-schema/target/types/types'; +import { TypeOptions } from '@kbn/config-schema/target/types'; const stringOptionalNullable = schema.maybe(schema.nullable(schema.string())); const stringOptional = schema.maybe(schema.string()); diff --git a/x-pack/package.json b/x-pack/package.json index 9e96388145038..36a6d120d946b 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -38,7 +38,6 @@ }, "dependencies": { "@elastic/safer-lodash-set": "link:../packages/elastic-safer-lodash-set", - "@kbn/config-schema": "link:../packages/kbn-config-schema", "@kbn/i18n": "link:../packages/kbn-i18n", "@kbn/interpreter": "link:../packages/kbn-interpreter", "@kbn/ui-framework": "link:../packages/kbn-ui-framework" diff --git a/yarn.lock b/yarn.lock index 693da02fddfdf..4f20e0122d470 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2632,7 +2632,7 @@ version "0.0.0" uid "" -"@kbn/config-schema@link:packages/kbn-config-schema": +"@kbn/config-schema@link:bazel-bin/packages/kbn-config-schema/npm_module": version "0.0.0" uid "" From b401cbb3ebc86343b12d45d34c8e122f0d38117d Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Wed, 14 Apr 2021 09:51:47 -0400 Subject: [PATCH 15/43] export DomainDeprecationDetails type from public + fix typo (#96885) --- ...public.domaindeprecationdetails.domainid.md | 11 +++++++++++ ...gin-core-public.domaindeprecationdetails.md | 18 ++++++++++++++++++ .../core/public/kibana-plugin-core-public.md | 1 + src/core/public/index.ts | 7 ++++++- src/core/public/public.api.md | 10 +++++++++- .../core_plugin_deprecations/server/config.ts | 2 +- .../test_suites/core/deprecations.ts | 2 +- 7 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.domainid.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.md diff --git a/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.domainid.md b/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.domainid.md new file mode 100644 index 0000000000000..b6d1f9386be8f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.domainid.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [DomainDeprecationDetails](./kibana-plugin-core-public.domaindeprecationdetails.md) > [domainId](./kibana-plugin-core-public.domaindeprecationdetails.domainid.md) + +## DomainDeprecationDetails.domainId property + +Signature: + +```typescript +domainId: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.md b/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.md new file mode 100644 index 0000000000000..93d715a11c503 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [DomainDeprecationDetails](./kibana-plugin-core-public.domaindeprecationdetails.md) + +## DomainDeprecationDetails interface + +Signature: + +```typescript +export interface DomainDeprecationDetails extends DeprecationsDetails +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [domainId](./kibana-plugin-core-public.domaindeprecationdetails.domainid.md) | string | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 32f17d5488f66..39e554f5492ac 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -61,6 +61,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [CoreStart](./kibana-plugin-core-public.corestart.md) | Core services exposed to the Plugin start lifecycle | | [DeprecationsServiceStart](./kibana-plugin-core-public.deprecationsservicestart.md) | DeprecationsService provides methods to fetch domain deprecation details from the Kibana server. | | [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) | | +| [DomainDeprecationDetails](./kibana-plugin-core-public.domaindeprecationdetails.md) | | | [ErrorToastOptions](./kibana-plugin-core-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) error APIs. | | [FatalErrorInfo](./kibana-plugin-core-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | | [FatalErrorsSetup](./kibana-plugin-core-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 750f2e27dc950..ca432d6b8269f 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -67,7 +67,12 @@ import { DocLinksStart } from './doc_links'; import { SavedObjectsStart } from './saved_objects'; import { DeprecationsServiceStart } from './deprecations'; -export type { PackageInfo, EnvironmentMode, IExternalUrlPolicy } from '../server/types'; +export type { + PackageInfo, + EnvironmentMode, + IExternalUrlPolicy, + DomainDeprecationDetails, +} from '../server/types'; export type { CoreContext, CoreSystem } from './core_system'; export { DEFAULT_APP_CATEGORIES } from '../utils'; export type { diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 3f4de7fccac72..88e4b0448a7be 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -476,7 +476,6 @@ export const DEFAULT_APP_CATEGORIES: Record; // @public export interface DeprecationsServiceStart { - // Warning: (ae-forgotten-export) The symbol "DomainDeprecationDetails" needs to be exported by the entry point index.d.ts getAllDeprecations: () => Promise; getDeprecations: (domainId: string) => Promise; isDeprecationResolvable: (details: DomainDeprecationDetails) => boolean; @@ -658,6 +657,15 @@ export interface DocLinksStart { }; } +// Warning: (ae-forgotten-export) The symbol "DeprecationsDetails" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "DomainDeprecationDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface DomainDeprecationDetails extends DeprecationsDetails { + // (undocumented) + domainId: string; +} + export { EnvironmentMode } // @public diff --git a/test/plugin_functional/plugins/core_plugin_deprecations/server/config.ts b/test/plugin_functional/plugins/core_plugin_deprecations/server/config.ts index db4288d26a3d7..e051c39f68150 100644 --- a/test/plugin_functional/plugins/core_plugin_deprecations/server/config.ts +++ b/test/plugin_functional/plugins/core_plugin_deprecations/server/config.ts @@ -24,7 +24,7 @@ const configSecretDeprecation: ConfigDeprecation = (settings, fromPath, addDepre addDeprecation({ documentationUrl: 'config-secret-doc-url', message: - 'Kibana plugin funcitonal tests will no longer allow corePluginDeprecations.secret ' + + 'Kibana plugin functional tests will no longer allow corePluginDeprecations.secret ' + 'config to be set to anything except 42.', }); } diff --git a/test/plugin_functional/test_suites/core/deprecations.ts b/test/plugin_functional/test_suites/core/deprecations.ts index c44781ab284c6..a78527d0d82e2 100644 --- a/test/plugin_functional/test_suites/core/deprecations.ts +++ b/test/plugin_functional/test_suites/core/deprecations.ts @@ -42,7 +42,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide { level: 'critical', message: - 'Kibana plugin funcitonal tests will no longer allow corePluginDeprecations.secret config to be set to anything except 42.', + 'Kibana plugin functional tests will no longer allow corePluginDeprecations.secret config to be set to anything except 42.', correctiveActions: {}, documentationUrl: 'config-secret-doc-url', domainId: 'corePluginDeprecations', From e2eeb4461339104f3187b8c0c837325ffe984c92 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Wed, 14 Apr 2021 16:36:36 +0200 Subject: [PATCH 16/43] Bump hosted-git-info from 2.5.0/3.0.7 to 2.8.9/3.0.8 (#96987) --- packages/kbn-pm/dist/index.js | 156 +++++++++++++++++++++++++--------- yarn.lock | 12 +-- 2 files changed, 124 insertions(+), 44 deletions(-) diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index af199fbbc27c2..e6cdd52686656 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -29754,15 +29754,14 @@ var gitHosts = __webpack_require__(284) var GitHost = module.exports = __webpack_require__(285) var protocolToRepresentationMap = { - 'git+ssh': 'sshurl', - 'git+https': 'https', - 'ssh': 'sshurl', - 'git': 'git' + 'git+ssh:': 'sshurl', + 'git+https:': 'https', + 'ssh:': 'sshurl', + 'git:': 'git' } function protocolToRepresentation (protocol) { - if (protocol.substr(-1) === ':') protocol = protocol.slice(0, -1) - return protocolToRepresentationMap[protocol] || protocol + return protocolToRepresentationMap[protocol] || protocol.slice(0, -1) } var authProtocols = { @@ -29776,6 +29775,7 @@ var authProtocols = { var cache = {} module.exports.fromUrl = function (giturl, opts) { + if (typeof giturl !== 'string') return var key = giturl + JSON.stringify(opts || {}) if (!(key in cache)) { @@ -29791,13 +29791,13 @@ function fromUrl (giturl, opts) { isGitHubShorthand(giturl) ? 'github:' + giturl : giturl ) var parsed = parseGitUrl(url) - var shortcutMatch = url.match(new RegExp('^([^:]+):(?:(?:[^@:]+(?:[^@]+)?@)?([^/]*))[/](.+?)(?:[.]git)?($|#)')) + var shortcutMatch = url.match(/^([^:]+):(?:[^@]+@)?(?:([^/]*)\/)?([^#]+)/) var matches = Object.keys(gitHosts).map(function (gitHostName) { try { var gitHostInfo = gitHosts[gitHostName] var auth = null if (parsed.auth && authProtocols[parsed.protocol]) { - auth = decodeURIComponent(parsed.auth) + auth = parsed.auth } var committish = parsed.hash ? decodeURIComponent(parsed.hash.substr(1)) : null var user = null @@ -29805,22 +29805,27 @@ function fromUrl (giturl, opts) { var defaultRepresentation = null if (shortcutMatch && shortcutMatch[1] === gitHostName) { user = shortcutMatch[2] && decodeURIComponent(shortcutMatch[2]) - project = decodeURIComponent(shortcutMatch[3]) + project = decodeURIComponent(shortcutMatch[3].replace(/\.git$/, '')) defaultRepresentation = 'shortcut' } else { - if (parsed.host !== gitHostInfo.domain) return + if (parsed.host && parsed.host !== gitHostInfo.domain && parsed.host.replace(/^www[.]/, '') !== gitHostInfo.domain) return if (!gitHostInfo.protocols_re.test(parsed.protocol)) return if (!parsed.path) return var pathmatch = gitHostInfo.pathmatch var matched = parsed.path.match(pathmatch) if (!matched) return - if (matched[1] != null) user = decodeURIComponent(matched[1].replace(/^:/, '')) - if (matched[2] != null) project = decodeURIComponent(matched[2]) + /* istanbul ignore else */ + if (matched[1] !== null && matched[1] !== undefined) { + user = decodeURIComponent(matched[1].replace(/^:/, '')) + } + project = decodeURIComponent(matched[2]) defaultRepresentation = protocolToRepresentation(parsed.protocol) } return new GitHost(gitHostName, user, auth, project, committish, defaultRepresentation, opts) } catch (ex) { - if (!(ex instanceof URIError)) throw ex + /* istanbul ignore else */ + if (ex instanceof URIError) { + } else throw ex } }).filter(function (gitHostInfo) { return gitHostInfo }) if (matches.length !== 1) return @@ -29850,9 +29855,31 @@ function fixupUnqualifiedGist (giturl) { } function parseGitUrl (giturl) { - if (typeof giturl !== 'string') giturl = '' + giturl var matched = giturl.match(/^([^@]+)@([^:/]+):[/]?((?:[^/]+[/])?[^/]+?)(?:[.]git)?(#.*)?$/) - if (!matched) return url.parse(giturl) + if (!matched) { + var legacy = url.parse(giturl) + // If we don't have url.URL, then sorry, this is just not fixable. + // This affects Node <= 6.12. + if (legacy.auth && typeof url.URL === 'function') { + // git urls can be in the form of scp-style/ssh-connect strings, like + // git+ssh://user@host.com:some/path, which the legacy url parser + // supports, but WhatWG url.URL class does not. However, the legacy + // parser de-urlencodes the username and password, so something like + // https://user%3An%40me:p%40ss%3Aword@x.com/ becomes + // https://user:n@me:p@ss:word@x.com/ which is all kinds of wrong. + // Pull off just the auth and host, so we dont' get the confusing + // scp-style URL, then pass that to the WhatWG parser to get the + // auth properly escaped. + var authmatch = giturl.match(/[^@]+@[^:/]+/) + /* istanbul ignore else - this should be impossible */ + if (authmatch) { + var whatwg = new url.URL(authmatch[0]) + legacy.auth = whatwg.username || '' + if (whatwg.password) legacy.auth += ':' + whatwg.password + } + } + return legacy + } return { protocol: 'git+ssh:', slashes: true, @@ -29894,7 +29921,7 @@ var gitHosts = module.exports = { 'filetemplate': 'https://{auth@}raw.githubusercontent.com/{user}/{project}/{committish}/{path}', 'bugstemplate': 'https://{domain}/{user}/{project}/issues', 'gittemplate': 'git://{auth@}{domain}/{user}/{project}.git{#committish}', - 'tarballtemplate': 'https://{domain}/{user}/{project}/archive/{committish}.tar.gz' + 'tarballtemplate': 'https://codeload.{domain}/{user}/{project}/tar.gz/{committish}' }, bitbucket: { 'protocols': [ 'git+ssh', 'git+https', 'ssh', 'https' ], @@ -29906,25 +29933,30 @@ var gitHosts = module.exports = { 'protocols': [ 'git+ssh', 'git+https', 'ssh', 'https' ], 'domain': 'gitlab.com', 'treepath': 'tree', - 'docstemplate': 'https://{domain}/{user}/{project}{/tree/committish}#README', 'bugstemplate': 'https://{domain}/{user}/{project}/issues', - 'tarballtemplate': 'https://{domain}/{user}/{project}/repository/archive.tar.gz?ref={committish}' + 'httpstemplate': 'git+https://{auth@}{domain}/{user}/{projectPath}.git{#committish}', + 'tarballtemplate': 'https://{domain}/{user}/{project}/repository/archive.tar.gz?ref={committish}', + 'pathmatch': /^[/]([^/]+)[/]((?!.*(\/-\/|\/repository\/archive\.tar\.gz\?=.*|\/repository\/[^/]+\/archive.tar.gz$)).*?)(?:[.]git|[/])?$/ }, gist: { 'protocols': [ 'git', 'git+ssh', 'git+https', 'ssh', 'https' ], 'domain': 'gist.github.com', - 'pathmatch': /^[/](?:([^/]+)[/])?([a-z0-9]+)(?:[.]git)?$/, + 'pathmatch': /^[/](?:([^/]+)[/])?([a-z0-9]{32,})(?:[.]git)?$/, 'filetemplate': 'https://gist.githubusercontent.com/{user}/{project}/raw{/committish}/{path}', 'bugstemplate': 'https://{domain}/{project}', 'gittemplate': 'git://{domain}/{project}.git{#committish}', 'sshtemplate': 'git@{domain}:/{project}.git{#committish}', 'sshurltemplate': 'git+ssh://git@{domain}/{project}.git{#committish}', 'browsetemplate': 'https://{domain}/{project}{/committish}', + 'browsefiletemplate': 'https://{domain}/{project}{/committish}{#path}', 'docstemplate': 'https://{domain}/{project}{/committish}', 'httpstemplate': 'git+https://{domain}/{project}.git{#committish}', 'shortcuttemplate': '{type}:{project}{#committish}', 'pathtemplate': '{project}{#committish}', - 'tarballtemplate': 'https://{domain}/{user}/{project}/archive/{committish}.tar.gz' + 'tarballtemplate': 'https://codeload.github.com/gist/{project}/tar.gz/{committish}', + 'hashformat': function (fragment) { + return 'file-' + formatHashFragment(fragment) + } } } @@ -29932,12 +29964,14 @@ var gitHostDefaults = { 'sshtemplate': 'git@{domain}:{user}/{project}.git{#committish}', 'sshurltemplate': 'git+ssh://git@{domain}/{user}/{project}.git{#committish}', 'browsetemplate': 'https://{domain}/{user}/{project}{/tree/committish}', + 'browsefiletemplate': 'https://{domain}/{user}/{project}/{treepath}/{committish}/{path}{#fragment}', 'docstemplate': 'https://{domain}/{user}/{project}{/tree/committish}#readme', 'httpstemplate': 'git+https://{auth@}{domain}/{user}/{project}.git{#committish}', 'filetemplate': 'https://{domain}/{user}/{project}/raw/{committish}/{path}', 'shortcuttemplate': '{type}:{user}/{project}{#committish}', 'pathtemplate': '{user}/{project}{#committish}', - 'pathmatch': /^[/]([^/]+)[/]([^/]+?)(?:[.]git|[/])?$/ + 'pathmatch': /^[/]([^/]+)[/]([^/]+?)(?:[.]git|[/])?$/, + 'hashformat': formatHashFragment } Object.keys(gitHosts).forEach(function (name) { @@ -29951,6 +29985,10 @@ Object.keys(gitHosts).forEach(function (name) { }).join('|') + '):$') }) +function formatHashFragment (fragment) { + return fragment.toLowerCase().replace(/^\W+|\/|\W+$/g, '').replace(/\W+/g, '-') +} + /***/ }), /* 285 */ @@ -29959,9 +29997,25 @@ Object.keys(gitHosts).forEach(function (name) { "use strict"; var gitHosts = __webpack_require__(284) -var extend = Object.assign || __webpack_require__(112)._extend +/* eslint-disable node/no-deprecated-api */ + +// copy-pasta util._extend from node's source, to avoid pulling +// the whole util module into peoples' webpack bundles. +/* istanbul ignore next */ +var extend = Object.assign || function _extend (target, source) { + // Don't do anything if source isn't an object + if (source === null || typeof source !== 'object') return target + + var keys = Object.keys(source) + var i = keys.length + while (i--) { + target[keys[i]] = source[keys[i]] + } + return target +} -var GitHost = module.exports = function (type, user, auth, project, committish, defaultRepresentation, opts) { +module.exports = GitHost +function GitHost (type, user, auth, project, committish, defaultRepresentation, opts) { var gitHostInfo = this gitHostInfo.type = type Object.keys(gitHosts[type]).forEach(function (key) { @@ -29974,7 +30028,6 @@ var GitHost = module.exports = function (type, user, auth, project, committish, gitHostInfo.default = defaultRepresentation gitHostInfo.opts = opts || {} } -GitHost.prototype = {} GitHost.prototype.hash = function () { return this.committish ? '#' + this.committish : '' @@ -29983,27 +30036,43 @@ GitHost.prototype.hash = function () { GitHost.prototype._fill = function (template, opts) { if (!template) return var vars = extend({}, opts) + vars.path = vars.path ? vars.path.replace(/^[/]+/g, '') : '' opts = extend(extend({}, this.opts), opts) var self = this Object.keys(this).forEach(function (key) { if (self[key] != null && vars[key] == null) vars[key] = self[key] }) var rawAuth = vars.auth - var rawComittish = vars.committish + var rawcommittish = vars.committish + var rawFragment = vars.fragment + var rawPath = vars.path + var rawProject = vars.project Object.keys(vars).forEach(function (key) { - vars[key] = encodeURIComponent(vars[key]) + var value = vars[key] + if ((key === 'path' || key === 'project') && typeof value === 'string') { + vars[key] = value.split('/').map(function (pathComponent) { + return encodeURIComponent(pathComponent) + }).join('/') + } else { + vars[key] = encodeURIComponent(value) + } }) vars['auth@'] = rawAuth ? rawAuth + '@' : '' + vars['#fragment'] = rawFragment ? '#' + this.hashformat(rawFragment) : '' + vars.fragment = vars.fragment ? vars.fragment : '' + vars['#path'] = rawPath ? '#' + this.hashformat(rawPath) : '' + vars['/path'] = vars.path ? '/' + vars.path : '' + vars.projectPath = rawProject.split('/').map(encodeURIComponent).join('/') if (opts.noCommittish) { vars['#committish'] = '' vars['/tree/committish'] = '' - vars['/comittish'] = '' - vars.comittish = '' + vars['/committish'] = '' + vars.committish = '' } else { - vars['#committish'] = rawComittish ? '#' + rawComittish : '' + vars['#committish'] = rawcommittish ? '#' + rawcommittish : '' vars['/tree/committish'] = vars.committish - ? '/' + vars.treepath + '/' + vars.committish - : '' + ? '/' + vars.treepath + '/' + vars.committish + : '' vars['/committish'] = vars.committish ? '/' + vars.committish : '' vars.committish = vars.committish || 'master' } @@ -30026,8 +30095,19 @@ GitHost.prototype.sshurl = function (opts) { return this._fill(this.sshurltemplate, opts) } -GitHost.prototype.browse = function (opts) { - return this._fill(this.browsetemplate, opts) +GitHost.prototype.browse = function (P, F, opts) { + if (typeof P === 'string') { + if (typeof F !== 'string') { + opts = F + F = null + } + return this._fill(this.browsefiletemplate, extend({ + fragment: F, + path: P + }, opts)) + } else { + return this._fill(this.browsetemplate, P) + } } GitHost.prototype.docs = function (opts) { @@ -30054,14 +30134,13 @@ GitHost.prototype.path = function (opts) { return this._fill(this.pathtemplate, opts) } -GitHost.prototype.tarball = function (opts) { +GitHost.prototype.tarball = function (opts_) { + var opts = extend({}, opts_, { noCommittish: false }) return this._fill(this.tarballtemplate, opts) } GitHost.prototype.file = function (P, opts) { - return this._fill(this.filetemplate, extend({ - path: P.replace(/^[/]+/g, '') - }, opts)) + return this._fill(this.filetemplate, extend({ path: P }, opts)) } GitHost.prototype.getDefaultRepresentation = function () { @@ -30069,7 +30148,8 @@ GitHost.prototype.getDefaultRepresentation = function () { } GitHost.prototype.toString = function (opts) { - return (this[this.default] || this.sshurl).call(this, opts) + if (this.default && typeof this[this.default] === 'function') return this[this.default](opts) + return this.sshurl(opts) } diff --git a/yarn.lock b/yarn.lock index 4f20e0122d470..4a9d60a6af194 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15841,14 +15841,14 @@ hooker@~0.2.3: integrity sha1-uDT3I8xKJCqmWWNFnfbZhMXT2Vk= hosted-git-info@^2.1.4: - version "2.5.0" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.5.0.tgz#6d60e34b3abbc8313062c3b798ef8d901a07af3c" - integrity sha512-pNgbURSuab90KbTqvRPsseaTxOJCZBD0a7t+haSN33piP9cCM4l0CqdzAif2hUqm716UovKB2ROmiabGAKVXyg== + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== hosted-git-info@^3.0.6: - version "3.0.7" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-3.0.7.tgz#a30727385ea85acfcee94e0aad9e368c792e036c" - integrity sha512-fWqc0IcuXs+BmE9orLDyVykAG9GJtGLGuZAAqgcckPgv5xad4AcXGIv8galtQvlwutxSlaMcdw7BUtq2EIvqCQ== + version "3.0.8" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-3.0.8.tgz#6e35d4cc87af2c5f816e4cb9ce350ba87a3f370d" + integrity sha512-aXpmwoOhRBrw6X3j0h5RloK4x1OzsxMPyxqIHyNfSe2pypkVTZFpEiRoSipPEPlMrh0HW/XsjkJ5WgnCirpNUw== dependencies: lru-cache "^6.0.0" From ad628878b11f1252769a511b24a7c76a3dbe046a Mon Sep 17 00:00:00 2001 From: Robert Oskamp Date: Wed, 14 Apr 2021 16:43:37 +0200 Subject: [PATCH 17/43] [ML] security_network module - fix type of defaultIndexPattern (#97096) This PR fixes the defaultIndexPattern type in the security_network module definition. --- .../modules/security_network/manifest.json | 6 +--- .../apis/ml/modules/get_module.ts | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/manifest.json index 55f07ab077d40..2a2c0c202f66b 100755 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/manifest.json @@ -4,11 +4,7 @@ "description": "Detect anomalous network activity in your ECS-compatible network logs.", "type": "network data", "logoFile": "logo.json", - "defaultIndexPattern": [ - "logs-*", - "filebeat-*", - "packetbeat-*" - ], + "defaultIndexPattern": "logs-*,filebeat-*,packetbeat-*", "query": { "bool": { "filter": [ diff --git a/x-pack/test/api_integration/apis/ml/modules/get_module.ts b/x-pack/test/api_integration/apis/ml/modules/get_module.ts index aade372374548..59aa6102b54e2 100644 --- a/x-pack/test/api_integration/apis/ml/modules/get_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/get_module.ts @@ -11,6 +11,8 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; import { USER } from '../../../../functional/services/ml/security_common'; import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api'; +import { isPopulatedObject } from '../../../../../plugins/ml/common/util/object_utils'; + const moduleIds = [ 'apache_ecs', 'apm_jsbase', @@ -70,6 +72,32 @@ export default ({ getService }: FtrProviderContext) => { const rspBody = await executeGetModuleRequest(moduleId, USER.ML_POWERUSER, 200); expect(rspBody).to.be.an(Object); + expect(rspBody).to.have.property('id').a('string'); + expect(rspBody).to.have.property('title').a('string'); + expect(rspBody).to.have.property('description').a('string'); + expect(rspBody).to.have.property('type').a('string'); + if (isPopulatedObject(rspBody, ['logoFile'])) { + expect(rspBody).to.have.property('logoFile').a('string'); + } + if (isPopulatedObject(rspBody, ['logo'])) { + expect(rspBody).to.have.property('logo').an(Object); + } + if (isPopulatedObject(rspBody, ['defaultIndexPattern'])) { + expect(rspBody).to.have.property('defaultIndexPattern').a('string'); + } + if (isPopulatedObject(rspBody, ['query'])) { + expect(rspBody).to.have.property('query').an(Object); + } + if (isPopulatedObject(rspBody, ['jobs'])) { + expect(rspBody).to.have.property('jobs').an(Object); + } + if (isPopulatedObject(rspBody, ['datafeeds'])) { + expect(rspBody).to.have.property('datafeeds').an(Object); + } + if (isPopulatedObject(rspBody, ['kibana'])) { + expect(rspBody).to.have.property('kibana').an(Object); + } + expect(rspBody.id).to.eql(moduleId); }); } From 7c2cbd39c446256137d55b2d6c169cd9155d67a0 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Wed, 14 Apr 2021 17:18:45 +0200 Subject: [PATCH 18/43] [Lens] respect custom labels for fields in time series visualizations (#96937) --- .../indexpattern_suggestions.test.tsx | 7 +- .../operations/definitions.test.ts | 97 ++++++++++++++----- .../definitions/calculations/counter_rate.tsx | 16 ++- .../calculations/cumulative_sum.tsx | 14 ++- .../definitions/calculations/differences.tsx | 8 +- 5 files changed, 104 insertions(+), 38 deletions(-) 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 e742b6ba62aff..c4ebcab85e722 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 @@ -70,7 +70,10 @@ const fieldsOne = [ aggregatable: true, searchable: true, }, - documentField, + { + ...documentField, + displayName: 'Records label', + }, ]; const fieldsTwo = [ @@ -2230,7 +2233,7 @@ describe('IndexPattern Data Source suggestions', () => { operation: { dataType: 'number', isBucketed: false, - label: 'Cumulative sum of Records', + label: 'Cumulative sum of Records label', scale: undefined, }, }, 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 3add39cc5fb8a..c131b16512823 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 @@ -11,7 +11,10 @@ import { countOperation, counterRateOperation, movingAverageOperation, + cumulativeSumOperation, derivativeOperation, + AvgIndexPatternColumn, + DerivativeIndexPatternColumn, } from './definitions'; import { getFieldByNameFactory } from '../pure_helpers'; import { documentField } from '../document_field'; @@ -35,7 +38,7 @@ const indexPatternFields = [ }, { name: 'bytes', - displayName: 'bytes', + displayName: 'bytesLabel', type: 'number', aggregatable: true, searchable: true, @@ -98,6 +101,73 @@ const baseColumnArgs: { field: indexPattern.fields[2], }; +const layer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: ['date', 'metric', 'ref'], + columns: { + date: { + label: '', + customLabel: true, + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { interval: 'auto' }, + }, + metric: { + label: 'metricLabel', + customLabel: true, + dataType: 'number', + isBucketed: false, + operationType: 'average', + sourceField: 'bytes', + params: {}, + } as AvgIndexPatternColumn, + ref: { + label: '', + customLabel: true, + dataType: 'number', + isBucketed: false, + operationType: 'differences', + references: ['metric'], + } as DerivativeIndexPatternColumn, + }, +}; + +describe('labels', () => { + const calcColumnArgs = { + ...baseColumnArgs, + referenceIds: ['metric'], + layer, + previousColumn: layer.columns.metric, + }; + it('should use label of referenced operation to create label for derivative and moving average', () => { + expect(derivativeOperation.buildColumn(calcColumnArgs)).toEqual( + expect.objectContaining({ + label: 'Differences of metricLabel', + }) + ); + expect(movingAverageOperation.buildColumn(calcColumnArgs)).toEqual( + expect.objectContaining({ + label: 'Moving average of metricLabel', + }) + ); + }); + + it('should use displayName of a field for a label for counter rate and cumulative sum', () => { + expect(counterRateOperation.buildColumn(calcColumnArgs)).toEqual( + expect.objectContaining({ + label: 'Counter rate of bytesLabel per second', + }) + ); + expect(cumulativeSumOperation.buildColumn(calcColumnArgs)).toEqual( + expect.objectContaining({ + label: 'Cumulative sum of bytesLabel', + }) + ); + }); +}); + describe('time scale transition', () => { it('should carry over time scale and adjust label on operation from count to sum', () => { expect( @@ -107,7 +177,7 @@ describe('time scale transition', () => { ).toEqual( expect.objectContaining({ timeScale: 'h', - label: 'Sum of bytes per hour', + label: 'Sum of bytesLabel per hour', }) ); }); @@ -125,27 +195,6 @@ describe('time scale transition', () => { ); }); - it('should carry over time scale and adjust label on operation from sum to count', () => { - expect( - countOperation.buildColumn({ - ...baseColumnArgs, - previousColumn: { - label: 'Sum of bytes per hour', - timeScale: 'h', - dataType: 'number', - isBucketed: false, - operationType: 'sum', - sourceField: 'bytes', - }, - }) - ).toEqual( - expect.objectContaining({ - timeScale: 'h', - label: 'Count of records per hour', - }) - ); - }); - it('should not set time scale if it was not set previously', () => { expect( countOperation.buildColumn({ @@ -188,7 +237,7 @@ describe('time scale transition', () => { expect( sumOperation.onFieldChange( { - label: 'Sum of bytes per hour', + label: 'Sum of bytesLabel per hour', timeScale: 'h', dataType: 'number', isBucketed: false, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx index 331aa528e6d55..c57f70ba1b58b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx @@ -66,16 +66,26 @@ export const counterRateOperation: OperationDefinition< }, getDefaultLabel: (column, indexPattern, columns) => { const ref = columns[column.references[0]]; - return ofName(ref && 'sourceField' in ref ? ref.sourceField : undefined, column.timeScale); + return ofName( + ref && 'sourceField' in ref + ? indexPattern.getFieldByName(ref.sourceField)?.displayName + : undefined, + column.timeScale + ); }, toExpression: (layer, columnId) => { return dateBasedOperationToExpression(layer, columnId, 'lens_counter_rate'); }, - buildColumn: ({ referenceIds, previousColumn, layer }) => { + buildColumn: ({ referenceIds, previousColumn, layer, indexPattern }) => { const metric = layer.columns[referenceIds[0]]; const timeScale = previousColumn?.timeScale || DEFAULT_TIME_SCALE; return { - label: ofName(metric && 'sourceField' in metric ? metric.sourceField : undefined, timeScale), + label: ofName( + metric && 'sourceField' in metric + ? indexPattern.getFieldByName(metric.sourceField)?.displayName + : undefined, + timeScale + ), dataType: 'number', operationType: 'counter_rate', isBucketed: false, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx index 1664f3639b598..7cec1fa0d4bbc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx @@ -64,15 +64,23 @@ export const cumulativeSumOperation: OperationDefinition< }, getDefaultLabel: (column, indexPattern, columns) => { const ref = columns[column.references[0]]; - return ofName(ref && 'sourceField' in ref ? ref.sourceField : undefined); + return ofName( + ref && 'sourceField' in ref + ? indexPattern.getFieldByName(ref.sourceField)?.displayName + : undefined + ); }, toExpression: (layer, columnId) => { return dateBasedOperationToExpression(layer, columnId, 'cumulative_sum'); }, - buildColumn: ({ referenceIds, previousColumn, layer }) => { + buildColumn: ({ referenceIds, previousColumn, layer, indexPattern }) => { const ref = layer.columns[referenceIds[0]]; return { - label: ofName(ref && 'sourceField' in ref ? ref.sourceField : undefined), + label: ofName( + ref && 'sourceField' in ref + ? indexPattern.getFieldByName(ref.sourceField)?.displayName + : undefined + ), dataType: 'number', operationType: 'cumulative_sum', isBucketed: false, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx index c50e9270eaac1..bef3fbc2e48ae 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx @@ -66,8 +66,7 @@ export const derivativeOperation: OperationDefinition< } }, getDefaultLabel: (column, indexPattern, columns) => { - const ref = columns[column.references[0]]; - return ofName(ref && 'sourceField' in ref ? ref.sourceField : undefined, column.timeScale); + return ofName(columns[column.references[0]]?.label, column.timeScale); }, toExpression: (layer, columnId) => { return dateBasedOperationToExpression(layer, columnId, 'derivative'); @@ -75,10 +74,7 @@ export const derivativeOperation: OperationDefinition< buildColumn: ({ referenceIds, previousColumn, layer }) => { const ref = layer.columns[referenceIds[0]]; return { - label: ofName( - ref && 'sourceField' in ref ? ref.sourceField : undefined, - previousColumn?.timeScale - ), + label: ofName(ref?.label, previousColumn?.timeScale), dataType: 'number', operationType: OPERATION_NAME, isBucketed: false, From fe00b68aa213838cd3d710fb98aaa88e2f1d770b Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Wed, 14 Apr 2021 10:39:56 -0500 Subject: [PATCH 19/43] [Workplace Search] Update ID label to Source Identifier (#96970) --- .../workplace_search/views/content_sources/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts index 3398427a7111b..32df63d0faba9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts @@ -148,7 +148,7 @@ export const ACCESS_TOKEN_LABEL = i18n.translate( ); export const ID_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.id.label', { - defaultMessage: 'ID', + defaultMessage: 'Source Identifier', }); export const LEARN_CUSTOM_FEATURES_BUTTON = i18n.translate( From 7a070e893d09c0a3b2f4dd20c94a0a62a9837e3c Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Wed, 14 Apr 2021 10:43:08 -0500 Subject: [PATCH 20/43] [Fleet] Add preconfiguration to kibana config (#96588) --- .../resources/base/bin/kibana-docker | 2 + .../plugins/fleet/common/constants/index.ts | 1 + .../common/constants/preconfiguration.ts | 9 +++ x-pack/plugins/fleet/common/types/index.ts | 3 + .../common/types/rest_spec/ingest_setup.ts | 1 + .../fleet/public/applications/fleet/app.tsx | 11 ++- .../plugins/fleet/server/constants/index.ts | 1 + x-pack/plugins/fleet/server/index.ts | 4 ++ x-pack/plugins/fleet/server/plugin.ts | 2 + .../server/routes/setup/handlers.test.ts | 4 +- .../fleet/server/routes/setup/handlers.ts | 7 +- .../fleet/server/saved_objects/index.ts | 14 ++++ .../fleet/server/services/agent_policy.ts | 11 ++- .../fleet/server/services/package_policy.ts | 39 +++++++---- .../server/services/preconfiguration.test.ts | 49 +++++++------- .../fleet/server/services/preconfiguration.ts | 67 +++++++++++++------ x-pack/plugins/fleet/server/services/setup.ts | 49 ++++++++++---- 17 files changed, 199 insertions(+), 75 deletions(-) create mode 100644 x-pack/plugins/fleet/common/constants/preconfiguration.ts diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 1ad1559288992..c65a3569448a3 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -200,6 +200,8 @@ kibana_vars=( xpack.fleet.agents.elasticsearch.host xpack.fleet.agents.kibana.host xpack.fleet.agents.tlsCheckDisabled + xpack.fleet.agentPolicies + xpack.fleet.packages xpack.fleet.registryUrl xpack.graph.canEditDrillDownUrls xpack.graph.enabled diff --git a/x-pack/plugins/fleet/common/constants/index.ts b/x-pack/plugins/fleet/common/constants/index.ts index 5598e63219776..3704533e79b4a 100644 --- a/x-pack/plugins/fleet/common/constants/index.ts +++ b/x-pack/plugins/fleet/common/constants/index.ts @@ -15,6 +15,7 @@ export * from './epm'; export * from './output'; export * from './enrollment_api_key'; export * from './settings'; +export * from './preconfiguration'; // TODO: This is the default `index.max_result_window` ES setting, which dictates // the maximum amount of results allowed to be returned from a search. It's possible diff --git a/x-pack/plugins/fleet/common/constants/preconfiguration.ts b/x-pack/plugins/fleet/common/constants/preconfiguration.ts new file mode 100644 index 0000000000000..376ba551b1359 --- /dev/null +++ b/x-pack/plugins/fleet/common/constants/preconfiguration.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE = + 'fleet-preconfiguration-deletion-record'; diff --git a/x-pack/plugins/fleet/common/types/index.ts b/x-pack/plugins/fleet/common/types/index.ts index 1984de79a6357..cdea56448f3a2 100644 --- a/x-pack/plugins/fleet/common/types/index.ts +++ b/x-pack/plugins/fleet/common/types/index.ts @@ -7,6 +7,7 @@ export * from './models'; export * from './rest_spec'; +import type { PreconfiguredAgentPolicy, PreconfiguredPackage } from './models/preconfiguration'; export interface FleetConfigType { enabled: boolean; @@ -32,6 +33,8 @@ export interface FleetConfigType { agentPolicyRolloutRateLimitIntervalMs: number; agentPolicyRolloutRateLimitRequestPerInterval: number; }; + agentPolicies?: PreconfiguredAgentPolicy[]; + packages?: PreconfiguredPackage[]; } // Calling Object.entries(PackagesGroupedByStatus) gave `status: string` diff --git a/x-pack/plugins/fleet/common/types/rest_spec/ingest_setup.ts b/x-pack/plugins/fleet/common/types/rest_spec/ingest_setup.ts index 12054aff124f7..2180b66908498 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/ingest_setup.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/ingest_setup.ts @@ -7,4 +7,5 @@ export interface PostIngestSetupResponse { isInitialized: boolean; + preconfigurationError?: { name: string; message: string }; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/app.tsx b/x-pack/plugins/fleet/public/applications/fleet/app.tsx index 2c24468b14782..5663bd4768d5c 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/app.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/app.tsx @@ -28,6 +28,7 @@ import { sendSetup, useBreadcrumbs, useConfig, + useStartServices, } from './hooks'; import { Error, Loading } from './components'; import { IntraAppStateProvider } from './hooks/use_intra_app_state'; @@ -59,6 +60,7 @@ const Panel = styled(EuiPanel)` export const WithPermissionsAndSetup: React.FC = memo(({ children }) => { useBreadcrumbs('base'); + const { notifications } = useStartServices(); const [isPermissionsLoading, setIsPermissionsLoading] = useState(false); const [permissionsError, setPermissionsError] = useState(); @@ -81,6 +83,13 @@ export const WithPermissionsAndSetup: React.FC = memo(({ children }) => { if (setupResponse.error) { setInitializationError(setupResponse.error); } + if (setupResponse.data.preconfigurationError) { + notifications.toasts.addError(setupResponse.data.preconfigurationError, { + title: i18n.translate('xpack.fleet.setup.uiPreconfigurationErrorTitle', { + defaultMessage: 'Configuration error', + }), + }); + } } catch (err) { setInitializationError(err); } @@ -92,7 +101,7 @@ export const WithPermissionsAndSetup: React.FC = memo(({ children }) => { setPermissionsError('REQUEST_ERROR'); } })(); - }, []); + }, [notifications.toasts]); if (isPermissionsLoading || permissionsError) { return ( diff --git a/x-pack/plugins/fleet/server/constants/index.ts b/x-pack/plugins/fleet/server/constants/index.ts index 7f5586fb0f034..27af46d0a757d 100644 --- a/x-pack/plugins/fleet/server/constants/index.ts +++ b/x-pack/plugins/fleet/server/constants/index.ts @@ -52,4 +52,5 @@ export { // Fleet Server index ENROLLMENT_API_KEYS_INDEX, AGENTS_INDEX, + PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, } from '../../common'; diff --git a/x-pack/plugins/fleet/server/index.ts b/x-pack/plugins/fleet/server/index.ts index 0178b801f4d2f..c66dd471690eb 100644 --- a/x-pack/plugins/fleet/server/index.ts +++ b/x-pack/plugins/fleet/server/index.ts @@ -15,6 +15,8 @@ import { AGENT_POLLING_REQUEST_TIMEOUT_MS, } from '../common'; +import { PreconfiguredPackagesSchema, PreconfiguredAgentPoliciesSchema } from './types'; + import { FleetPlugin } from './plugin'; export { default as apm } from 'elastic-apm-node'; @@ -77,6 +79,8 @@ export const config: PluginConfigDescriptor = { defaultValue: AGENT_POLICY_ROLLOUT_RATE_LIMIT_REQUEST_PER_INTERVAL, }), }), + packages: schema.maybe(PreconfiguredPackagesSchema), + agentPolicies: schema.maybe(PreconfiguredAgentPoliciesSchema), }), }; diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index 20cfae6bc1cf2..d25b1e13904db 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -48,6 +48,7 @@ import { AGENT_SAVED_OBJECT_TYPE, AGENT_EVENT_SAVED_OBJECT_TYPE, ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, + PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, } from './constants'; import { registerSavedObjects, registerEncryptedSavedObjects } from './saved_objects'; import { @@ -133,6 +134,7 @@ const allSavedObjectTypes = [ AGENT_SAVED_OBJECT_TYPE, AGENT_EVENT_SAVED_OBJECT_TYPE, ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, + PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, ]; /** diff --git a/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts b/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts index 469b2409f140a..2618f3de0d534 100644 --- a/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts @@ -45,7 +45,9 @@ describe('FleetSetupHandler', () => { }); it('POST /setup succeeds w/200 and body of resolved value', async () => { - mockSetupIngestManager.mockImplementation(() => Promise.resolve({ isIntialized: true })); + mockSetupIngestManager.mockImplementation(() => + Promise.resolve({ isInitialized: true, preconfigurationError: undefined }) + ); await FleetSetupHandler(context, request, response); const expectedBody: PostIngestSetupResponse = { isInitialized: true }; diff --git a/x-pack/plugins/fleet/server/routes/setup/handlers.ts b/x-pack/plugins/fleet/server/routes/setup/handlers.ts index a7fdcf78f4be9..e94c9470dd350 100644 --- a/x-pack/plugins/fleet/server/routes/setup/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/setup/handlers.ts @@ -63,13 +63,13 @@ export const createFleetSetupHandler: RequestHandler< try { const soClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asCurrentUser; - await setupIngestManager(soClient, esClient); + const body = await setupIngestManager(soClient, esClient); await setupFleet(soClient, esClient, { forceRecreate: request.body?.forceRecreate ?? false, }); return response.ok({ - body: { isInitialized: true }, + body, }); } catch (error) { return defaultIngestErrorHandler({ error, response }); @@ -81,8 +81,7 @@ export const FleetSetupHandler: RequestHandler = async (context, request, respon const esClient = context.core.elasticsearch.client.asCurrentUser; try { - const body: PostIngestSetupResponse = { isInitialized: true }; - await setupIngestManager(soClient, esClient); + const body: PostIngestSetupResponse = await setupIngestManager(soClient, esClient); return response.ok({ body, }); diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 8554c0702f733..58ec3972ca517 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -19,6 +19,7 @@ import { AGENT_ACTION_SAVED_OBJECT_TYPE, ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, } from '../constants'; import { @@ -358,6 +359,19 @@ const getSavedObjectTypes = ( }, }, }, + [PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE]: { + name: PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, + hidden: false, + namespaceType: 'agnostic', + management: { + importableAndExportable: false, + }, + mappings: { + properties: { + preconfiguration_id: { type: 'keyword' }, + }, + }, + }, }); export function registerSavedObjects( diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 7f793a41ab985..59214e287c873 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -19,6 +19,7 @@ import { DEFAULT_AGENT_POLICY, AGENT_POLICY_SAVED_OBJECT_TYPE, AGENT_SAVED_OBJECT_TYPE, + PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, } from '../constants'; import type { PackagePolicy, @@ -150,7 +151,7 @@ class AgentPolicyService { config: PreconfiguredAgentPolicy ): Promise<{ created: boolean; - policy: AgentPolicy; + policy?: AgentPolicy; }> { const { id, ...preconfiguredAgentPolicy } = omit(config, 'package_policies'); const preconfigurationId = String(id); @@ -582,6 +583,13 @@ class AgentPolicyService { } ); } + + if (agentPolicy.preconfiguration_id) { + await soClient.create(PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, { + preconfiguration_id: String(agentPolicy.preconfiguration_id), + }); + } + await soClient.delete(SAVED_OBJECT_TYPE, id); await this.triggerAgentPolicyUpdatedEvent(soClient, esClient, 'deleted', id); return { @@ -819,5 +827,6 @@ export async function addPackageToAgentPolicy( await packagePolicyService.create(soClient, esClient, newPackagePolicy, { bumpRevision: false, + skipEnsureInstalled: true, }); } diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 7d12aad6f32b5..1d2295a553462 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -60,7 +60,13 @@ class PackagePolicyService { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, packagePolicy: NewPackagePolicy, - options?: { id?: string; user?: AuthenticatedUser; bumpRevision?: boolean; force?: boolean } + options?: { + id?: string; + user?: AuthenticatedUser; + bumpRevision?: boolean; + force?: boolean; + skipEnsureInstalled?: boolean; + } ): Promise { // Check that its agent policy does not have a package policy with the same name const parentAgentPolicy = await agentPolicyService.get(soClient, packagePolicy.policy_id); @@ -90,18 +96,25 @@ class PackagePolicyService { // Make sure the associated package is installed if (packagePolicy.package?.name) { - const [, pkgInfo] = await Promise.all([ - ensureInstalledPackage({ - savedObjectsClient: soClient, - pkgName: packagePolicy.package.name, - esClient, - }), - getPackageInfo({ - savedObjectsClient: soClient, - pkgName: packagePolicy.package.name, - pkgVersion: packagePolicy.package.version, - }), - ]); + const pkgInfoPromise = getPackageInfo({ + savedObjectsClient: soClient, + pkgName: packagePolicy.package.name, + pkgVersion: packagePolicy.package.version, + }); + + let pkgInfo; + if (options?.skipEnsureInstalled) pkgInfo = await pkgInfoPromise; + else { + const [, packageInfo] = await Promise.all([ + ensureInstalledPackage({ + savedObjectsClient: soClient, + pkgName: packagePolicy.package.name, + esClient, + }), + pkgInfoPromise, + ]); + pkgInfo = packageInfo; + } // Check if it is a limited package, and if so, check that the corresponding agent policy does not // already contain a package policy for this package diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index 8a885f9c5c821..94865f5d3d917 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -10,6 +10,8 @@ import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/serve import type { PreconfiguredAgentPolicy } from '../../common/types'; import type { AgentPolicy, NewPackagePolicy, Output } from '../types'; +import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../constants'; + import { ensurePreconfiguredPackagesAndPolicies } from './preconfiguration'; const mockInstalledPackages = new Map(); @@ -27,30 +29,31 @@ const mockDefaultOutput: Output = { function getPutPreconfiguredPackagesMock() { const soClient = savedObjectsClientMock.create(); soClient.find.mockImplementation(async ({ type, search }) => { - const attributes = mockConfiguredPolicies.get(search!.replace(/"/g, '')); - if (attributes) { - return { - saved_objects: [ - { - id: `mocked-${attributes.preconfiguration_id}`, - attributes, - type: type as string, - score: 1, - references: [], - }, - ], - total: 1, - page: 1, - per_page: 1, - }; - } else { - return { - saved_objects: [], - total: 0, - page: 1, - per_page: 0, - }; + if (type === AGENT_POLICY_SAVED_OBJECT_TYPE) { + const attributes = mockConfiguredPolicies.get(search!.replace(/"/g, '')); + if (attributes) { + return { + saved_objects: [ + { + id: `mocked-${attributes.preconfiguration_id}`, + attributes, + type: type as string, + score: 1, + references: [], + }, + ], + total: 1, + page: 1, + per_page: 1, + }; + } } + return { + saved_objects: [], + total: 0, + page: 1, + per_page: 0, + }; }); soClient.create.mockImplementation(async (type, policy) => { const attributes = policy as AgentPolicy; diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index 97480fcf6b2a8..3bd3169673b31 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -19,6 +19,9 @@ import type { PreconfiguredAgentPolicy, PreconfiguredPackage, } from '../../common'; +import { PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE } from '../constants'; + +import { escapeSearchQueryPhrase } from './saved_object'; import { pkgToPkgKey } from './epm/registry'; import { getInstallation } from './epm/packages'; @@ -69,6 +72,21 @@ export async function ensurePreconfiguredPackagesAndPolicies( // Create policies specified in Kibana config const preconfiguredPolicies = await Promise.all( policies.map(async (preconfiguredAgentPolicy) => { + // Check to see if a preconfigured policy with the same preconfigurationId was already deleted by the user + const preconfigurationId = String(preconfiguredAgentPolicy.id); + const searchParams = { + searchFields: ['preconfiguration_id'], + search: escapeSearchQueryPhrase(preconfigurationId), + }; + const deletionRecords = await soClient.find({ + type: PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, + ...searchParams, + }); + const wasDeleted = deletionRecords.total > 0; + if (wasDeleted) { + return { created: false, deleted: preconfigurationId }; + } + const { created, policy } = await agentPolicyService.ensurePreconfiguredAgentPolicy( soClient, esClient, @@ -122,22 +140,32 @@ export async function ensurePreconfiguredPackagesAndPolicies( await addPreconfiguredPolicyPackages( soClient, esClient, - policy, + policy!, installedPackagePolicies!, defaultOutput ); // Add the is_managed flag after configuring package policies to avoid errors if (shouldAddIsManagedFlag) { - agentPolicyService.update(soClient, esClient, policy.id, { is_managed: true }); + agentPolicyService.update(soClient, esClient, policy!.id, { is_managed: true }); } } } return { - policies: preconfiguredPolicies.map((p) => ({ - id: p.policy.id, - updated_at: p.policy.updated_at, - })), + policies: preconfiguredPolicies.map((p) => + p.policy + ? { + id: p.policy.id, + updated_at: p.policy.updated_at, + } + : { + id: p.deleted, + updated_at: i18n.translate('xpack.fleet.preconfiguration.policyDeleted', { + defaultMessage: 'Preconfigured policy {id} was deleted; skipping creation', + values: { id: p.deleted }, + }), + } + ), packages: preconfiguredPackages.map((pkg) => pkgToPkgKey(pkg)), }; } @@ -155,20 +183,19 @@ async function addPreconfiguredPolicyPackages( >, defaultOutput: Output ) { - return await Promise.all( - installedPackagePolicies.map(async ({ installedPackage, name, description, inputs }) => - addPackageToAgentPolicy( - soClient, - esClient, - installedPackage, - agentPolicy, - defaultOutput, - name, - description, - (policy) => overridePackageInputs(policy, inputs) - ) - ) - ); + // Add packages synchronously to avoid overwriting + for (const { installedPackage, name, description, inputs } of installedPackagePolicies) { + await addPackageToAgentPolicy( + soClient, + esClient, + installedPackage, + agentPolicy, + defaultOutput, + name, + description, + (policy) => overridePackageInputs(policy, inputs) + ); + } } async function ensureInstalledPreconfiguredPackage( diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index b5e2326386e02..6d98bc4263a16 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -15,7 +15,9 @@ import type { PackagePolicy } from '../../common'; import { SO_SEARCH_LIMIT } from '../constants'; +import { appContextService } from './app_context'; import { agentPolicyService, addPackageToAgentPolicy } from './agent_policy'; +import { ensurePreconfiguredPackagesAndPolicies } from './preconfiguration'; import { outputService } from './output'; import { ensureInstalledDefaultPackages, @@ -34,7 +36,8 @@ const FLEET_ENROLL_USERNAME = 'fleet_enroll'; const FLEET_ENROLL_ROLE = 'fleet_enroll'; export interface SetupStatus { - isIntialized: true | undefined; + isInitialized: boolean; + preconfigurationError: { name: string; message: string } | undefined; } export async function setupIngestManager( @@ -48,17 +51,10 @@ async function createSetupSideEffects( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient ): Promise { - const [ - installedPackages, - defaultOutput, - { created: defaultAgentPolicyCreated, policy: defaultAgentPolicy }, - { created: defaultFleetServerPolicyCreated, policy: defaultFleetServerPolicy }, - ] = await Promise.all([ + const [installedPackages, defaultOutput] = await Promise.all([ // packages installed by default ensureInstalledDefaultPackages(soClient, esClient), outputService.ensureDefaultOutput(soClient), - agentPolicyService.ensureDefaultAgentPolicy(soClient, esClient), - agentPolicyService.ensureDefaultFleetServerAgentPolicy(soClient, esClient), updateFleetRoleIfExists(esClient), settingsService.getSettings(soClient).catch((e: any) => { if (e.isBoom && e.output.statusCode === 404) { @@ -86,6 +82,37 @@ async function createSetupSideEffects( esClient, }); + const { agentPolicies: policiesOrUndefined, packages: packagesOrUndefined } = + appContextService.getConfig() ?? {}; + + const policies = policiesOrUndefined ?? []; + const packages = packagesOrUndefined ?? []; + let preconfigurationError; + + try { + await ensurePreconfiguredPackagesAndPolicies( + soClient, + esClient, + policies, + packages, + defaultOutput + ); + } catch (e) { + preconfigurationError = { name: e.name, message: e.message }; + } + + // Ensure the predefined default policies AFTER loading preconfigured policies. This allows the kibana config + // to override the default agent policies. + + const [ + { created: defaultAgentPolicyCreated, policy: defaultAgentPolicy }, + { created: defaultFleetServerPolicyCreated, policy: defaultFleetServerPolicy }, + ] = await Promise.all([ + agentPolicyService.ensureDefaultAgentPolicy(soClient, esClient), + agentPolicyService.ensureDefaultFleetServerAgentPolicy(soClient, esClient), + ]); + + // If we just created the default fleet server policy add the fleet server package if (defaultFleetServerPolicyCreated) { await addPackageToAgentPolicy( soClient, @@ -96,8 +123,6 @@ async function createSetupSideEffects( ); } - // If we just created the default fleet server policy add the fleet server package - // If we just created the default policy, ensure default packages are added to it if (defaultAgentPolicyCreated) { const agentPolicyWithPackagePolicies = await agentPolicyService.get( @@ -151,7 +176,7 @@ async function createSetupSideEffects( await ensureAgentActionPolicyChangeExists(soClient); - return { isIntialized: true }; + return { isInitialized: true, preconfigurationError }; } async function updateFleetRoleIfExists(esClient: ElasticsearchClient) { From 4c00710be8b8ae419df736d352d58f4cd2a86bd7 Mon Sep 17 00:00:00 2001 From: Phillip Burch Date: Wed, 14 Apr 2021 10:46:26 -0500 Subject: [PATCH 21/43] [Metrics UI] Add Log Rate to the metrics tab (#96596) * Add Log Rate to the metrics tab * Add custom metrics to Metrics tab * Remove unused variables * Review feedback Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../tabs/metrics/chart_header.tsx | 17 +- .../tabs/metrics/chart_section.tsx | 103 +++++ .../node_details/tabs/metrics/metrics.tsx | 392 +++++++++--------- .../tabs/metrics/translations.tsx | 11 + 4 files changed, 313 insertions(+), 210 deletions(-) create mode 100644 x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_section.tsx diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx index 03ee51477492e..9c9e91b814fad 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx @@ -12,6 +12,7 @@ import { EuiFlexGroup } from '@elastic/eui'; import { EuiIcon } from '@elastic/eui'; import { colorTransformer } from '../../../../../../../../common/color_palette'; import { MetricsExplorerOptionsMetric } from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options'; +import { euiStyled } from '../../../../../../../../../../../src/plugins/kibana_react/common'; interface Props { title: string; @@ -21,11 +22,11 @@ interface Props { export const ChartHeader = ({ title, metrics }: Props) => { return ( - + -

{title}

+

{title}

-
+ {metrics.map((chartMetric) => ( @@ -50,3 +51,13 @@ export const ChartHeader = ({ title, metrics }: Props) => { ); }; + +const HeaderItem = euiStyled(EuiFlexItem).attrs({ grow: 1 })` + overflow: hidden; +`; + +const H4 = euiStyled('h4')` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_section.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_section.tsx new file mode 100644 index 0000000000000..c8f924042b195 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_section.tsx @@ -0,0 +1,103 @@ +/* + * 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 { + Axis, + Settings, + Position, + Chart, + PointerUpdateListener, + TickFormatter, + TooltipValue, + ChartSizeArray, +} from '@elastic/charts'; +import React from 'react'; +import moment from 'moment'; +import { MetricsExplorerSeries } from '../../../../../../../../common/http_api'; +import { MetricExplorerSeriesChart } from '../../../../../metrics_explorer/components/series_chart'; +import { + MetricsExplorerChartType, + MetricsExplorerOptionsMetric, +} from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options'; +import { ChartHeader } from './chart_header'; +import { getTimelineChartTheme } from '../../../../../metrics_explorer/components/helpers/get_chart_theme'; +import { useUiSetting } from '../../../../../../../../../../../src/plugins/kibana_react/public'; + +const CHART_SIZE: ChartSizeArray = ['100%', 160]; + +interface Props { + title: string; + style: MetricsExplorerChartType; + chartRef: React.Ref; + series: ChartSectionSeries[]; + tickFormatterForTime: TickFormatter; + tickFormatter: TickFormatter; + onPointerUpdate: PointerUpdateListener; + domain: { max: number; min: number }; + stack?: boolean; +} + +export interface ChartSectionSeries { + metric: MetricsExplorerOptionsMetric; + series: MetricsExplorerSeries; +} + +export const ChartSection = ({ + title, + style, + chartRef, + series, + tickFormatterForTime, + tickFormatter, + onPointerUpdate, + domain, + stack = false, +}: Props) => { + const isDarkMode = useUiSetting('theme:darkMode'); + const metrics = series.map((chartSeries) => chartSeries.metric); + const tooltipProps = { + headerFormatter: (tooltipValue: TooltipValue) => + moment(tooltipValue.value).format('Y-MM-DD HH:mm:ss.SSS'), + }; + + return ( + <> + + + {series.map((chartSeries, index) => ( + + ))} + + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx index 5ab8eb380a657..b554cb8024211 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx @@ -8,17 +8,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { first, last } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { - Axis, - Chart, - ChartSizeArray, - niceTimeFormatter, - Position, - Settings, - TooltipValue, - PointerEvent, -} from '@elastic/charts'; -import moment from 'moment'; +import { Chart, niceTimeFormatter, PointerEvent } from '@elastic/charts'; import { EuiLoadingChart, EuiSpacer, EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; import { TabContent, TabProps } from '../shared'; import { useSnapshot } from '../../../../hooks/use_snaphot'; @@ -36,12 +26,10 @@ import { MetricsExplorerAggregation, MetricsExplorerSeries, } from '../../../../../../../../common/http_api'; -import { MetricExplorerSeriesChart } from '../../../../../metrics_explorer/components/series_chart'; import { createInventoryMetricFormatter } from '../../../../lib/create_inventory_metric_formatter'; import { calculateDomain } from '../../../../../metrics_explorer/components/helpers/calculate_domain'; -import { getTimelineChartTheme } from '../../../../../metrics_explorer/components/helpers/get_chart_theme'; -import { useUiSetting } from '../../../../../../../../../../../src/plugins/kibana_react/public'; -import { ChartHeader } from './chart_header'; +import { euiStyled } from '../../../../../../../../../../../src/plugins/kibana_react/common'; +import { ChartSection } from './chart_section'; import { SYSTEM_METRIC_NAME, USER_METRIC_NAME, @@ -53,26 +41,36 @@ import { LOAD_CHART_TITLE, MEMORY_CHART_TITLE, NETWORK_CHART_TITLE, + LOG_RATE_METRIC_NAME, + LOG_RATE_CHART_TITLE, } from './translations'; import { TimeDropdown } from './time_dropdown'; +import { getCustomMetricLabel } from '../../../../../../../../common/formatters/get_custom_metric_label'; +import { createFormatterForMetric } from '../../../../../metrics_explorer/components/helpers/create_formatter_for_metric'; const ONE_HOUR = 60 * 60 * 1000; -const CHART_SIZE: ChartSizeArray = ['100%', 160]; const TabComponent = (props: TabProps) => { const cpuChartRef = useRef(null); const networkChartRef = useRef(null); const memoryChartRef = useRef(null); const loadChartRef = useRef(null); + const logRateChartRef = useRef(null); + const customMetricRefs = useRef>({}); const [time, setTime] = useState(ONE_HOUR); - const chartRefs = useMemo(() => [cpuChartRef, networkChartRef, memoryChartRef, loadChartRef], [ + const chartRefs = useMemo(() => { + const refs = [cpuChartRef, networkChartRef, memoryChartRef, loadChartRef, logRateChartRef]; + return [...refs, customMetricRefs]; + }, [ cpuChartRef, networkChartRef, memoryChartRef, loadChartRef, + logRateChartRef, + customMetricRefs, ]); const { sourceId, createDerivedIndexPattern } = useSourceContext(); - const { nodeType, accountId, region } = useWaffleOptionsContext(); + const { nodeType, accountId, region, customMetrics } = useWaffleOptionsContext(); const { currentTime, options, node } = props; const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ createDerivedIndexPattern, @@ -102,20 +100,29 @@ const TabComponent = (props: TabProps) => { [setTime] ); + const timeRange = { + interval: '1m', + to: currentTime, + from: currentTime - time, + ignoreLookback: true, + }; + + const defaultMetrics: Array<{ type: SnapshotMetricType }> = [ + { type: 'rx' }, + { type: 'tx' }, + buildCustomMetric('system.cpu.user.pct', 'user'), + buildCustomMetric('system.cpu.system.pct', 'system'), + buildCustomMetric('system.load.1', 'load1m'), + buildCustomMetric('system.load.5', 'load5m'), + buildCustomMetric('system.load.15', 'load15m'), + buildCustomMetric('system.memory.actual.used.bytes', 'usedMemory'), + buildCustomMetric('system.memory.actual.free', 'freeMemory'), + buildCustomMetric('system.cpu.cores', 'cores', 'max'), + ]; + const { nodes, reload } = useSnapshot( filter, - [ - { type: 'rx' }, - { type: 'tx' }, - buildCustomMetric('system.cpu.user.pct', 'user'), - buildCustomMetric('system.cpu.system.pct', 'system'), - buildCustomMetric('system.load.1', 'load1m'), - buildCustomMetric('system.load.5', 'load5m'), - buildCustomMetric('system.load.15', 'load15m'), - buildCustomMetric('system.memory.actual.used.bytes', 'usedMemory'), - buildCustomMetric('system.memory.actual.free', 'freeMemory'), - buildCustomMetric('system.cpu.cores', 'cores', 'max'), - ], + [...defaultMetrics, ...customMetrics], [], nodeType, sourceId, @@ -123,12 +130,20 @@ const TabComponent = (props: TabProps) => { accountId, region, false, - { - interval: '1m', - to: currentTime, - from: currentTime - time, - ignoreLookback: true, - } + timeRange + ); + + const { nodes: logRateNodes, reload: reloadLogRate } = useSnapshot( + filter, + [{ type: 'logRate' }], + [], + nodeType, + sourceId, + currentTime, + accountId, + region, + false, + timeRange ); const getDomain = useCallback( @@ -163,6 +178,7 @@ const TabComponent = (props: TabProps) => { [] ); const loadFormatter = useMemo(() => createInventoryMetricFormatter({ type: 'load' }), []); + const logRateFormatter = useMemo(() => createInventoryMetricFormatter({ type: 'logRate' }), []); const mergeTimeseries = useCallback((...series: MetricsExplorerSeries[]) => { const base = series[0]; @@ -196,19 +212,22 @@ const TabComponent = (props: TabProps) => { (event: PointerEvent) => { chartRefs.forEach((ref) => { if (ref.current) { - ref.current.dispatchExternalPointerEvent(event); + if (ref.current instanceof Chart) { + ref.current.dispatchExternalPointerEvent(event); + } else { + const charts = Object.values(ref.current); + charts.forEach((c) => { + if (c) { + c.dispatchExternalPointerEvent(event); + } + }); + } } }); }, [chartRefs] ); - const isDarkMode = useUiSetting('theme:darkMode'); - const tooltipProps = { - headerFormatter: (tooltipValue: TooltipValue) => - moment(tooltipValue.value).format('Y-MM-DD HH:mm:ss.SSS'), - }; - const getTimeseries = useCallback( (metricName: string) => { if (!nodes || !nodes.length) { @@ -219,6 +238,16 @@ const TabComponent = (props: TabProps) => { [nodes] ); + const getLogRateTimeseries = useCallback(() => { + if (!logRateNodes) { + return null; + } + if (logRateNodes.length === 0) { + return { rows: [], columns: [], id: '0' }; + } + return logRateNodes[0].metrics.find((m) => m.name === 'logRate')!.timeseries!; + }, [logRateNodes]); + const systemMetricsTs = useMemo(() => getTimeseries('system'), [getTimeseries]); const userMetricsTs = useMemo(() => getTimeseries('user'), [getTimeseries]); const rxMetricsTs = useMemo(() => getTimeseries('rx'), [getTimeseries]); @@ -229,10 +258,12 @@ const TabComponent = (props: TabProps) => { const usedMemoryMetricsTs = useMemo(() => getTimeseries('usedMemory'), [getTimeseries]); const freeMemoryMetricsTs = useMemo(() => getTimeseries('freeMemory'), [getTimeseries]); const coresMetricsTs = useMemo(() => getTimeseries('cores'), [getTimeseries]); + const logRateMetricsTs = useMemo(() => getLogRateTimeseries(), [getLogRateTimeseries]); useEffect(() => { reload(); - }, [time, reload]); + reloadLogRate(); + }, [time, reload, reloadLogRate]); if ( !systemMetricsTs || @@ -243,12 +274,14 @@ const TabComponent = (props: TabProps) => { !load5mMetricsTs || !load15mMetricsTs || !usedMemoryMetricsTs || - !freeMemoryMetricsTs + !freeMemoryMetricsTs || + !logRateMetricsTs ) { return ; } const cpuChartMetrics = buildChartMetricLabels([SYSTEM_METRIC_NAME, USER_METRIC_NAME], 'avg'); + const logRateChartMetrics = buildChartMetricLabels([LOG_RATE_METRIC_NAME], 'rate'); const networkChartMetrics = buildChartMetricLabels( [INBOUND_METRIC_NAME, OUTBOUND_METRIC_NAME], 'rate' @@ -277,6 +310,7 @@ const TabComponent = (props: TabProps) => { return r; }); const cpuTimeseries = mergeTimeseries(systemMetricsTs, userMetricsTs); + const logRateTimeseries = mergeTimeseries(logRateMetricsTs); const networkTimeseries = mergeTimeseries(rxMetricsTs, txMetricsTs); const loadTimeseries = mergeTimeseries(load1mMetricsTs, load5mMetricsTs, load15mMetricsTs); const memoryTimeseries = mergeTimeseries(usedMemoryMetricsTs, freeMemoryMetricsTs); @@ -290,173 +324,117 @@ const TabComponent = (props: TabProps) => { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + {customMetrics.map((c) => { + const metricTS = getTimeseries(c.id); + const chartMetrics = buildChartMetricLabels([c.field], c.aggregation); + if (!metricTS) return null; + return ( + + { + customMetricRefs.current[c.id] = r; + }} + series={[{ metric: chartMetrics[0], series: metricTS }]} + tickFormatterForTime={formatter} + tickFormatter={createFormatterForMetric(c)} + onPointerUpdate={pointerUpdate} + domain={getDomain(mergeTimeseries(metricTS), chartMetrics)} + stack={true} + /> + + ); + })} ); }; +const ChartGridItem = euiStyled(EuiFlexItem)` + overflow: hidden +`; + const LoadingPlaceholder = () => { return (
Date: Wed, 14 Apr 2021 12:14:57 -0400 Subject: [PATCH 22/43] [App Search] Remaining Result Settings work (#96974) --- .../credentials_list.test.tsx | 2 +- .../result_settings/result_settings.test.tsx | 56 ++++- .../result_settings/result_settings.tsx | 100 +++++--- .../result_settings_logic.test.ts | 218 ++++++++++-------- .../result_settings/result_settings_logic.ts | 8 +- .../components/result_settings/utils.test.ts | 30 --- .../components/result_settings/utils.ts | 9 +- 7 files changed, 249 insertions(+), 174 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.test.tsx index 09340d37fcf7b..274bda56a2fc1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.test.tsx @@ -87,7 +87,7 @@ describe('CredentialsList', () => { }); describe('empty state', () => { - it('renders an EuiEmptyState when no credentials are available', () => { + it('renders an EuiEmptyPrompt when no credentials are available', () => { setMockValues({ ...values, apiTokens: [], diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx index a1e1fd920b139..e5a901f8d0779 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx @@ -13,15 +13,20 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; -import { EuiPageHeader } from '@elastic/eui'; +import { EuiPageHeader, EuiEmptyPrompt } from '@elastic/eui'; import { ResultSettings } from './result_settings'; import { ResultSettingsTable } from './result_settings_table'; import { SampleResponse } from './sample_response'; -describe('RelevanceTuning', () => { +describe('ResultSettings', () => { const values = { + schema: { + foo: 'text', + }, dataLoading: false, + stagedUpdates: true, + resultFieldsAtDefaultSettings: false, }; const actions = { @@ -32,9 +37,9 @@ describe('RelevanceTuning', () => { }; beforeEach(() => { + jest.clearAllMocks(); setMockValues(values); setMockActions(actions); - jest.clearAllMocks(); }); const subject = () => shallow(); @@ -69,6 +74,16 @@ describe('RelevanceTuning', () => { expect(actions.saveResultSettings).toHaveBeenCalled(); }); + it('renders the "save" button as disabled if the user has made no changes since the page loaded', () => { + setMockValues({ + ...values, + stagedUpdates: false, + }); + const buttons = findButtons(subject()); + const saveButton = shallow(buttons[0]); + expect(saveButton.prop('disabled')).toBe(true); + }); + it('renders a "restore defaults" button that will reset all values to their defaults', () => { const buttons = findButtons(subject()); expect(buttons.length).toBe(3); @@ -77,6 +92,16 @@ describe('RelevanceTuning', () => { expect(actions.confirmResetAllFields).toHaveBeenCalled(); }); + it('renders the "restore defaults" button as disabled if the values are already at their defaults', () => { + setMockValues({ + ...values, + resultFieldsAtDefaultSettings: true, + }); + const buttons = findButtons(subject()); + const resetButton = shallow(buttons[1]); + expect(resetButton.prop('disabled')).toBe(true); + }); + it('renders a "clear" button that will remove all selected options', () => { const buttons = findButtons(subject()); expect(buttons.length).toBe(3); @@ -84,4 +109,29 @@ describe('RelevanceTuning', () => { clearButton.simulate('click'); expect(actions.clearAllFields).toHaveBeenCalled(); }); + + describe('when there is no schema yet', () => { + let wrapper: ShallowWrapper; + beforeAll(() => { + setMockValues({ + ...values, + schema: {}, + }); + wrapper = subject(); + }); + + it('will not render action buttons', () => { + const buttons = findButtons(wrapper); + expect(buttons.length).toBe(0); + }); + + it('will not render the main page content', () => { + expect(wrapper.find(ResultSettingsTable).exists()).toBe(false); + expect(wrapper.find(SampleResponse).exists()).toBe(false); + }); + + it('will render an "empty" message', () => { + expect(wrapper.find(EuiEmptyPrompt).exists()).toBe(true); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx index 70dbee7425ae8..285d8fef35770 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx @@ -9,7 +9,15 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiPageHeader, EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty } from '@elastic/eui'; +import { + EuiPageHeader, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiEmptyPrompt, + EuiPanel, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -32,7 +40,9 @@ const CLEAR_BUTTON_LABEL = i18n.translate( ); export const ResultSettings: React.FC = () => { - const { dataLoading } = useValues(ResultSettingsLogic); + const { dataLoading, schema, stagedUpdates, resultFieldsAtDefaultSettings } = useValues( + ResultSettingsLogic + ); const { initializeResultSettingsData, saveResultSettings, @@ -45,6 +55,7 @@ export const ResultSettings: React.FC = () => { }, []); if (dataLoading) return ; + const hasSchema = Object.keys(schema).length > 0; return ( <> @@ -55,36 +66,65 @@ export const ResultSettings: React.FC = () => { 'xpack.enterpriseSearch.appSearch.engine.resultSettings.pageDescription', { defaultMessage: 'Enrich search results and select which fields will appear.' } )} - rightSideItems={[ - - {SAVE_BUTTON_LABEL} - , - - {RESTORE_DEFAULTS_BUTTON_LABEL} - , - - {CLEAR_BUTTON_LABEL} - , - ]} + rightSideItems={ + hasSchema + ? [ + + {SAVE_BUTTON_LABEL} + , + + {RESTORE_DEFAULTS_BUTTON_LABEL} + , + + {CLEAR_BUTTON_LABEL} + , + ] + : [] + } /> - - - - - - - - + {hasSchema ? ( + + + + + + + + + ) : ( + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.noSchemaTitle', + { defaultMessage: 'Engine does not have a schema' } + )} +
+ } + body={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.noSchemaDescription', + { + defaultMessage: + 'You need one! A schema is created for you after you index some documents.', + } + )} + /> +
+ )} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.test.ts index 8d9c33e3c9e68..437949982cb5a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.test.ts @@ -19,6 +19,18 @@ import { ServerFieldResultSettingObject } from './types'; import { ResultSettingsLogic } from '.'; +// toHaveBeenCalledWith uses toEqual which is a more lenient check. We have a couple of +// methods that need a stricter check, using `toStrictEqual`. +const expectToHaveBeenCalledWithStrict = ( + mock: jest.Mock, + expectedParam1: string, + expectedParam2: object +) => { + const [param1, param2] = mock.mock.calls[0]; + expect(param1).toEqual(expectedParam1); + expect(param2).toStrictEqual(expectedParam2); +}; + describe('ResultSettingsLogic', () => { const { mount } = new LogicMounter(ResultSettingsLogic); @@ -35,7 +47,6 @@ describe('ResultSettingsLogic', () => { serverResultFields: {}, reducedServerResultFields: {}, resultFieldsAtDefaultSettings: true, - resultFieldsEmpty: true, stagedUpdates: false, nonTextResultFields: {}, textResultFields: {}, @@ -322,30 +333,6 @@ describe('ResultSettingsLogic', () => { }); }); - describe('resultFieldsEmpty', () => { - it('should return true if all fields are empty', () => { - mount({ - resultFields: { - foo: {}, - bar: {}, - }, - }); - - expect(ResultSettingsLogic.values.resultFieldsEmpty).toEqual(true); - }); - - it('should return false otherwise', () => { - mount({ - resultFields: { - foo: {}, - bar: { raw: true, snippet: true, snippetFallback: false }, - }, - }); - - expect(ResultSettingsLogic.values.resultFieldsEmpty).toEqual(false); - }); - }); - describe('stagedUpdates', () => { it('should return true if changes have been made since the last save', () => { mount({ @@ -535,17 +522,20 @@ describe('ResultSettingsLogic', () => { mount({ resultFields: { foo: { raw: true, rawSize: 5, snippet: false }, - bar: { raw: true, rawSize: 5, snippet: false }, }, }); jest.spyOn(ResultSettingsLogic.actions, 'updateField'); ResultSettingsLogic.actions.clearRawSizeForField('foo'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('foo', { - raw: true, - snippet: false, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'foo', + { + raw: true, + snippet: false, + } + ); }); }); @@ -554,17 +544,20 @@ describe('ResultSettingsLogic', () => { mount({ resultFields: { foo: { raw: false, snippet: true, snippetSize: 5 }, - bar: { raw: true, rawSize: 5, snippet: false }, }, }); jest.spyOn(ResultSettingsLogic.actions, 'updateField'); ResultSettingsLogic.actions.clearSnippetSizeForField('foo'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('foo', { - raw: false, - snippet: true, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'foo', + { + raw: false, + snippet: true, + } + ); }); }); @@ -572,7 +565,6 @@ describe('ResultSettingsLogic', () => { it('should toggle the raw value on for a field', () => { mount({ resultFields: { - foo: { raw: false, snippet: true, snippetSize: 5 }, bar: { raw: false, snippet: false }, }, }); @@ -580,16 +572,19 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.toggleRawForField('bar'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { - raw: true, - snippet: false, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'bar', + { + raw: true, + snippet: false, + } + ); }); it('should maintain rawSize if it was set prior', () => { mount({ resultFields: { - foo: { raw: false, snippet: true, snippetSize: 5 }, bar: { raw: false, rawSize: 10, snippet: false }, }, }); @@ -597,17 +592,20 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.toggleRawForField('bar'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { - raw: true, - rawSize: 10, - snippet: false, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'bar', + { + raw: true, + rawSize: 10, + snippet: false, + } + ); }); it('should remove rawSize value when toggling off', () => { mount({ resultFields: { - foo: { raw: false, snippet: true, snippetSize: 5 }, bar: { raw: true, rawSize: 5, snippet: false }, }, }); @@ -615,16 +613,19 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.toggleRawForField('bar'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { - raw: false, - snippet: false, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'bar', + { + raw: false, + snippet: false, + } + ); }); it('should still work if the object is empty', () => { mount({ resultFields: { - foo: { raw: false, snippet: true, snippetSize: 5 }, bar: {}, }, }); @@ -632,9 +633,13 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.toggleRawForField('bar'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { - raw: true, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'bar', + { + raw: true, + } + ); }); }); @@ -642,7 +647,6 @@ describe('ResultSettingsLogic', () => { it('should toggle the raw value on for a field, always setting the snippet size to 100', () => { mount({ resultFields: { - foo: { raw: false, snippet: true, snippetSize: 5 }, bar: { raw: false, snippet: false }, }, }); @@ -650,17 +654,20 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.toggleSnippetForField('bar'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { - raw: false, - snippet: true, - snippetSize: 100, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'bar', + { + raw: false, + snippet: true, + snippetSize: 100, + } + ); }); it('should remove rawSize value when toggling off', () => { mount({ resultFields: { - foo: { raw: false, snippet: true, snippetSize: 5 }, bar: { raw: false, snippet: true, snippetSize: 5 }, }, }); @@ -668,16 +675,19 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.toggleSnippetForField('bar'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { - raw: false, - snippet: false, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'bar', + { + raw: false, + snippet: false, + } + ); }); it('should still work if the object is empty', () => { mount({ resultFields: { - foo: { raw: false, snippet: true, snippetSize: 5 }, bar: {}, }, }); @@ -685,10 +695,14 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.toggleSnippetForField('bar'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { - snippet: true, - snippetSize: 100, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'bar', + { + snippet: true, + snippetSize: 100, + } + ); }); }); @@ -697,19 +711,22 @@ describe('ResultSettingsLogic', () => { mount({ resultFields: { foo: { raw: false, snippet: true, snippetSize: 5, snippetFallback: true }, - bar: { raw: false, snippet: false }, }, }); jest.spyOn(ResultSettingsLogic.actions, 'updateField'); ResultSettingsLogic.actions.toggleSnippetFallbackForField('foo'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('foo', { - raw: false, - snippet: true, - snippetSize: 5, - snippetFallback: false, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'foo', + { + raw: false, + snippet: true, + snippetSize: 5, + snippetFallback: false, + } + ); }); }); @@ -717,7 +734,6 @@ describe('ResultSettingsLogic', () => { it('should update the rawSize value for a field', () => { mount({ resultFields: { - foo: { raw: false, snippet: true, snippetSize: 5, snippetFallback: true }, bar: { raw: true, rawSize: 5, snippet: false }, }, }); @@ -725,11 +741,15 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.updateRawSizeForField('bar', 7); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { - raw: true, - rawSize: 7, - snippet: false, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'bar', + { + raw: true, + rawSize: 7, + snippet: false, + } + ); }); }); @@ -738,19 +758,22 @@ describe('ResultSettingsLogic', () => { mount({ resultFields: { foo: { raw: false, snippet: true, snippetSize: 5, snippetFallback: true }, - bar: { raw: true, rawSize: 5, snippet: false }, }, }); jest.spyOn(ResultSettingsLogic.actions, 'updateField'); ResultSettingsLogic.actions.updateSnippetSizeForField('foo', 7); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('foo', { - raw: false, - snippet: true, - snippetSize: 7, - snippetFallback: true, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'foo', + { + raw: false, + snippet: true, + snippetSize: 7, + snippetFallback: true, + } + ); }); }); @@ -759,17 +782,20 @@ describe('ResultSettingsLogic', () => { mount({ resultFields: { foo: { raw: false, snippet: true, snippetSize: 5 }, - bar: { raw: true, rawSize: 5, snippet: false }, }, }); jest.spyOn(ResultSettingsLogic.actions, 'updateField'); ResultSettingsLogic.actions.clearSnippetSizeForField('foo'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('foo', { - raw: false, - snippet: true, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'foo', + { + raw: false, + snippet: true, + } + ); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts index f518fc945bfbf..af78543cda2b2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts @@ -24,7 +24,6 @@ import { import { areFieldsAtDefaultSettings, - areFieldsEmpty, clearAllFields, convertServerResultFieldsToResultFields, convertToServerFieldResultSetting, @@ -198,10 +197,6 @@ export const ResultSettingsLogic = kea [selectors.resultFields], (resultFields) => areFieldsAtDefaultSettings(resultFields), ], - resultFieldsEmpty: [ - () => [selectors.resultFields], - (resultFields) => areFieldsEmpty(resultFields), - ], stagedUpdates: [ () => [selectors.lastSavedResultFields, selectors.resultFields], (lastSavedResultFields, resultFields) => !isEqual(lastSavedResultFields, resultFields), @@ -256,10 +251,11 @@ export const ResultSettingsLogic = kea { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.test.ts index 5797e5c633bc7..6fee0a2500357 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.test.ts @@ -9,7 +9,6 @@ import { SchemaTypes } from '../../../shared/types'; import { areFieldsAtDefaultSettings, - areFieldsEmpty, convertServerResultFieldsToResultFields, convertToServerFieldResultSetting, clearAllFields, @@ -145,35 +144,6 @@ describe('splitResultFields', () => { }); }); -describe('areFieldsEmpty', () => { - it('should return true if all fields are empty objects', () => { - expect( - areFieldsEmpty({ - foo: {}, - bar: {}, - }) - ).toBe(true); - }); - it('should return false otherwise', () => { - expect( - areFieldsEmpty({ - foo: { - raw: true, - rawSize: 5, - snippet: false, - snippetFallback: false, - }, - bar: { - raw: true, - rawSize: 5, - snippet: false, - snippetFallback: false, - }, - }) - ).toBe(false); - }); -}); - describe('areFieldsAtDefaultSettings', () => { it('will return true if all settings for all fields are at their defaults', () => { expect( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.ts index bde67c268ac16..ff88aaac193d7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { isEqual, isEmpty } from 'lodash'; +import { isEqual } from 'lodash'; import { Schema } from '../../../shared/types'; @@ -112,13 +112,6 @@ export const splitResultFields = (resultFields: FieldResultSettingObject, schema return { textResultFields, nonTextResultFields }; }; -export const areFieldsEmpty = (fields: FieldResultSettingObject) => { - const anyNonEmptyField = Object.values(fields).find((resultSettings) => { - return !isEmpty(resultSettings); - }); - return !anyNonEmptyField; -}; - export const areFieldsAtDefaultSettings = (fields: FieldResultSettingObject) => { const anyNonDefaultSettingsValue = Object.values(fields).find((resultSettings) => { return !isEqual(resultSettings, DEFAULT_FIELD_SETTINGS); From 1615d5f62b843d969131dc61ea4e5baeddc95eed Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 14 Apr 2021 09:20:59 -0700 Subject: [PATCH 23/43] Reporting: Refactor functional tests with security roles checks (#96856) * Reporting: Refactor functional tests with security roles checks * consolidate initEcommerce calls Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../workpad_header/share_menu/share_menu.ts | 1 + x-pack/scripts/functional_tests.js | 2 + .../ftr_provider_context.d.ts | 3 +- .../reporting_and_security.config.ts | 25 +-- ...diate.snap => download_csv_dashboard.snap} | 0 .../reporting_and_security/constants.ts | 18 -- ...immediate.ts => download_csv_dashboard.ts} | 29 +-- ...job_params.ts => generate_csv_discover.ts} | 2 +- .../reporting_and_security/index.ts | 15 +- .../security_roles_privileges.ts | 192 ++++++++++++++++++ .../reporting_and_security/usage.ts | 22 +- .../reporting_without_security.config.ts | 25 +-- .../reporting_without_security/index.ts | 3 +- .../reporting_without_security/job_apis.ts | 4 +- .../{ => services}/fixtures.ts | 0 .../{ => services}/generation_urls.ts | 0 .../services/index.ts | 26 +++ .../services/scenarios.ts | 154 ++++++++++++++ .../{services.ts => services/usage.ts} | 83 +------- .../ftr_provider_context.d.ts | 12 ++ .../reporting_and_security.config.ts | 37 ++++ .../reporting_and_security/index.ts | 57 ++++++ .../reporting_and_security/management.ts | 37 ++++ .../security_roles_privileges.ts | 109 ++++++++++ .../reporting_without_security.config.ts | 34 ++++ .../reporting_without_security/index.ts | 16 ++ .../reporting_without_security/management.ts | 2 +- .../reporting_functional/services/index.ts | 19 ++ .../services/scenarios.ts | 165 +++++++++++++++ 29 files changed, 916 insertions(+), 176 deletions(-) rename x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/{csv_searchsource_immediate.snap => download_csv_dashboard.snap} (100%) delete mode 100644 x-pack/test/reporting_api_integration/reporting_and_security/constants.ts rename x-pack/test/reporting_api_integration/reporting_and_security/{csv_searchsource_immediate.ts => download_csv_dashboard.ts} (94%) rename x-pack/test/reporting_api_integration/reporting_and_security/{csv_job_params.ts => generate_csv_discover.ts} (97%) create mode 100644 x-pack/test/reporting_api_integration/reporting_and_security/security_roles_privileges.ts rename x-pack/test/reporting_api_integration/{ => services}/fixtures.ts (100%) rename x-pack/test/reporting_api_integration/{ => services}/generation_urls.ts (100%) create mode 100644 x-pack/test/reporting_api_integration/services/index.ts create mode 100644 x-pack/test/reporting_api_integration/services/scenarios.ts rename x-pack/test/reporting_api_integration/{services.ts => services/usage.ts} (53%) create mode 100644 x-pack/test/reporting_functional/ftr_provider_context.d.ts create mode 100644 x-pack/test/reporting_functional/reporting_and_security.config.ts create mode 100644 x-pack/test/reporting_functional/reporting_and_security/index.ts create mode 100644 x-pack/test/reporting_functional/reporting_and_security/management.ts create mode 100644 x-pack/test/reporting_functional/reporting_and_security/security_roles_privileges.ts create mode 100644 x-pack/test/reporting_functional/reporting_without_security.config.ts create mode 100644 x-pack/test/reporting_functional/reporting_without_security/index.ts rename x-pack/test/{reporting_api_integration => reporting_functional}/reporting_without_security/management.ts (96%) create mode 100644 x-pack/test/reporting_functional/services/index.ts create mode 100644 x-pack/test/reporting_functional/services/scenarios.ts diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts index 942ae428e3691..a0448504db54b 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts @@ -91,6 +91,7 @@ export const ShareMenu = compose( .catch((err: Error) => { services.notify.error(err, { title: strings.getExportPDFErrorTitle(workpad.name), + 'data-test-subj': 'queueReportError', }); }); case 'json': diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 1f6fe310bfa7c..450cbc224eb48 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -12,6 +12,8 @@ const alwaysImportedTests = [ require.resolve('../test/plugin_functional/config.ts'), require.resolve('../test/functional_with_es_ssl/config.ts'), require.resolve('../test/functional/config_security_basic.ts'), + require.resolve('../test/reporting_functional/reporting_and_security.config.ts'), + require.resolve('../test/reporting_functional/reporting_without_security.config.ts'), require.resolve('../test/security_functional/login_selector.config.ts'), require.resolve('../test/security_functional/oidc.config.ts'), require.resolve('../test/security_functional/saml.config.ts'), diff --git a/x-pack/test/reporting_api_integration/ftr_provider_context.d.ts b/x-pack/test/reporting_api_integration/ftr_provider_context.d.ts index 809f464289ff2..671866cad6ff5 100644 --- a/x-pack/test/reporting_api_integration/ftr_provider_context.d.ts +++ b/x-pack/test/reporting_api_integration/ftr_provider_context.d.ts @@ -6,7 +6,6 @@ */ import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; -import { pageObjects } from '../functional/page_objects'; // Reporting APIs depend on UI functionality import { services } from './services'; -export type FtrProviderContext = GenericFtrProviderContext; +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/reporting_api_integration/reporting_and_security.config.ts b/x-pack/test/reporting_api_integration/reporting_and_security.config.ts index ddd6fe046dd31..623799c84d860 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security.config.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security.config.ts @@ -5,16 +5,14 @@ * 2.0. */ -// @ts-expect-error https://github.com/elastic/kibana/issues/95679 -import { esTestConfig, kbnTestConfig, kibanaServerTestUser } from '@kbn/test'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; -import { format as formatUrl } from 'url'; +import { resolve } from 'path'; import { ReportingAPIProvider } from './services'; export default async function ({ readConfigFile }: FtrConfigProviderContext) { const apiConfig = await readConfigFile(require.resolve('../api_integration/config')); - const functionalConfig = await readConfigFile(require.resolve('../functional/config')); // Reporting API tests need a fully working UI + // config for testing network policy const testPolicyRules = [ { allow: true, protocol: 'http:' }, { allow: false, host: 'via.placeholder.com' }, @@ -24,9 +22,9 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ]; return { - servers: apiConfig.get('servers'), + ...apiConfig.getAll(), junit: { reportName: 'X-Pack Reporting API Integration Tests' }, - testFiles: [require.resolve('./reporting_and_security')], + testFiles: [resolve(__dirname, './reporting_and_security')], services: { ...apiConfig.get('services'), reportingAPI: ReportingAPIProvider, @@ -34,22 +32,11 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { kbnTestServer: { ...apiConfig.get('kbnTestServer'), serverArgs: [ - ...functionalConfig.get('kbnTestServer.serverArgs'), - - `--elasticsearch.hosts=${formatUrl(esTestConfig.getUrlParts())}`, - `--elasticsearch.password=${kibanaServerTestUser.password}`, - `--elasticsearch.username=${kibanaServerTestUser.username}`, - `--logging.json=false`, - `--server.maxPayloadBytes=1679958`, - `--server.port=${kbnTestConfig.getPort()}`, + ...apiConfig.get('kbnTestServer.serverArgs'), + `--xpack.reporting.capture.networkPolicy.rules=${JSON.stringify(testPolicyRules)}`, `--xpack.reporting.capture.maxAttempts=1`, `--xpack.reporting.csv.maxSizeBytes=6000`, - `--xpack.reporting.queue.pollInterval=3000`, - `--xpack.security.session.idleTimeout=3600000`, - `--xpack.reporting.capture.networkPolicy.rules=${JSON.stringify(testPolicyRules)}`, ], }, - esArchiver: apiConfig.get('esArchiver'), - esTestCluster: apiConfig.get('esTestCluster'), }; } diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/csv_searchsource_immediate.snap b/x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/download_csv_dashboard.snap similarity index 100% rename from x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/csv_searchsource_immediate.snap rename to x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/download_csv_dashboard.snap diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/constants.ts b/x-pack/test/reporting_api_integration/reporting_and_security/constants.ts deleted file mode 100644 index f765046bce9b1..0000000000000 --- a/x-pack/test/reporting_api_integration/reporting_and_security/constants.ts +++ /dev/null @@ -1,18 +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 { REPO_ROOT } from '@kbn/utils'; -import path from 'path'; - -export const OSS_KIBANA_ARCHIVE_PATH = path.resolve( - REPO_ROOT, - 'test/functional/fixtures/es_archiver/dashboard/current/kibana' -); -export const OSS_DATA_ARCHIVE_PATH = path.resolve( - REPO_ROOT, - 'test/functional/fixtures/es_archiver/dashboard/current/data' -); diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/csv_searchsource_immediate.ts b/x-pack/test/reporting_api_integration/reporting_and_security/download_csv_dashboard.ts similarity index 94% rename from x-pack/test/reporting_api_integration/reporting_and_security/csv_searchsource_immediate.ts rename to x-pack/test/reporting_api_integration/reporting_and_security/download_csv_dashboard.ts index f381bc1edd28e..7f642f171b9fc 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/csv_searchsource_immediate.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/download_csv_dashboard.ts @@ -38,15 +38,14 @@ export default function ({ getService }: FtrProviderContext) { 'dateFormat:tz': 'UTC', defaultIndex: 'logstash-*', }); + await reportingAPI.initEcommerce(); }); after(async () => { + await reportingAPI.teardownEcommerce(); await reportingAPI.deleteAllReports(); }); it('Exports CSV with almost all fields when using fieldsFromSource', async () => { - await esArchiver.load('reporting/ecommerce'); - await esArchiver.load('reporting/ecommerce_kibana'); - const { status: resStatus, text: resText, @@ -145,15 +144,9 @@ export default function ({ getService }: FtrProviderContext) { expect(resStatus).to.eql(200); expect(resType).to.eql('text/csv'); expectSnapshot(resText).toMatch(); - - await esArchiver.unload('reporting/ecommerce'); - await esArchiver.unload('reporting/ecommerce_kibana'); }); it('Exports CSV with all fields when using defaults', async () => { - await esArchiver.load('reporting/ecommerce'); - await esArchiver.load('reporting/ecommerce_kibana'); - const { status: resStatus, text: resText, @@ -192,15 +185,9 @@ export default function ({ getService }: FtrProviderContext) { expect(resStatus).to.eql(200); expect(resType).to.eql('text/csv'); expectSnapshot(resText).toMatch(); - - await esArchiver.unload('reporting/ecommerce'); - await esArchiver.unload('reporting/ecommerce_kibana'); }); it('Logs the error explanation if the search query returns an error', async () => { - await esArchiver.load('reporting/ecommerce'); - await esArchiver.load('reporting/ecommerce_kibana'); - const { status: resStatus, text: resText } = (await generateAPI.getCSVFromSearchSource( getMockJobParams({ searchSource: { @@ -234,9 +221,6 @@ export default function ({ getService }: FtrProviderContext) { )) as supertest.Response; expect(resStatus).to.eql(500); expectSnapshot(resText).toMatch(); - - await esArchiver.unload('reporting/ecommerce'); - await esArchiver.unload('reporting/ecommerce_kibana'); }); describe('date formatting', () => { @@ -434,6 +418,9 @@ export default function ({ getService }: FtrProviderContext) { }); describe('validation', () => { + after(async () => { + await reportingAPI.deleteAllReports(); + }); it('Return a 404', async () => { const { body } = (await generateAPI.getCSVFromSearchSource( getMockJobParams({ @@ -451,8 +438,7 @@ export default function ({ getService }: FtrProviderContext) { }); it(`Searches large amount of data, stops at Max Size Reached`, async () => { - await esArchiver.load('reporting/ecommerce'); - await esArchiver.load('reporting/ecommerce_kibana'); + await reportingAPI.initEcommerce(); const { status: resStatus, @@ -504,8 +490,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resType).to.eql('text/csv'); expectSnapshot(resText).toMatch(); - await esArchiver.unload('reporting/ecommerce'); - await esArchiver.unload('reporting/ecommerce_kibana'); + await reportingAPI.teardownEcommerce(); }); }); }); diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/csv_job_params.ts b/x-pack/test/reporting_api_integration/reporting_and_security/generate_csv_discover.ts similarity index 97% rename from x-pack/test/reporting_api_integration/reporting_and_security/csv_job_params.ts rename to x-pack/test/reporting_api_integration/reporting_and_security/generate_csv_discover.ts index b3fa9ebe46f8c..3370eb0bb398b 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/csv_job_params.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/generate_csv_discover.ts @@ -7,7 +7,7 @@ import expect from '@kbn/expect'; import supertest from 'supertest'; -import { JOB_PARAMS_RISON_CSV_DEPRECATED } from '../fixtures'; +import { JOB_PARAMS_RISON_CSV_DEPRECATED } from '../services/fixtures'; import { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts index b4e05e37d3fda..78873f2097e80 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts @@ -8,11 +8,20 @@ import { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export -export default function ({ loadTestFile }: FtrProviderContext) { +export default function ({ getService, loadTestFile }: FtrProviderContext) { describe('Reporting APIs', function () { this.tags('ciGroup2'); - loadTestFile(require.resolve('./csv_job_params')); - loadTestFile(require.resolve('./csv_searchsource_immediate')); + + before(async () => { + const reportingAPI = getService('reportingAPI'); + await reportingAPI.createDataAnalystRole(); + await reportingAPI.createDataAnalyst(); + await reportingAPI.createTestReportingUser(); + }); + + loadTestFile(require.resolve('./security_roles_privileges')); + loadTestFile(require.resolve('./download_csv_dashboard')); + loadTestFile(require.resolve('./generate_csv_discover')); loadTestFile(require.resolve('./network_policy')); loadTestFile(require.resolve('./spaces')); loadTestFile(require.resolve('./usage')); diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/security_roles_privileges.ts b/x-pack/test/reporting_api_integration/reporting_and_security/security_roles_privileges.ts new file mode 100644 index 0000000000000..4dbf1b6fa5ebb --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/security_roles_privileges.ts @@ -0,0 +1,192 @@ +/* + * 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 supertest from 'supertest'; +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const reportingAPI = getService('reportingAPI'); + + describe('Security Roles and Privileges for Applications', () => { + before(async () => { + await reportingAPI.initEcommerce(); + }); + after(async () => { + await reportingAPI.teardownEcommerce(); + await reportingAPI.deleteAllReports(); + }); + + describe('Dashboard: CSV download file', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = (await reportingAPI.downloadCsv( + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD, + { + searchSource: { + query: { query: '', language: 'kuery' }, + index: '5193f870-d861-11e9-a311-0fa548c5f953', + filter: [], + }, + browserTimezone: 'UTC', + title: 'testfooyu78yt90-', + } as any + )) as supertest.Response; + expect(res.status).to.eql(403); + }); + + it('does allow user with the role privilege', async () => { + const res = (await reportingAPI.downloadCsv( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + searchSource: { + query: { query: '', language: 'kuery' }, + index: '5193f870-d861-11e9-a311-0fa548c5f953', + filter: [], + }, + browserTimezone: 'UTC', + title: 'testfooyu78yt90-', + } as any + )) as supertest.Response; + expect(res.status).to.eql(200); + }); + }); + + describe('Dashboard: Generate PDF report', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = await reportingAPI.generatePdf( + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF disallowed', + layout: { id: 'preserve' }, + relativeUrls: ['/fooyou'], + objectType: 'dashboard', + } + ); + expect(res.status).to.eql(403); + }); + + it('does allow user with the role-based privilege', async () => { + const res = await reportingAPI.generatePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve' }, + relativeUrls: ['/fooyou'], + objectType: 'dashboard', + } + ); + expect(res.status).to.eql(200); + }); + }); + + describe('Visualize: Generate PDF report', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = await reportingAPI.generatePdf( + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF disallowed', + layout: { id: 'preserve' }, + relativeUrls: ['/fooyou'], + objectType: 'visualization', + } + ); + expect(res.status).to.eql(403); + }); + + it('does allow user with the role-based privilege', async () => { + const res = await reportingAPI.generatePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve' }, + relativeUrls: ['/fooyou'], + objectType: 'visualization', + } + ); + expect(res.status).to.eql(200); + }); + }); + + describe('Canvas: Generate PDF report', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = await reportingAPI.generatePdf( + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF disallowed', + layout: { id: 'preserve' }, + relativeUrls: ['/fooyou'], + objectType: 'canvas', + } + ); + expect(res.status).to.eql(403); + }); + + it('does allow user with the role-based privilege', async () => { + const res = await reportingAPI.generatePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve' }, + relativeUrls: ['/fooyou'], + objectType: 'canvas', + } + ); + expect(res.status).to.eql(200); + }); + }); + + describe('Discover: Generate CSV report', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = await reportingAPI.generateCsv( + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD, + { + browserTimezone: 'UTC', + searchSource: {}, + objectType: 'search', + title: 'test disallowed', + } + ); + expect(res.status).to.eql(403); + }); + + it('does allow user with the role-based privilege', async () => { + const res = await reportingAPI.generateCsv( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'allowed search', + objectType: 'search', + searchSource: { + version: true, + fields: [{ field: '*', include_unmapped: 'true' }], + index: '5193f870-d861-11e9-a311-0fa548c5f953', + } as any, + columns: [], + } + ); + expect(res.status).to.eql(200); + }); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts index 2a6bf95023fb4..a69534cfc4df7 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts @@ -6,10 +6,20 @@ */ import expect from '@kbn/expect'; +import { REPO_ROOT } from '@kbn/utils'; +import path from 'path'; import { FtrProviderContext } from '../ftr_provider_context'; -import * as GenerationUrls from '../generation_urls'; -import { ReportingUsageStats } from '../services'; -import { OSS_DATA_ARCHIVE_PATH, OSS_KIBANA_ARCHIVE_PATH } from './constants'; +import * as GenerationUrls from '../services/generation_urls'; +import { ReportingUsageStats } from '../services/usage'; + +const OSS_KIBANA_ARCHIVE_PATH = path.resolve( + REPO_ROOT, + 'test/functional/fixtures/es_archiver/dashboard/current/kibana' +); +const OSS_DATA_ARCHIVE_PATH = path.resolve( + REPO_ROOT, + 'test/functional/fixtures/es_archiver/dashboard/current/data' +); interface UsageStats { reporting: ReportingUsageStats; @@ -20,6 +30,7 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const reportingAPI = getService('reportingAPI'); + const retry = getService('retry'); const usageAPI = getService('usageAPI'); describe('Usage', () => { @@ -46,7 +57,10 @@ export default function ({ getService }: FtrProviderContext) { let usage: UsageStats; before(async () => { - usage = (await usageAPI.getUsageStats()) as UsageStats; + await retry.try(async () => { + // use retry for stability - usage API could return 503 + usage = (await usageAPI.getUsageStats()) as UsageStats; + }); }); it('shows reporting as available and enabled', async () => { diff --git a/x-pack/test/reporting_api_integration/reporting_without_security.config.ts b/x-pack/test/reporting_api_integration/reporting_without_security.config.ts index 20f9ff1b10592..b962ab30876a5 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security.config.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security.config.ts @@ -5,24 +5,15 @@ * 2.0. */ -// @ts-expect-error https://github.com/elastic/kibana/issues/95679 -import { esTestConfig, kbnTestConfig } from '@kbn/test'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; -import { format as formatUrl } from 'url'; -import { pageObjects } from '../functional/page_objects'; // Reporting APIs depend on UI functionality -import { services } from './services'; export default async function ({ readConfigFile }: FtrConfigProviderContext) { - const apiConfig = await readConfigFile(require.resolve('../api_integration/config')); + const apiConfig = await readConfigFile(require.resolve('./reporting_and_security.config')); return { - apps: { reporting: { pathname: '/app/management/insightsAndAlerting/reporting' } }, - servers: apiConfig.get('servers'), - junit: { reportName: 'X-Pack Reporting Without Security API Integration Tests' }, + ...apiConfig.getAll(), + junit: { reportName: 'X-Pack Reporting API Integration Tests Without Security Enabled' }, testFiles: [require.resolve('./reporting_without_security')], - services, - pageObjects, - esArchiver: apiConfig.get('esArchiver'), esTestCluster: { ...apiConfig.get('esTestCluster'), serverArgs: [ @@ -33,15 +24,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { }, kbnTestServer: { ...apiConfig.get('kbnTestServer'), - serverArgs: [ - `--elasticsearch.hosts=${formatUrl(esTestConfig.getUrlParts())}`, - `--logging.json=false`, - `--server.maxPayloadBytes=1679958`, - `--server.port=${kbnTestConfig.getPort()}`, - `--xpack.reporting.capture.maxAttempts=1`, - `--xpack.reporting.csv.maxSizeBytes=2850`, - `--xpack.security.enabled=false`, - ], + serverArgs: [...apiConfig.get('kbnTestServer.serverArgs'), `--xpack.security.enabled=false`], }, }; } diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/index.ts b/x-pack/test/reporting_api_integration/reporting_without_security/index.ts index eb0a349df7d3e..15960e45d4a62 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/index.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security/index.ts @@ -9,9 +9,8 @@ import { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function ({ loadTestFile }: FtrProviderContext) { - describe('Reporting APIs', function () { + describe('Reporting API Integration Tests with Security disabled', function () { this.tags('ciGroup13'); loadTestFile(require.resolve('./job_apis')); - loadTestFile(require.resolve('./management')); }); } diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/job_apis.ts b/x-pack/test/reporting_api_integration/reporting_without_security/job_apis.ts index 8d827f02dfd16..194a3d6d1f5bc 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/job_apis.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security/job_apis.ts @@ -7,7 +7,7 @@ import expect from '@kbn/expect'; import { forOwn } from 'lodash'; -import { JOB_PARAMS_RISON_CSV_DEPRECATED } from '../fixtures'; +import { JOB_PARAMS_RISON_CSV_DEPRECATED } from '../services/fixtures'; import { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export @@ -16,7 +16,7 @@ export default function ({ getService }: FtrProviderContext) { const supertestNoAuth = getService('supertestWithoutAuth'); const reportingAPI = getService('reportingAPI'); - describe('Job Listing APIs, Without Security', () => { + describe('Job Listing APIs', () => { before(async () => { await esArchiver.load('reporting/logs'); await esArchiver.load('logstash_functional'); diff --git a/x-pack/test/reporting_api_integration/fixtures.ts b/x-pack/test/reporting_api_integration/services/fixtures.ts similarity index 100% rename from x-pack/test/reporting_api_integration/fixtures.ts rename to x-pack/test/reporting_api_integration/services/fixtures.ts diff --git a/x-pack/test/reporting_api_integration/generation_urls.ts b/x-pack/test/reporting_api_integration/services/generation_urls.ts similarity index 100% rename from x-pack/test/reporting_api_integration/generation_urls.ts rename to x-pack/test/reporting_api_integration/services/generation_urls.ts diff --git a/x-pack/test/reporting_api_integration/services/index.ts b/x-pack/test/reporting_api_integration/services/index.ts new file mode 100644 index 0000000000000..c0c3da4dd6ba1 --- /dev/null +++ b/x-pack/test/reporting_api_integration/services/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { services as xpackServices } from '../../functional/services'; +import { services as apiIntegrationServices } from '../../api_integration/services'; +import { FtrProviderContext } from '../ftr_provider_context'; +import { createUsageServices } from './usage'; +import { createScenarios } from './scenarios'; + +export function ReportingAPIProvider(context: FtrProviderContext) { + return { + ...createScenarios(context), + ...createUsageServices(context), + }; +} + +export const services = { + ...xpackServices, + supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, + usageAPI: apiIntegrationServices.usageAPI, + reportingAPI: ReportingAPIProvider, +}; diff --git a/x-pack/test/reporting_api_integration/services/scenarios.ts b/x-pack/test/reporting_api_integration/services/scenarios.ts new file mode 100644 index 0000000000000..d13deac3578ba --- /dev/null +++ b/x-pack/test/reporting_api_integration/services/scenarios.ts @@ -0,0 +1,154 @@ +/* + * 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 rison, { RisonValue } from 'rison-node'; +import { JobParamsCSV } from '../../../plugins/reporting/server/export_types/csv_searchsource/types'; +import { JobParamsDownloadCSV } from '../../../plugins/reporting/server/export_types/csv_searchsource_immediate/types'; +import { JobParamsPNG } from '../../../plugins/reporting/server/export_types/png/types'; +import { JobParamsPDF } from '../../../plugins/reporting/server/export_types/printable_pdf/types'; +import { FtrProviderContext } from '../ftr_provider_context'; + +function removeWhitespace(str: string) { + return str.replace(/\s/g, ''); +} + +export function createScenarios({ getService }: Pick) { + const security = getService('security'); + const esArchiver = getService('esArchiver'); + const log = getService('log'); + const supertest = getService('supertest'); + const esSupertest = getService('esSupertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const retry = getService('retry'); + + const DATA_ANALYST_USERNAME = 'data_analyst'; + const DATA_ANALYST_PASSWORD = 'data_analyst-password'; + const REPORTING_USER_USERNAME = 'reporting_user'; + const REPORTING_USER_PASSWORD = 'reporting_user-password'; + + const initEcommerce = async () => { + await esArchiver.load('reporting/ecommerce'); + await esArchiver.load('reporting/ecommerce_kibana'); + }; + const teardownEcommerce = async () => { + await esArchiver.unload('reporting/ecommerce'); + await esArchiver.unload('reporting/ecommerce_kibana'); + await deleteAllReports(); + }; + + const createDataAnalystRole = async () => { + await security.role.create('data_analyst', { + metadata: {}, + elasticsearch: { + cluster: [], + indices: [ + { + names: ['ecommerce'], + privileges: ['read', 'view_index_metadata'], + allow_restricted_indices: false, + }, + ], + run_as: [], + }, + kibana: [{ base: ['read'], feature: {}, spaces: ['*'] }], + }); + }; + + const createDataAnalyst = async () => { + await security.user.create('data_analyst', { + password: 'data_analyst-password', + roles: ['data_analyst'], + full_name: 'Data Analyst User', + }); + }; + + const createTestReportingUser = async () => { + await security.user.create('reporting_user', { + password: 'reporting_user-password', + roles: ['data_analyst', 'reporting_user'], + full_name: 'Reporting User', + }); + }; + + const downloadCsv = async (username: string, password: string, job: JobParamsDownloadCSV) => { + return await supertestWithoutAuth + .post(`/api/reporting/v1/generate/immediate/csv_searchsource`) + .auth(username, password) + .set('kbn-xsrf', 'xxx') + .send(job); + }; + const generatePdf = async (username: string, password: string, job: JobParamsPDF) => { + const jobParams = rison.encode((job as object) as RisonValue); + return await supertestWithoutAuth + .post(`/api/reporting/generate/printablePdf`) + .auth(username, password) + .set('kbn-xsrf', 'xxx') + .send({ jobParams }); + }; + const generatePng = async (username: string, password: string, job: JobParamsPNG) => { + const jobParams = rison.encode((job as object) as RisonValue); + return await supertestWithoutAuth + .post(`/api/reporting/generate/png`) + .auth(username, password) + .set('kbn-xsrf', 'xxx') + .send({ jobParams }); + }; + const generateCsv = async (username: string, password: string, job: JobParamsCSV) => { + const jobParams = rison.encode((job as object) as RisonValue); + return await supertestWithoutAuth + .post(`/api/reporting/generate/csv_searchsource`) + .auth(username, password) + .set('kbn-xsrf', 'xxx') + .send({ jobParams }); + }; + + const postJob = async (apiPath: string): Promise => { + log.debug(`ReportingAPI.postJob(${apiPath})`); + const { body } = await supertest + .post(removeWhitespace(apiPath)) + .set('kbn-xsrf', 'xxx') + .expect(200); + return body.path; + }; + + const postJobJSON = async (apiPath: string, jobJSON: object = {}): Promise => { + log.debug(`ReportingAPI.postJobJSON((${apiPath}): ${JSON.stringify(jobJSON)})`); + const { body } = await supertest.post(apiPath).set('kbn-xsrf', 'xxx').send(jobJSON); + return body.path; + }; + + const deleteAllReports = async () => { + log.debug('ReportingAPI.deleteAllReports'); + + // ignores 409 errs and keeps retrying + await retry.tryForTime(5000, async () => { + await esSupertest + .post('/.reporting*/_delete_by_query') + .send({ query: { match_all: {} } }) + .expect(200); + }); + }; + + return { + initEcommerce, + teardownEcommerce, + DATA_ANALYST_USERNAME, + DATA_ANALYST_PASSWORD, + REPORTING_USER_USERNAME, + REPORTING_USER_PASSWORD, + createDataAnalystRole, + createDataAnalyst, + createTestReportingUser, + downloadCsv, + generatePdf, + generatePng, + generateCsv, + postJob, + postJobJSON, + deleteAllReports, + }; +} diff --git a/x-pack/test/reporting_api_integration/services.ts b/x-pack/test/reporting_api_integration/services/usage.ts similarity index 53% rename from x-pack/test/reporting_api_integration/services.ts rename to x-pack/test/reporting_api_integration/services/usage.ts index b451a6b65fc91..ababbbf03e4c1 100644 --- a/x-pack/test/reporting_api_integration/services.ts +++ b/x-pack/test/reporting_api_integration/services/usage.ts @@ -6,10 +6,7 @@ */ import expect from '@kbn/expect'; -import { indexTimestamp } from '../../plugins/reporting/server/lib/store/index_timestamp'; -import { services as xpackServices } from '../functional/services'; -import { services as apiIntegrationServices } from '../api_integration/services'; -import { FtrProviderContext } from './ftr_provider_context'; +import { FtrProviderContext } from '../ftr_provider_context'; interface PDFAppCounts { app: { @@ -38,15 +35,9 @@ interface UsageStats { reporting: ReportingUsageStats; } -function removeWhitespace(str: string) { - return str.replace(/\s/g, ''); -} - -export function ReportingAPIProvider({ getService }: FtrProviderContext) { +export function createUsageServices({ getService }: FtrProviderContext) { const log = getService('log'); const supertest = getService('supertest'); - const esSupertest = getService('esSupertest'); - const retry = getService('retry'); return { async waitForJobToFinish(downloadReportPath: string) { @@ -84,69 +75,6 @@ export function ReportingAPIProvider({ getService }: FtrProviderContext) { ); }, - async postJob(apiPath: string): Promise { - log.debug(`ReportingAPI.postJob(${apiPath})`); - const { body } = await supertest - .post(removeWhitespace(apiPath)) - .set('kbn-xsrf', 'xxx') - .expect(200); - return body.path; - }, - - async postJobJSON(apiPath: string, jobJSON: object = {}): Promise { - log.debug(`ReportingAPI.postJobJSON((${apiPath}): ${JSON.stringify(jobJSON)})`); - const { body } = await supertest.post(apiPath).set('kbn-xsrf', 'xxx').send(jobJSON); - return body.path; - }, - - /** - * - * @return {Promise} A function to call to clean up the index alias that was added. - */ - async coerceReportsIntoExistingIndex(indexName: string) { - log.debug(`ReportingAPI.coerceReportsIntoExistingIndex(${indexName})`); - - // Adding an index alias coerces the report to be generated on an existing index which means any new - // index schema won't be applied. This is important if a point release updated the schema. Reports may still - // be inserted into an existing index before the new schema is applied. - const timestampForIndex = indexTimestamp('week', '.'); - await esSupertest - .post('/_aliases') - .send({ - actions: [ - { - add: { index: indexName, alias: `.reporting-${timestampForIndex}` }, - }, - ], - }) - .expect(200); - - return async () => { - await esSupertest - .post('/_aliases') - .send({ - actions: [ - { - remove: { index: indexName, alias: `.reporting-${timestampForIndex}` }, - }, - ], - }) - .expect(200); - }; - }, - - async deleteAllReports() { - log.debug('ReportingAPI.deleteAllReports'); - - // ignores 409 errs and keeps retrying - await retry.tryForTime(5000, async () => { - await esSupertest - .post('/.reporting*/_delete_by_query') - .send({ query: { match_all: {} } }) - .expect(200); - }); - }, - expectRecentPdfAppStats(stats: UsageStats, app: string, count: number) { expect(stats.reporting.last_7_days.printable_pdf.app[app]).to.be(count); }, @@ -180,10 +108,3 @@ export function ReportingAPIProvider({ getService }: FtrProviderContext) { }, }; } - -export const services = { - ...xpackServices, - supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, - usageAPI: apiIntegrationServices.usageAPI, - reportingAPI: ReportingAPIProvider, -}; diff --git a/x-pack/test/reporting_functional/ftr_provider_context.d.ts b/x-pack/test/reporting_functional/ftr_provider_context.d.ts new file mode 100644 index 0000000000000..58ebd71086130 --- /dev/null +++ b/x-pack/test/reporting_functional/ftr_provider_context.d.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. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; +import { pageObjects } from '../functional/page_objects'; +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/reporting_functional/reporting_and_security.config.ts b/x-pack/test/reporting_functional/reporting_and_security.config.ts new file mode 100644 index 0000000000000..1f9ec5754e0bd --- /dev/null +++ b/x-pack/test/reporting_functional/reporting_and_security.config.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 { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { resolve } from 'path'; +import { ReportingAPIProvider } from '../reporting_api_integration/services'; +import { ReportingFunctionalProvider } from './services'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile(require.resolve('../functional/config')); // Reporting API tests need a fully working UI + const apiConfig = await readConfigFile(require.resolve('../api_integration/config')); + + return { + ...apiConfig.getAll(), + ...functionalConfig.getAll(), + junit: { reportName: 'X-Pack Reporting Functional Tests' }, + testFiles: [resolve(__dirname, './reporting_and_security')], + kbnTestServer: { + ...functionalConfig.get('kbnTestServer'), + serverArgs: [ + ...functionalConfig.get('kbnTestServer.serverArgs'), + `--xpack.reporting.capture.maxAttempts=1`, + `--xpack.reporting.csv.maxSizeBytes=6000`, + ], + }, + services: { + ...apiConfig.get('services'), + ...functionalConfig.get('services'), + reportingAPI: ReportingAPIProvider, + reportingFunctional: ReportingFunctionalProvider, + }, + }; +} diff --git a/x-pack/test/reporting_functional/reporting_and_security/index.ts b/x-pack/test/reporting_functional/reporting_and_security/index.ts new file mode 100644 index 0000000000000..f3e01453b0a59 --- /dev/null +++ b/x-pack/test/reporting_functional/reporting_and_security/index.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const security = getService('security'); + const createDataAnalystRole = async () => { + await security.role.create('data_analyst', { + metadata: {}, + elasticsearch: { + cluster: [], + indices: [ + { + names: ['ecommerce'], + privileges: ['read', 'view_index_metadata'], + allow_restricted_indices: false, + }, + ], + run_as: [], + }, + kibana: [{ base: ['all'], feature: {}, spaces: ['*'] }], + }); + }; + const createDataAnalyst = async () => { + await security.user.create('data_analyst', { + password: 'data_analyst-password', + roles: ['data_analyst', 'kibana_user'], + full_name: 'a kibana user called data_a', + }); + }; + const createReportingUser = async () => { + await security.user.create('reporting_user', { + password: 'reporting_user-password', + roles: ['reporting_user', 'data_analyst', 'kibana_user'], + full_name: 'a reporting user', + }); + }; + + describe('Reporting Functional Tests with Role-based Security configuration enabled', function () { + this.tags('ciGroup2'); + + before(async () => { + await createDataAnalystRole(); + await createDataAnalyst(); + await createReportingUser(); + }); + + loadTestFile(require.resolve('./security_roles_privileges')); + loadTestFile(require.resolve('./management')); + }); +} diff --git a/x-pack/test/reporting_functional/reporting_and_security/management.ts b/x-pack/test/reporting_functional/reporting_and_security/management.ts new file mode 100644 index 0000000000000..dba16c798d4ff --- /dev/null +++ b/x-pack/test/reporting_functional/reporting_and_security/management.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 { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService, getPageObjects }: FtrProviderContext) => { + const PageObjects = getPageObjects(['common', 'reporting', 'discover']); + + const testSubjects = getService('testSubjects'); + const reportingFunctional = getService('reportingFunctional'); + + describe('Access to Management > Reporting', () => { + before(async () => { + await reportingFunctional.initEcommerce(); + }); + after(async () => { + await reportingFunctional.teardownEcommerce(); + }); + + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await PageObjects.common.navigateToApp('reporting'); + await testSubjects.missingOrFail('reportJobListing'); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginReportingUser(); + await PageObjects.common.navigateToApp('reporting'); + await testSubjects.existOrFail('reportJobListing'); + }); + }); +}; diff --git a/x-pack/test/reporting_functional/reporting_and_security/security_roles_privileges.ts b/x-pack/test/reporting_functional/reporting_and_security/security_roles_privileges.ts new file mode 100644 index 0000000000000..76ccb01477856 --- /dev/null +++ b/x-pack/test/reporting_functional/reporting_and_security/security_roles_privileges.ts @@ -0,0 +1,109 @@ +/* + * 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 { FtrProviderContext } from '../ftr_provider_context'; + +const DASHBOARD_TITLE = 'Ecom Dashboard'; +const SAVEDSEARCH_TITLE = 'Ecommerce Data'; +const VIS_TITLE = 'e-commerce pie chart'; +const CANVAS_TITLE = 'The Very Cool Workpad for PDF Tests'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const reportingFunctional = getService('reportingFunctional'); + + describe('Security with `reporting_user` built-in role', () => { + before(async () => { + await reportingFunctional.initEcommerce(); + }); + after(async () => { + await reportingFunctional.teardownEcommerce(); + }); + + describe('Dashboard: Download CSV file', () => { + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); + await reportingFunctional.tryDashboardDownloadCsvFail('Ecommerce Data'); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); + await reportingFunctional.tryDashboardDownloadCsvSuccess('Ecommerce Data'); + }); + }); + + describe('Dashboard: Generate Screenshot', () => { + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); + await reportingFunctional.tryGeneratePdfFail(); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginReportingUser(); + await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); + await reportingFunctional.tryGeneratePdfSuccess(); + }); + }); + + describe('Discover: Generate CSV', () => { + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openSavedSearch(SAVEDSEARCH_TITLE); + await reportingFunctional.tryDiscoverCsvFail(); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginReportingUser(); + await reportingFunctional.openSavedSearch(SAVEDSEARCH_TITLE); + await reportingFunctional.tryDiscoverCsvSuccess(); + }); + }); + + describe('Canvas: Generate PDF', () => { + const esArchiver = getService('esArchiver'); + const reportingApi = getService('reportingAPI'); + before('initialize tests', async () => { + await esArchiver.load('canvas/reports'); + }); + + after('teardown tests', async () => { + await esArchiver.unload('canvas/reports'); + await reportingApi.deleteAllReports(); + await reportingFunctional.initEcommerce(); + }); + + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openCanvasWorkpad(CANVAS_TITLE); + await reportingFunctional.tryGeneratePdfFail(); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginReportingUser(); + await reportingFunctional.openCanvasWorkpad(CANVAS_TITLE); + await reportingFunctional.tryGeneratePdfSuccess(); + }); + }); + + describe('Visualize Editor: Generate Screenshot', () => { + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openSavedVisualization(VIS_TITLE); + await reportingFunctional.tryGeneratePdfFail(); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginReportingUser(); + await reportingFunctional.openSavedVisualization(VIS_TITLE); + await reportingFunctional.tryGeneratePdfSuccess(); + }); + }); + }); +} diff --git a/x-pack/test/reporting_functional/reporting_without_security.config.ts b/x-pack/test/reporting_functional/reporting_without_security.config.ts new file mode 100644 index 0000000000000..b88c611543953 --- /dev/null +++ b/x-pack/test/reporting_functional/reporting_without_security.config.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { resolve } from 'path'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const reportingConfig = await readConfigFile(require.resolve('./reporting_and_security.config')); + + return { + ...reportingConfig.getAll(), + junit: { reportName: 'X-Pack Reporting Functional Tests Without Security Enabled' }, + testFiles: [resolve(__dirname, './reporting_without_security')], + kbnTestServer: { + ...reportingConfig.get('kbnTestServer'), + serverArgs: [ + ...reportingConfig.get('kbnTestServer.serverArgs'), + `--xpack.security.enabled=false`, + ], + }, + esTestCluster: { + ...reportingConfig.get('esTestCluster'), + serverArgs: [ + ...reportingConfig.get('esTestCluster.serverArgs'), + 'node.name=UnsecuredClusterNode01', + 'xpack.security.enabled=false', + ], + }, + }; +} diff --git a/x-pack/test/reporting_functional/reporting_without_security/index.ts b/x-pack/test/reporting_functional/reporting_without_security/index.ts new file mode 100644 index 0000000000000..d1801b7e3e2e6 --- /dev/null +++ b/x-pack/test/reporting_functional/reporting_without_security/index.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. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ loadTestFile, getService }: FtrProviderContext) { + describe('Reporting Functional Tests with Security disabled', function () { + this.tags('ciGroup2'); + loadTestFile(require.resolve('./management')); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/management.ts b/x-pack/test/reporting_functional/reporting_without_security/management.ts similarity index 96% rename from x-pack/test/reporting_api_integration/reporting_without_security/management.ts rename to x-pack/test/reporting_functional/reporting_without_security/management.ts index f6db20c75639d..b116bb5fe201c 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/management.ts +++ b/x-pack/test/reporting_functional/reporting_without_security/management.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { JOB_PARAMS_ECOM_MARKDOWN } from '../fixtures'; +import { JOB_PARAMS_ECOM_MARKDOWN } from '../../reporting_api_integration/services/fixtures'; import { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export diff --git a/x-pack/test/reporting_functional/services/index.ts b/x-pack/test/reporting_functional/services/index.ts new file mode 100644 index 0000000000000..458ddc7c73420 --- /dev/null +++ b/x-pack/test/reporting_functional/services/index.ts @@ -0,0 +1,19 @@ +/* + * 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 { services as apiServices } from '../../reporting_api_integration/services'; +import { FtrProviderContext } from '../ftr_provider_context'; +import { createScenarios } from './scenarios'; + +export function ReportingFunctionalProvider(context: FtrProviderContext) { + return createScenarios(context); +} + +export const services = { + ...apiServices, + reportingFunctional: ReportingFunctionalProvider, +}; diff --git a/x-pack/test/reporting_functional/services/scenarios.ts b/x-pack/test/reporting_functional/services/scenarios.ts new file mode 100644 index 0000000000000..a1387127ffc0a --- /dev/null +++ b/x-pack/test/reporting_functional/services/scenarios.ts @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; +import { createScenarios as createAPIScenarios } from '../../reporting_api_integration/services/scenarios'; + +export function createScenarios( + context: Pick +) { + const { getService, getPageObjects } = context; + const log = getService('log'); + const testSubjects = getService('testSubjects'); + const dashboardPanelActions = getService('dashboardPanelActions'); + + const PageObjects = getPageObjects([ + 'reporting', + 'security', + 'common', + 'share', + 'visualize', + 'dashboard', + 'discover', + 'canvas', + ]); + const scenariosAPI = createAPIScenarios(context); + + const { + DATA_ANALYST_USERNAME, + DATA_ANALYST_PASSWORD, + REPORTING_USER_USERNAME, + REPORTING_USER_PASSWORD, + } = scenariosAPI; + + const loginDataAnalyst = async () => { + await PageObjects.security.forceLogout(); + await PageObjects.security.login(DATA_ANALYST_USERNAME, DATA_ANALYST_PASSWORD, { + expectSpaceSelector: false, + }); + }; + + const loginReportingUser = async () => { + await PageObjects.security.forceLogout(); + await PageObjects.security.login(REPORTING_USER_USERNAME, REPORTING_USER_PASSWORD, { + expectSpaceSelector: false, + }); + }; + + const openSavedVisualization = async (title: string) => { + log.debug(`Opening saved visualizatiton: ${title}`); + await PageObjects.common.navigateToApp('visualize'); + await PageObjects.visualize.openSavedVisualization(title); + }; + + const openSavedDashboard = async (title: string) => { + log.debug(`Opening saved dashboard: ${title}`); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.loadSavedDashboard(title); + }; + + const openSavedSearch = async (title: string) => { + log.debug(`Opening saved search: ${title}`); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.loadSavedSearch(title); + }; + + const openCanvasWorkpad = async (title: string) => { + log.debug(`Opening saved canvas workpad: ${title}`); + await PageObjects.common.navigateToApp('canvas'); + await PageObjects.canvas.loadFirstWorkpad(title); + }; + + const getSavedSearchPanel = async (savedSearchTitle: string) => { + return await testSubjects.find(`embeddablePanelHeading-${savedSearchTitle.replace(' ', '')}`); + }; + const tryDashboardDownloadCsvFail = async (savedSearchTitle: string) => { + const savedSearchPanel = await getSavedSearchPanel(savedSearchTitle); + await dashboardPanelActions.toggleContextMenu(savedSearchPanel); + await dashboardPanelActions.clickContextMenuMoreItem(); + const actionItemTestSubj = 'embeddablePanelAction-downloadCsvReport'; + await testSubjects.existOrFail(actionItemTestSubj); + /* wait for the full panel to display or else the test runner could click the wrong option! */ await testSubjects.click( + actionItemTestSubj + ); + await testSubjects.existOrFail('downloadCsvFail'); + }; + const tryDashboardDownloadCsvNotAvailable = async (savedSearchTitle: string) => { + const savedSearchPanel = await getSavedSearchPanel(savedSearchTitle); + await dashboardPanelActions.toggleContextMenu(savedSearchPanel); + await dashboardPanelActions.clickContextMenuMoreItem(); + await testSubjects.missingOrFail('embeddablePanelAction-downloadCsvReport'); + }; + const tryDashboardDownloadCsvSuccess = async (savedSearchTitle: string) => { + const savedSearchPanel = await getSavedSearchPanel(savedSearchTitle); + await dashboardPanelActions.toggleContextMenu(savedSearchPanel); + await dashboardPanelActions.clickContextMenuMoreItem(); + const actionItemTestSubj = 'embeddablePanelAction-downloadCsvReport'; + await testSubjects.existOrFail(actionItemTestSubj); + /* wait for the full panel to display or else the test runner could click the wrong option! */ await testSubjects.click( + actionItemTestSubj + ); + await testSubjects.existOrFail('csvDownloadStarted'); /* validate toast panel */ + }; + const tryDiscoverCsvFail = async () => { + await PageObjects.reporting.openCsvReportingPanel(); + await PageObjects.reporting.clickGenerateReportButton(); + const queueReportError = await PageObjects.reporting.getQueueReportError(); + expect(queueReportError).to.be(true); + }; + const tryDiscoverCsvNotAvailable = async () => { + await PageObjects.share.clickShareTopNavButton(); + await testSubjects.missingOrFail('sharePanel-CSVReports'); + }; + const tryDiscoverCsvSuccess = async () => { + await PageObjects.reporting.openCsvReportingPanel(); + expect(await PageObjects.reporting.canReportBeCreated()).to.be(true); + }; + const tryGeneratePdfFail = async () => { + await PageObjects.reporting.openPdfReportingPanel(); + await PageObjects.reporting.clickGenerateReportButton(); + const queueReportError = await PageObjects.reporting.getQueueReportError(); + expect(queueReportError).to.be(true); + }; + const tryGeneratePdfNotAvailable = async () => { + PageObjects.share.clickShareTopNavButton(); + await testSubjects.missingOrFail(`sharePanel-PDFReports`); + }; + const tryGeneratePdfSuccess = async () => { + await PageObjects.reporting.openPdfReportingPanel(); + expect(await PageObjects.reporting.canReportBeCreated()).to.be(true); + }; + const tryGeneratePngSuccess = async () => { + await PageObjects.reporting.openPngReportingPanel(); + expect(await PageObjects.reporting.canReportBeCreated()).to.be(true); + }; + const tryReportsNotAvailable = async () => { + await PageObjects.share.clickShareTopNavButton(); + await testSubjects.missingOrFail('sharePanel-Reports'); + }; + + return { + ...scenariosAPI, + openSavedVisualization, + openSavedDashboard, + openSavedSearch, + openCanvasWorkpad, + tryDashboardDownloadCsvFail, + tryDashboardDownloadCsvNotAvailable, + tryDashboardDownloadCsvSuccess, + tryDiscoverCsvFail, + tryDiscoverCsvNotAvailable, + tryDiscoverCsvSuccess, + tryGeneratePdfFail, + tryGeneratePdfNotAvailable, + tryGeneratePdfSuccess, + tryGeneratePngSuccess, + tryReportsNotAvailable, + loginDataAnalyst, + loginReportingUser, + }; +} From 813681eb08d0b6659b5ec5f72bb7cf83397ae120 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Wed, 14 Apr 2021 12:21:46 -0400 Subject: [PATCH 24/43] [Upgrade Assistant] Redesign overview page (#95346) --- ...-plugin-core-public.doclinksstart.links.md | 1 + .../public/doc_links/doc_links_service.ts | 3 + src/core/public/public.api.md | 1 + .../translations/translations/ja-JP.json | 41 --- .../translations/translations/zh-CN.json | 41 --- x-pack/plugins/upgrade_assistant/kibana.json | 2 +- .../public/application/app.tsx | 39 ++- .../public/application/app_context.tsx | 5 +- .../application/components/error_banner.tsx | 49 --- .../__snapshots__/filter_bar.test.tsx.snap | 14 +- .../es_deprecations/deprecation_tab.tsx | 222 ------------- .../deprecation_tab_content.tsx | 138 +++++++++ .../es_deprecations/es_deprecation_errors.tsx | 50 +++ .../es_deprecations/es_deprecations.tsx | 212 +++++++++++++ .../es_deprecations/filter_bar.test.tsx | 4 +- .../components/es_deprecations/filter_bar.tsx | 56 ++-- .../components/es_deprecations/index.ts | 2 +- .../overview/deprecation_logging_toggle.tsx | 55 ++-- .../components/overview/es_stats.tsx | 129 ++++++++ .../components/overview/es_stats_error.tsx | 84 +++++ .../application/components/overview/index.ts | 2 +- .../components/overview/overview.tsx | 157 +++++++--- .../application/components/overview/steps.tsx | 293 ------------------ .../application/components/page_content.tsx | 44 --- .../public/application/components/tabs.tsx | 184 ----------- .../public/application/components/types.ts | 7 +- .../public/application/lib/breadcrumbs.ts | 66 ++++ .../application/lib/es_deprecation_errors.ts | 59 ++++ .../application/mount_management_section.ts | 10 +- .../public/application/render_app.tsx | 2 - .../public/shared_imports.ts | 1 + .../helpers/indices.helpers.ts | 16 +- .../helpers/overview.helpers.ts | 25 +- .../helpers/setup_environment.tsx | 11 +- .../tests_client_integration/indices.test.ts | 71 ++++- .../tests_client_integration/overview.test.ts | 237 +++++++------- .../accessibility/apps/upgrade_assistant.ts | 26 +- 37 files changed, 1217 insertions(+), 1142 deletions(-) delete mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/error_banner.tsx delete mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_tab.tsx create mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_tab_content.tsx create mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecation_errors.tsx create mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecations.tsx create mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/overview/es_stats.tsx create mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/overview/es_stats_error.tsx delete mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/overview/steps.tsx delete mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/page_content.tsx delete mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/tabs.tsx create mode 100644 x-pack/plugins/upgrade_assistant/public/application/lib/breadcrumbs.ts create mode 100644 x-pack/plugins/upgrade_assistant/public/application/lib/es_deprecation_errors.ts diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index 01079bdf03d0c..535bd8f11236d 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -108,6 +108,7 @@ readonly links: { }; readonly addData: string; readonly kibana: string; + readonly upgradeAssistant: string; readonly elasticsearch: Record; readonly siem: { readonly guide: string; diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 1bff91f15a150..4220d3e490f63 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -130,6 +130,7 @@ export class DocLinksService { }, addData: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/connect-to-elasticsearch.html`, kibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index.html`, + upgradeAssistant: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/upgrade-assistant.html`, elasticsearch: { docsBase: `${ELASTICSEARCH_DOCS}`, asyncSearch: `${ELASTICSEARCH_DOCS}async-search-intro.html`, @@ -181,6 +182,7 @@ export class DocLinksService { scriptParameters: `${ELASTICSEARCH_DOCS}modules-scripting-using.html#prefer-params`, transportSettings: `${ELASTICSEARCH_DOCS}modules-transport.html`, typesRemoval: `${ELASTICSEARCH_DOCS}removal-of-types.html`, + deprecationLogging: `${ELASTICSEARCH_DOCS}logging.html#deprecation-logging`, }, siem: { guide: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/index.html`, @@ -495,6 +497,7 @@ export interface DocLinksStart { }; readonly addData: string; readonly kibana: string; + readonly upgradeAssistant: string; readonly elasticsearch: Record; readonly siem: { readonly guide: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 88e4b0448a7be..661ac51c4983c 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -590,6 +590,7 @@ export interface DocLinksStart { }; readonly addData: string; readonly kibana: string; + readonly upgradeAssistant: string; readonly elasticsearch: Record; readonly siem: { readonly guide: string; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4ec86a71dcb2a..933bf512bdda0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -22593,14 +22593,9 @@ "xpack.uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatErrorMessage": "無効な形式:{message}", "xpack.uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatGeneralErrorMessage": "無効なフォーマット。例:{exampleUrl}", "xpack.upgradeAssistant.appTitle": "{version} アップグレードアシスタント", - "xpack.upgradeAssistant.checkupTab.backUpCallout.calloutBody.calloutDetail": "{snapshotRestoreDocsButton} でデータをバックアップします。", - "xpack.upgradeAssistant.checkupTab.backUpCallout.calloutBody.snapshotRestoreDocsButtonLabel": "API のスナップショットと復元", - "xpack.upgradeAssistant.checkupTab.backUpCallout.calloutTitle": "今すぐインデックをバックアップ", "xpack.upgradeAssistant.checkupTab.changeFiltersShowMoreLabel": "より多く表示させるにはフィルターを変更します。", - "xpack.upgradeAssistant.checkupTab.clusterTabLabel": "クラスター", "xpack.upgradeAssistant.checkupTab.controls.collapseAllButtonLabel": "すべて縮小", "xpack.upgradeAssistant.checkupTab.controls.expandAllButtonLabel": "すべて拡張", - "xpack.upgradeAssistant.checkupTab.controls.filterBar.allButtonLabel": "すべて", "xpack.upgradeAssistant.checkupTab.controls.filterBar.criticalButtonLabel": "致命的", "xpack.upgradeAssistant.checkupTab.controls.filterErrorMessageLabel": "フィルター無効:{searchTermError}", "xpack.upgradeAssistant.checkupTab.controls.groupByBar.byIndexLabel": "インデックス別", @@ -22615,12 +22610,9 @@ "xpack.upgradeAssistant.checkupTab.deprecations.indexTable.indexColumnLabel": "インデックス", "xpack.upgradeAssistant.checkupTab.deprecations.warningActionTooltip": "アップグレード前にこの問題を解決することをお勧めしますが、必須ではありません。", "xpack.upgradeAssistant.checkupTab.deprecations.warningLabel": "警告", - "xpack.upgradeAssistant.checkupTab.indexLabel": "インデックス", - "xpack.upgradeAssistant.checkupTab.indicesTabLabel": "インデックス", "xpack.upgradeAssistant.checkupTab.noDeprecationsLabel": "説明がありません", "xpack.upgradeAssistant.checkupTab.noIssues.nextStepsDetail": "{overviewTabButton} で次のステップを確認してください。", "xpack.upgradeAssistant.checkupTab.noIssues.nextStepsDetail.overviewTabButtonLabel": "概要タブ", - "xpack.upgradeAssistant.checkupTab.noIssues.noIssuesLabel": "{strongCheckupLabel} の問題がありません。", "xpack.upgradeAssistant.checkupTab.noIssues.noIssuesTitle": "完璧です!", "xpack.upgradeAssistant.checkupTab.numDeprecationsShownLabel": "{total} 件中 {numShown} 件を表示中", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.cancelButtonLabel": "キャンセル", @@ -22663,45 +22655,12 @@ "xpack.upgradeAssistant.checkupTab.reindexing.reindexButton.loadingLabel": "読み込み中…", "xpack.upgradeAssistant.checkupTab.reindexing.reindexButton.pausedLabel": "一時停止中", "xpack.upgradeAssistant.checkupTab.reindexing.reindexButton.reindexLabel": "再インデックス", - "xpack.upgradeAssistant.checkupTab.tabDetail": "これらの {strongCheckupLabel} 問題に対応する必要があります。Elasticsearch {nextEsVersion} へのアップグレード前に解決してください。", - "xpack.upgradeAssistant.forbiddenErrorCallout.calloutTitle": "このページを表示するための権限がありません。", - "xpack.upgradeAssistant.genericErrorCallout.calloutTitle": "チェックアップの結果を取得中にエラーが発生しました。", - "xpack.upgradeAssistant.overviewTab.overviewTabTitle": "概要", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.issuesRemainingStepTitle": "クラスターの問題を確認してください", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.noIssuesRemainingStepTitle": "クラスターの設定は準備完了です", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.noRemainingIssuesLabel": "廃止された設定は残っていません。", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.remainingIssuesDetail": "{numIssues} 件の問題が解決されました。", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.todo.clusterTabButtonLabel": "クラスタータブ", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.todo.todoDetail": "{clusterTabButton} に移動して廃止された設定を更新してください。", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.deprecationLogs.deprecationLogsDocButtonLabel": "廃止ログ", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.deprecationLogs.logsDetail": "{deprecationLogsDocButton} で、アプリケーションが {nextEsVersion} で利用できない機能を使用していないか確認してください。廃止ログを有効にする必要があるかもしれません。", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingLabel": "廃止ログを有効にしますか?", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.disabledLabel": "オフ", "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.enabledLabel": "オン", "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.errorLabel": "ログステータスを読み込めませんでした", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.stepTitle": "Elasticsearch の廃止ログを確認してください", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.issuesRemainingStepTitle": "インデックスの問題を確認してください", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.noIssuesRemainingStepTitle": "インデックスの設定は準備完了です", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.noRemainingIssuesLabel": "廃止された設定は残っていません。", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.remainingIssuesDetail": "{numIssues} 件の問題が解決されました。", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.todo.indicesTabButtonLabel": "インデックスタブ", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.todo.todoDetail": "{indicesTabButton} に移動して廃止された設定を更新してください。", - "xpack.upgradeAssistant.overviewTab.steps.startUpgradeStep.stepTitle": "アップグレード開始", - "xpack.upgradeAssistant.overviewTab.steps.startUpgradeStepCloud.stepDetail.goToCloudDashboardDetail": "Elastic Cloud ダッシュボードのデプロイセクションに移動し、アップグレードを開始します。", - "xpack.upgradeAssistant.overviewTab.steps.startUpgradeStepOnPrem.stepDetail.followInstructionsDetail": "{instructionButton} に従い、アップグレードを開始します。", - "xpack.upgradeAssistant.overviewTab.steps.startUpgradeStepOnPrem.stepDetail.instructionButtonLabel": "これらの手順", - "xpack.upgradeAssistant.overviewTab.steps.waitForReleaseStep.stepDetail": "リリースされ次第最新の {currentEsMajorVersion} バージョンにアップグレードし、ここに戻って {nextEsMajorVersion} へのアップグレードを行ってください。", - "xpack.upgradeAssistant.overviewTab.steps.waitForReleaseStep.stepTitle": "Elasticsearch {nextEsVersion} のリリース待ち", - "xpack.upgradeAssistant.overviewTab.tabDetail": "このアシスタントは、クラスターとインデックスの Elasticsearch への準備に役立ちます {nextEsVersion} 対処が必要な他の問題に関しては、Elasticsearch のログをご覧ください。", "xpack.upgradeAssistant.reindex.reindexPrivilegesErrorBatch": "「{indexName}」に再インデックスするための権限が不十分です。", - "xpack.upgradeAssistant.tabs.checkupTab.clusterLabel": "クラスター", "xpack.upgradeAssistant.tabs.incompleteCallout.calloutBody.breackingChangesDocButtonLabel": "廃止と互換性を破る変更", "xpack.upgradeAssistant.tabs.incompleteCallout.calloutBody.calloutDetail": "Elasticsearch {nextEsVersion} の {breakingChangesDocButton} の完全なリストは、最終の {currentEsVersion} マイナーリリースで確認できます。この警告は、リストがすべて解決されると消えます。", "xpack.upgradeAssistant.tabs.incompleteCallout.calloutTitle": "リストの問題がすべて解決されていない可能性があります。", - "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradeCompleteDescription": "すべての Elasticsearch ノードがアップグレードされました。Kibana をアップデートする準備ができました。", - "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradeCompleteTitle": "クラスターがアップグレードされました", - "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradingDescription": "1 つまたは複数の Elasticsearch ノードに、 Kibana よりも新しいバージョンの Elasticsearch があります。すべてのノードがアップグレードされた後で Kibana をアップグレードしてください。", - "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradingTitle": "クラスターをアップグレード中です", "xpack.uptime.addDataButtonLabel": "データの追加", "xpack.uptime.alerts.anomaly.criteriaExpression.ariaLabel": "選択したモニターの条件を表示する式。", "xpack.uptime.alerts.anomaly.criteriaExpression.description": "監視するとき", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 97317818f10cb..917c68913d462 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -22950,14 +22950,9 @@ "xpack.uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatErrorMessage": "格式无效:{message}", "xpack.uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatGeneralErrorMessage": "格式无效。例如:{exampleUrl}", "xpack.upgradeAssistant.appTitle": "{version} 升级助手", - "xpack.upgradeAssistant.checkupTab.backUpCallout.calloutBody.calloutDetail": "使用 {snapshotRestoreDocsButton} 备份您的数据。", - "xpack.upgradeAssistant.checkupTab.backUpCallout.calloutBody.snapshotRestoreDocsButtonLabel": "快照和还原 API", - "xpack.upgradeAssistant.checkupTab.backUpCallout.calloutTitle": "立即备份索引", "xpack.upgradeAssistant.checkupTab.changeFiltersShowMoreLabel": "更改筛选以显示更多内容。", - "xpack.upgradeAssistant.checkupTab.clusterTabLabel": "集群", "xpack.upgradeAssistant.checkupTab.controls.collapseAllButtonLabel": "折叠全部", "xpack.upgradeAssistant.checkupTab.controls.expandAllButtonLabel": "展开全部", - "xpack.upgradeAssistant.checkupTab.controls.filterBar.allButtonLabel": "全部", "xpack.upgradeAssistant.checkupTab.controls.filterBar.criticalButtonLabel": "紧急", "xpack.upgradeAssistant.checkupTab.controls.filterErrorMessageLabel": "筛选无效:{searchTermError}", "xpack.upgradeAssistant.checkupTab.controls.groupByBar.byIndexLabel": "按索引", @@ -22972,13 +22967,10 @@ "xpack.upgradeAssistant.checkupTab.deprecations.indexTable.indexColumnLabel": "索引", "xpack.upgradeAssistant.checkupTab.deprecations.warningActionTooltip": "建议在升级之前先解决此问题,但这不是必需的。", "xpack.upgradeAssistant.checkupTab.deprecations.warningLabel": "警告", - "xpack.upgradeAssistant.checkupTab.indexLabel": "索引", "xpack.upgradeAssistant.checkupTab.indicesBadgeLabel": "{numIndices, plural, other { 个索引}}", - "xpack.upgradeAssistant.checkupTab.indicesTabLabel": "索引", "xpack.upgradeAssistant.checkupTab.noDeprecationsLabel": "无弃用内容", "xpack.upgradeAssistant.checkupTab.noIssues.nextStepsDetail": "选中 {overviewTabButton} 以执行后续步骤。", "xpack.upgradeAssistant.checkupTab.noIssues.nextStepsDetail.overviewTabButtonLabel": "“概述”选项卡", - "xpack.upgradeAssistant.checkupTab.noIssues.noIssuesLabel": "您没有 {strongCheckupLabel} 问题。", "xpack.upgradeAssistant.checkupTab.noIssues.noIssuesTitle": "全部清除!", "xpack.upgradeAssistant.checkupTab.numDeprecationsShownLabel": "显示 {numShown} 个,共 {total} 个", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.cancelButtonLabel": "取消", @@ -23021,45 +23013,12 @@ "xpack.upgradeAssistant.checkupTab.reindexing.reindexButton.loadingLabel": "正在加载……", "xpack.upgradeAssistant.checkupTab.reindexing.reindexButton.pausedLabel": "已暂停", "xpack.upgradeAssistant.checkupTab.reindexing.reindexButton.reindexLabel": "重新索引", - "xpack.upgradeAssistant.checkupTab.tabDetail": "您需要注意这些 {strongCheckupLabel} 问题。在升级到 Elasticsearch {nextEsVersion} 之前先解决它们。", - "xpack.upgradeAssistant.forbiddenErrorCallout.calloutTitle": "您没有足够的权限来查看此页。", - "xpack.upgradeAssistant.genericErrorCallout.calloutTitle": "检索检查结果时出错。", - "xpack.upgradeAssistant.overviewTab.overviewTabTitle": "概览", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.issuesRemainingStepTitle": "检查集群是否存在问题", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.noIssuesRemainingStepTitle": "您的集群设置已就绪", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.noRemainingIssuesLabel": "没有其余已弃用设置。", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.remainingIssuesDetail": "必须解决 {numIssues} 个问题。", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.todo.clusterTabButtonLabel": "“集群”选项卡", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.todo.todoDetail": "转到 {clusterTabButton} 并更新已弃用的设置。", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.deprecationLogs.deprecationLogsDocButtonLabel": "弃用日志", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.deprecationLogs.logsDetail": "请参阅{deprecationLogsDocButton},了解您的应用程序是否使用未在 {nextEsVersion} 中提供的功能。您可能需要启用弃用日志。", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingLabel": "是否启用弃用日志?", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.disabledLabel": "关闭", "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.enabledLabel": "开启", "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.errorLabel": "无法加载日志状态", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.stepTitle": "查看 Elasticsearch 弃用日志", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.issuesRemainingStepTitle": "检查索引是否存在问题", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.noIssuesRemainingStepTitle": "您的索引设置已就绪", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.noRemainingIssuesLabel": "没有其余已弃用设置。", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.remainingIssuesDetail": "必须解决 {numIssues} 个问题。", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.todo.indicesTabButtonLabel": "“索引”选项卡", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.todo.todoDetail": "转到 {indicesTabButton} 并更新已弃用的设置。", - "xpack.upgradeAssistant.overviewTab.steps.startUpgradeStep.stepTitle": "开始升级", - "xpack.upgradeAssistant.overviewTab.steps.startUpgradeStepCloud.stepDetail.goToCloudDashboardDetail": "转到 Elastic Cloud 仪表板上的“部署”部分开始升级。", - "xpack.upgradeAssistant.overviewTab.steps.startUpgradeStepOnPrem.stepDetail.followInstructionsDetail": "按照 {instructionButton} 开始升级。", - "xpack.upgradeAssistant.overviewTab.steps.startUpgradeStepOnPrem.stepDetail.instructionButtonLabel": "以下说明", - "xpack.upgradeAssistant.overviewTab.steps.waitForReleaseStep.stepDetail": "版本发布后,请升级到最新的 {currentEsMajorVersion} 版本,然后返回此处,继续升级到 {nextEsMajorVersion}。", - "xpack.upgradeAssistant.overviewTab.steps.waitForReleaseStep.stepTitle": "等待 Elasticsearch {nextEsVersion} 发布版", - "xpack.upgradeAssistant.overviewTab.tabDetail": "此助理将帮助您为 Elasticsearch {nextEsVersion} 准备集群和索引。有关需要注意的其他问题,请参阅 Elasticsearch 日志。", "xpack.upgradeAssistant.reindex.reindexPrivilegesErrorBatch": "您没有足够的权限重新索引“{indexName}”。", - "xpack.upgradeAssistant.tabs.checkupTab.clusterLabel": "集群", "xpack.upgradeAssistant.tabs.incompleteCallout.calloutBody.breackingChangesDocButtonLabel": "弃用内容和重大更改", "xpack.upgradeAssistant.tabs.incompleteCallout.calloutBody.calloutDetail": "Elasticsearch {nextEsVersion} 中的 {breakingChangesDocButton} 完整列表将在最终的 {currentEsVersion} 次要版本中提供。完成列表后,此警告将消失。", "xpack.upgradeAssistant.tabs.incompleteCallout.calloutTitle": "问题列表可能不完整", - "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradeCompleteDescription": "所有 Elasticsearch 节点已升级。可以现在升级 Kibana。", - "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradeCompleteTitle": "您的集群已升级", - "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradingDescription": "一个或多个 Elasticsearch 节点的 Elasticsearch 版本比 Kibana 版本新。所有节点升级后,请升级 Kibana。", - "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradingTitle": "您的集群正在升级", "xpack.uptime.addDataButtonLabel": "添加数据", "xpack.uptime.alerts.anomaly.criteriaExpression.ariaLabel": "显示选定监测的条件的表达式。", "xpack.uptime.alerts.anomaly.criteriaExpression.description": "当监测", diff --git a/x-pack/plugins/upgrade_assistant/kibana.json b/x-pack/plugins/upgrade_assistant/kibana.json index eda624dc42246..d9f4917fa0a6c 100644 --- a/x-pack/plugins/upgrade_assistant/kibana.json +++ b/x-pack/plugins/upgrade_assistant/kibana.json @@ -6,5 +6,5 @@ "configPath": ["xpack", "upgrade_assistant"], "requiredPlugins": ["management", "licensing", "features"], "optionalPlugins": ["cloud", "usageCollection"], - "requiredBundles": ["esUiShared"] + "requiredBundles": ["esUiShared", "kibanaReact"] } diff --git a/x-pack/plugins/upgrade_assistant/public/application/app.tsx b/x-pack/plugins/upgrade_assistant/public/application/app.tsx index 1276198a528df..7be723e335e8b 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/app.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/app.tsx @@ -6,19 +6,48 @@ */ import React from 'react'; -import { I18nStart } from 'src/core/public'; -import { AppContextProvider, ContextValue } from './app_context'; -import { PageContent } from './components/page_content'; +import { Router, Switch, Route, Redirect } from 'react-router-dom'; +import { I18nStart, ScopedHistory } from 'src/core/public'; +import { AppContextProvider, ContextValue, useAppContext } from './app_context'; +import { ComingSoonPrompt } from './components/coming_soon_prompt'; +import { EsDeprecationsContent } from './components/es_deprecations'; +import { DeprecationsOverview } from './components/overview'; export interface AppDependencies extends ContextValue { i18n: I18nStart; + history: ScopedHistory; } -export const RootComponent = ({ i18n, ...contextValue }: AppDependencies) => { +const App: React.FunctionComponent = () => { + const { isReadOnlyMode } = useAppContext(); + + // Read-only mode will be enabled up until the last minor before the next major release + if (isReadOnlyMode) { + return ; + } + + return ( + + + + + + ); +}; + +export const AppWithRouter = ({ history }: { history: ScopedHistory }) => { + return ( + + + + ); +}; + +export const RootComponent = ({ i18n, history, ...contextValue }: AppDependencies) => { return ( - + ); diff --git a/x-pack/plugins/upgrade_assistant/public/application/app_context.tsx b/x-pack/plugins/upgrade_assistant/public/application/app_context.tsx index 2b49d1a5bca1f..18df47d4cbd4a 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/app_context.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/app_context.tsx @@ -5,9 +5,10 @@ * 2.0. */ -import { DocLinksStart, HttpSetup, NotificationsStart } from 'src/core/public'; +import { CoreStart, DocLinksStart, HttpSetup, NotificationsStart } from 'src/core/public'; import React, { createContext, useContext } from 'react'; import { ApiService } from './lib/api'; +import { BreadcrumbService } from './lib/breadcrumbs'; export interface KibanaVersionContext { currentMajor: number; @@ -23,6 +24,8 @@ export interface ContextValue { notifications: NotificationsStart; isReadOnlyMode: boolean; api: ApiService; + breadcrumbs: BreadcrumbService; + getUrlForApp: CoreStart['application']['getUrlForApp']; } export const AppContext = createContext({} as any); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/error_banner.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/error_banner.tsx deleted file mode 100644 index 72e6c5c0702af..0000000000000 --- a/x-pack/plugins/upgrade_assistant/public/application/components/error_banner.tsx +++ /dev/null @@ -1,49 +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 React from 'react'; - -import { EuiCallOut } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { UpgradeAssistantTabProps } from './types'; - -export const LoadingErrorBanner: React.FunctionComponent< - Pick -> = ({ loadingError }) => { - if (loadingError?.statusCode === 403) { - return ( - - } - color="danger" - iconType="cross" - data-test-subj="permissionsError" - /> - ); - } - - return ( - - } - color="danger" - iconType="cross" - data-test-subj="upgradeStatusError" - > - {loadingError ? loadingError.message : null} - - ); -}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/__snapshots__/filter_bar.test.tsx.snap b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/__snapshots__/filter_bar.test.tsx.snap index da9153f4a6c8d..b88886b364165 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/__snapshots__/filter_bar.test.tsx.snap +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/__snapshots__/filter_bar.test.tsx.snap @@ -6,20 +6,22 @@ exports[`FilterBar renders 1`] = ` > - all + critical - critical + warning diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_tab.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_tab.tsx deleted file mode 100644 index a5ae341f1e424..0000000000000 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_tab.tsx +++ /dev/null @@ -1,222 +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 { find } from 'lodash'; -import React, { FunctionComponent, useState } from 'react'; - -import { - EuiCallOut, - EuiEmptyPrompt, - EuiLink, - EuiPageContent, - EuiPageContentBody, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { LoadingErrorBanner } from '../error_banner'; -import { useAppContext } from '../../app_context'; -import { GroupByOption, LevelFilterOption, UpgradeAssistantTabProps } from '../types'; -import { CheckupControls } from './controls'; -import { GroupedDeprecations } from './deprecations/grouped'; - -export interface CheckupTabProps extends UpgradeAssistantTabProps { - checkupLabel: string; - showBackupWarning?: boolean; -} - -/** - * Displays a list of deprecations that filterable and groupable. Can be used for cluster, - * nodes, or indices checkups. - */ -export const DeprecationTab: FunctionComponent = ({ - alertBanner, - checkupLabel, - deprecations, - loadingError, - isLoading, - refreshCheckupData, - setSelectedTabIndex, - showBackupWarning = false, -}) => { - const [currentFilter, setCurrentFilter] = useState(LevelFilterOption.all); - const [search, setSearch] = useState(''); - const [currentGroupBy, setCurrentGroupBy] = useState(GroupByOption.message); - - const { docLinks, kibanaVersionInfo } = useAppContext(); - - const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks; - const esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; - - const { nextMajor } = kibanaVersionInfo; - - const changeFilter = (filter: LevelFilterOption) => { - setCurrentFilter(filter); - }; - - const changeSearch = (newSearch: string) => { - setSearch(newSearch); - }; - - const changeGroupBy = (groupBy: GroupByOption) => { - setCurrentGroupBy(groupBy); - }; - - const availableGroupByOptions = () => { - if (!deprecations) { - return []; - } - - return Object.keys(GroupByOption).filter((opt) => find(deprecations, opt)) as GroupByOption[]; - }; - - const renderCheckupData = () => { - return ( - - ); - }; - - return ( - <> - - -

- {checkupLabel}, - nextEsVersion: `${nextMajor}.x`, - }} - /> -

-
- - - - {alertBanner && ( - <> - {alertBanner} - - - )} - - {showBackupWarning && ( - <> - - } - color="warning" - iconType="help" - > -

- - - - ), - }} - /> -

-
- - - )} - - - - {loadingError ? ( - - ) : deprecations && deprecations.length > 0 ? ( -
- - - {renderCheckupData()} -
- ) : ( - - -
- } - body={ - <> -

- {checkupLabel}, - }} - /> -

-

- setSelectedTabIndex(0)}> - - - ), - }} - /> -

- - } - /> - )} - - - - ); -}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_tab_content.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_tab_content.tsx new file mode 100644 index 0000000000000..9e8678fea0eb9 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_tab_content.tsx @@ -0,0 +1,138 @@ +/* + * 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 { find } from 'lodash'; +import React, { FunctionComponent, useState } from 'react'; + +import { EuiEmptyPrompt, EuiLink, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { SectionLoading } from '../../../shared_imports'; +import { GroupByOption, LevelFilterOption, UpgradeAssistantTabProps } from '../types'; +import { CheckupControls } from './controls'; +import { GroupedDeprecations } from './deprecations/grouped'; +import { EsDeprecationErrors } from './es_deprecation_errors'; + +const i18nTexts = { + isLoading: i18n.translate('xpack.upgradeAssistant.esDeprecations.loadingText', { + defaultMessage: 'Loading deprecations…', + }), +}; + +export interface CheckupTabProps extends UpgradeAssistantTabProps { + checkupLabel: string; +} + +/** + * Displays a list of deprecations that are filterable and groupable. Can be used for cluster, + * nodes, or indices deprecations. + */ +export const DeprecationTabContent: FunctionComponent = ({ + checkupLabel, + deprecations, + error, + isLoading, + refreshCheckupData, + navigateToOverviewPage, +}) => { + const [currentFilter, setCurrentFilter] = useState(LevelFilterOption.all); + const [search, setSearch] = useState(''); + const [currentGroupBy, setCurrentGroupBy] = useState(GroupByOption.message); + + const availableGroupByOptions = () => { + if (!deprecations) { + return []; + } + + return Object.keys(GroupByOption).filter((opt) => find(deprecations, opt)) as GroupByOption[]; + }; + + if (deprecations && deprecations.length === 0) { + return ( + + +
+ } + body={ + <> +

+ +

+

+ + + + ), + }} + /> +

+ + } + /> + ); + } + + let content: React.ReactNode; + + if (isLoading) { + content = {i18nTexts.isLoading}; + } else if (deprecations?.length) { + content = ( +
+ + + + + +
+ ); + } else if (error) { + content = ; + } + + return ( +
+ + + {content} +
+ ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecation_errors.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecation_errors.tsx new file mode 100644 index 0000000000000..239433808c5af --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecation_errors.tsx @@ -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 React from 'react'; + +import { EuiCallOut } from '@elastic/eui'; + +import { ResponseError } from '../../lib/api'; +import { getEsDeprecationError } from '../../lib/es_deprecation_errors'; +interface Props { + error: ResponseError; +} + +export const EsDeprecationErrors: React.FunctionComponent = ({ error }) => { + const { code: errorType, message } = getEsDeprecationError(error); + + switch (errorType) { + case 'unauthorized_error': + return ( + + ); + case 'partially_upgraded_error': + return ( + + ); + case 'upgraded_error': + return ; + case 'request_error': + default: + return ( + + {error.message} + + ); + } +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecations.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecations.tsx new file mode 100644 index 0000000000000..0da4a4877a7ec --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecations.tsx @@ -0,0 +1,212 @@ +/* + * 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, useEffect, useState } from 'react'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; + +import { + EuiButton, + EuiButtonEmpty, + EuiPageBody, + EuiPageHeader, + EuiTabbedContent, + EuiTabbedContentTab, + EuiPageContent, + EuiPageContentBody, + EuiToolTip, + EuiNotificationBadge, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { useAppContext } from '../../app_context'; +import { UpgradeAssistantTabProps, EsTabs, TelemetryState } from '../types'; +import { DeprecationTabContent } from './deprecation_tab_content'; + +const i18nTexts = { + pageTitle: i18n.translate('xpack.upgradeAssistant.esDeprecations.pageTitle', { + defaultMessage: 'Elasticsearch', + }), + pageDescription: i18n.translate('xpack.upgradeAssistant.esDeprecations.pageDescription', { + defaultMessage: + 'Review the deprecated cluster and index settings. You must resolve any critical issues before upgrading.', + }), + docLinkText: i18n.translate('xpack.upgradeAssistant.esDeprecations.docLinkText', { + defaultMessage: 'Documentation', + }), + backupDataButton: { + label: i18n.translate('xpack.upgradeAssistant.esDeprecations.backupDataButtonLabel', { + defaultMessage: 'Back up your data', + }), + tooltipText: i18n.translate('xpack.upgradeAssistant.esDeprecations.backupDataTooltipText', { + defaultMessage: 'Take a snapshot before you make any changes.', + }), + }, + clusterTab: { + tabName: i18n.translate('xpack.upgradeAssistant.esDeprecations.clusterTabLabel', { + defaultMessage: 'Cluster', + }), + deprecationType: i18n.translate('xpack.upgradeAssistant.esDeprecations.clusterLabel', { + defaultMessage: 'cluster', + }), + }, + indicesTab: { + tabName: i18n.translate('xpack.upgradeAssistant.esDeprecations.indicesTabLabel', { + defaultMessage: 'Indices', + }), + deprecationType: i18n.translate('xpack.upgradeAssistant.esDeprecations.indexLabel', { + defaultMessage: 'index', + }), + }, +}; + +interface MatchParams { + tabName: EsTabs; +} + +export const EsDeprecationsContent = withRouter( + ({ + match: { + params: { tabName }, + }, + history, + }: RouteComponentProps) => { + const [telemetryState, setTelemetryState] = useState(TelemetryState.Complete); + + const { api, breadcrumbs, getUrlForApp, docLinks } = useAppContext(); + + const { data: checkupData, isLoading, error, resendRequest } = api.useLoadUpgradeStatus(); + + const onTabClick = (selectedTab: EuiTabbedContentTab) => { + history.push(`/es_deprecations/${selectedTab.id}`); + }; + + const tabs = useMemo(() => { + const commonTabProps: UpgradeAssistantTabProps = { + error, + isLoading, + refreshCheckupData: resendRequest, + navigateToOverviewPage: () => history.push('/overview'), + }; + + return [ + { + id: 'cluster', + 'data-test-subj': 'upgradeAssistantClusterTab', + name: ( + + {i18nTexts.clusterTab.tabName} + {checkupData && checkupData.cluster.length > 0 && ( + <> + {' '} + {checkupData.cluster.length} + + )} + + ), + content: ( + + ), + }, + { + id: 'indices', + 'data-test-subj': 'upgradeAssistantIndicesTab', + name: ( + + {i18nTexts.indicesTab.tabName} + {checkupData && checkupData.indices.length > 0 && ( + <> + {' '} + {checkupData.indices.length} + + )} + + ), + content: ( + + ), + }, + ]; + }, [checkupData, error, history, isLoading, resendRequest]); + + useEffect(() => { + breadcrumbs.setBreadcrumbs('esDeprecations'); + }, [breadcrumbs]); + + useEffect(() => { + if (isLoading === false) { + setTelemetryState(TelemetryState.Running); + + async function sendTelemetryData() { + await api.sendTelemetryData({ + [tabName]: true, + }); + setTelemetryState(TelemetryState.Complete); + } + + sendTelemetryData(); + } + }, [api, tabName, isLoading]); + + return ( + + + + {i18nTexts.docLinkText} + , + ]} + > + + + {i18nTexts.backupDataButton.label} + + + + + + tab.id === tabName)} + /> + + + + ); + } +); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/filter_bar.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/filter_bar.test.tsx index feac88cf4a525..4888efda97bd0 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/filter_bar.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/filter_bar.test.tsx @@ -17,7 +17,7 @@ const defaultProps = { { level: LevelFilterOption.critical }, { level: LevelFilterOption.critical }, ] as DeprecationInfo[], - currentFilter: LevelFilterOption.critical, + currentFilter: LevelFilterOption.all, onFilterChange: jest.fn(), }; @@ -28,7 +28,7 @@ describe('FilterBar', () => { test('clicking button calls onFilterChange', () => { const wrapper = mount(); - wrapper.find('button.euiFilterButton-hasActiveFilters').simulate('click'); + wrapper.find('button[data-test-subj="criticalLevelFilter"]').simulate('click'); expect(defaultProps.onFilterChange).toHaveBeenCalledTimes(1); expect(defaultProps.onFilterChange.mock.calls[0][0]).toEqual(LevelFilterOption.critical); }); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/filter_bar.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/filter_bar.tsx index 7ef3ae2fc9332..848ac3b14a817 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/filter_bar.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/filter_bar.tsx @@ -15,17 +15,18 @@ import { DeprecationInfo } from '../../../../common/types'; import { LevelFilterOption } from '../types'; const LocalizedOptions: { [option: string]: string } = { - all: i18n.translate('xpack.upgradeAssistant.checkupTab.controls.filterBar.allButtonLabel', { - defaultMessage: 'all', - }), + warning: i18n.translate( + 'xpack.upgradeAssistant.checkupTab.controls.filterBar.warningButtonLabel', + { + defaultMessage: 'warning', + } + ), critical: i18n.translate( 'xpack.upgradeAssistant.checkupTab.controls.filterBar.criticalButtonLabel', { defaultMessage: 'critical' } ), }; -const allFilterOptions = Object.keys(LevelFilterOption) as LevelFilterOption[]; - interface FilterBarProps { allDeprecations?: DeprecationInfo[]; currentFilter: LevelFilterOption; @@ -43,23 +44,40 @@ export const FilterBar: React.FunctionComponent = ({ return counts; }, {} as { [level: string]: number }); - const allCount = allDeprecations.length; - return ( - {allFilterOptions.map((option) => ( - - {LocalizedOptions[option]} - - ))} + { + onFilterChange( + currentFilter !== LevelFilterOption.critical + ? LevelFilterOption.critical + : LevelFilterOption.all + ); + }} + hasActiveFilters={currentFilter === LevelFilterOption.critical} + numFilters={levelCounts[LevelFilterOption.critical] || undefined} + data-test-subj="criticalLevelFilter" + > + {LocalizedOptions[LevelFilterOption.critical]} + + { + onFilterChange( + currentFilter !== LevelFilterOption.warning + ? LevelFilterOption.warning + : LevelFilterOption.all + ); + }} + hasActiveFilters={currentFilter === LevelFilterOption.warning} + numFilters={levelCounts[LevelFilterOption.warning] || undefined} + data-test-subj="warningLevelFilter" + > + {LocalizedOptions[LevelFilterOption.warning]} + ); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/index.ts b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/index.ts index 8b7435b94b2c1..0e69259adc609 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/index.ts +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { DeprecationTab } from './deprecation_tab'; +export { EsDeprecationsContent } from './es_deprecations'; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/deprecation_logging_toggle.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/deprecation_logging_toggle.tsx index 5ed46c25ecf17..6be7793f0bd4a 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/deprecation_logging_toggle.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/deprecation_logging_toggle.tsx @@ -13,8 +13,35 @@ import { i18n } from '@kbn/i18n'; import { useAppContext } from '../../app_context'; import { ResponseError } from '../../lib/api'; +const i18nTexts = { + toggleErrorLabel: i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.errorLabel', + { + defaultMessage: 'Could not load logging state', + } + ), + toggleLabel: i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.enabledLabel', + { + defaultMessage: 'Enable deprecation logging', + } + ), + enabledMessage: i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.enabledToastMessage', + { + defaultMessage: 'Log deprecated actions.', + } + ), + disabledMessage: i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.disabledToastMessage', + { + defaultMessage: 'Do not log deprecated actions.', + } + ), +}; + export const DeprecationLoggingToggle: React.FunctionComponent = () => { - const { api } = useAppContext(); + const { api, notifications } = useAppContext(); const [isEnabled, setIsEnabled] = useState(true); const [isLoading, setIsLoading] = useState(false); @@ -44,27 +71,10 @@ export const DeprecationLoggingToggle: React.FunctionComponent = () => { const renderLoggingState = () => { if (error) { - return i18n.translate( - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.errorLabel', - { - defaultMessage: 'Could not load logging state', - } - ); - } else if (isEnabled) { - return i18n.translate( - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.enabledLabel', - { - defaultMessage: 'On', - } - ); - } else { - return i18n.translate( - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.disabledLabel', - { - defaultMessage: 'Off', - } - ); + return i18nTexts.toggleErrorLabel; } + + return i18nTexts.toggleLabel; }; const toggleLogging = async () => { @@ -82,6 +92,9 @@ export const DeprecationLoggingToggle: React.FunctionComponent = () => { setError(updateError); } else if (data) { setIsEnabled(data.isEnabled); + notifications.toasts.addSuccess( + data.isEnabled ? i18nTexts.enabledMessage : i18nTexts.disabledMessage + ); } }; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/es_stats.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/es_stats.tsx new file mode 100644 index 0000000000000..51a66bdd35395 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/es_stats.tsx @@ -0,0 +1,129 @@ +/* + * 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, { FunctionComponent } from 'react'; + +import { + EuiLink, + EuiPanel, + EuiStat, + EuiTitle, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { RouteComponentProps } from 'react-router-dom'; +import { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_react/public'; +import { useAppContext } from '../../app_context'; +import { EsStatsErrors } from './es_stats_error'; + +const i18nTexts = { + statsTitle: i18n.translate('xpack.upgradeAssistant.esDeprecationStats.statsTitle', { + defaultMessage: 'Elasticsearch', + }), + totalDeprecationsTitle: i18n.translate( + 'xpack.upgradeAssistant.esDeprecationStats.totalDeprecationsTitle', + { + defaultMessage: 'Deprecations', + } + ), + criticalDeprecationsTitle: i18n.translate( + 'xpack.upgradeAssistant.esDeprecationStats.criticalDeprecationsTitle', + { + defaultMessage: 'Critical', + } + ), + viewDeprecationsLink: i18n.translate( + 'xpack.upgradeAssistant.esDeprecationStats.viewDeprecationsLinkText', + { + defaultMessage: 'View deprecations', + } + ), + getTotalDeprecationsTooltip: (clusterCount: number, indexCount: number) => + i18n.translate('xpack.upgradeAssistant.esDeprecationStats.totalDeprecationsTooltip', { + defaultMessage: + 'This cluster is using {clusterCount} deprecated cluster settings and {indexCount} deprecated index settings', + values: { + clusterCount, + indexCount, + }, + }), +}; + +interface Props { + history: RouteComponentProps['history']; +} + +export const ESDeprecationStats: FunctionComponent = ({ history }) => { + const { api } = useAppContext(); + + const { data: esDeprecations, isLoading, error } = api.useLoadUpgradeStatus(); + + const allDeprecations = esDeprecations?.cluster?.concat(esDeprecations?.indices) ?? []; + const criticalDeprecations = allDeprecations.filter( + (deprecation) => deprecation.level === 'critical' + ); + + return ( + + + + +

{i18nTexts.statsTitle}

+
+
+ + + {i18nTexts.viewDeprecationsLink} + + +
+ + + + + + + {i18nTexts.totalDeprecationsTitle}{' '} + + + } + isLoading={isLoading} + /> + + + + + {error && } + + + +
+ ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/es_stats_error.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/es_stats_error.tsx new file mode 100644 index 0000000000000..dda7d16599e0c --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/es_stats_error.tsx @@ -0,0 +1,84 @@ +/* + * 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 { EuiIconTip, EuiSpacer } from '@elastic/eui'; +import { ResponseError } from '../../lib/api'; +import { getEsDeprecationError } from '../../lib/es_deprecation_errors'; + +interface Props { + error: ResponseError; +} + +export const EsStatsErrors: React.FunctionComponent = ({ error }) => { + let iconContent: React.ReactNode; + + const { code: errorType, message } = getEsDeprecationError(error); + + switch (errorType) { + case 'unauthorized_error': + iconContent = ( + + ); + break; + case 'partially_upgraded_error': + iconContent = ( + + ); + break; + case 'upgraded_error': + iconContent = ( + + ); + break; + case 'request_error': + default: + iconContent = ( + + ); + } + + return ( + <> + + {iconContent} + + ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/index.ts b/x-pack/plugins/upgrade_assistant/public/application/components/overview/index.ts index c43c1415f6f7c..a64d7b0d44915 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/index.ts +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { OverviewTab } from './overview'; +export { DeprecationsOverview } from './overview'; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/overview.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/overview.tsx index 01677e7394a87..0784fbc102805 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/overview.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/overview.tsx @@ -5,70 +5,133 @@ * 2.0. */ -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, useEffect } from 'react'; import { - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, EuiPageContent, EuiPageContentBody, - EuiSpacer, EuiText, + EuiPageHeader, + EuiPageBody, + EuiButtonEmpty, + EuiFlexItem, + EuiFlexGroup, + EuiSpacer, + EuiLink, + EuiFormRow, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { RouteComponentProps } from 'react-router-dom'; import { useAppContext } from '../../app_context'; -import { LoadingErrorBanner } from '../error_banner'; -import { UpgradeAssistantTabProps } from '../types'; -import { Steps } from './steps'; +import { LatestMinorBanner } from '../latest_minor_banner'; +import { ESDeprecationStats } from './es_stats'; +import { DeprecationLoggingToggle } from './deprecation_logging_toggle'; -export const OverviewTab: FunctionComponent = (props) => { - const { kibanaVersionInfo } = useAppContext(); +const i18nTexts = { + pageTitle: i18n.translate('xpack.upgradeAssistant.pageTitle', { + defaultMessage: 'Upgrade Assistant', + }), + getPageDescription: (nextMajor: string) => + i18n.translate('xpack.upgradeAssistant.pageDescription', { + defaultMessage: + 'Prepare to upgrade by identifying deprecated settings and updating your configuration. Enable deprecation logging to see if your are using deprecated features that will not be available after you upgrade to Elastic {nextMajor}.', + values: { + nextMajor, + }, + }), + getDeprecationLoggingLabel: (href: string) => ( + + {i18n.translate('xpack.upgradeAssistant.deprecationLoggingDescription.learnMoreLink', { + defaultMessage: 'Learn more.', + })} + + ), + }} + /> + ), + docLink: i18n.translate('xpack.upgradeAssistant.documentationLinkText', { + defaultMessage: 'Documentation', + }), +}; + +interface Props { + history: RouteComponentProps['history']; +} + +export const DeprecationsOverview: FunctionComponent = ({ history }) => { + const { kibanaVersionInfo, breadcrumbs, docLinks, api } = useAppContext(); const { nextMajor } = kibanaVersionInfo; + useEffect(() => { + async function sendTelemetryData() { + await api.sendTelemetryData({ + overview: true, + }); + } + + sendTelemetryData(); + }, [api]); + + useEffect(() => { + breadcrumbs.setBreadcrumbs('overview'); + }, [breadcrumbs]); + return ( - <> - - - -

- -

-
- - - - {props.alertBanner && ( - <> - {props.alertBanner} - - - - )} - - + + + + {i18nTexts.docLink} + , + ]} + /> + - {props.isLoading && ( - - - - - - )} + <> + +

{i18nTexts.getPageDescription(`${nextMajor}.x`)}

+
+ + - {props.checkupData && } + {/* Remove this in last minor of the current major (e.g., 7.15) */} + - {props.loadingError && } + + + + + + + + + + + + + +
- +
); }; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/steps.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/steps.tsx deleted file mode 100644 index 095960ae93562..0000000000000 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/steps.tsx +++ /dev/null @@ -1,293 +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 React, { Fragment, FunctionComponent } from 'react'; - -import { - EuiFormRow, - EuiLink, - EuiNotificationBadge, - EuiSpacer, - // @ts-ignore - EuiStat, - EuiSteps, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { useAppContext } from '../../app_context'; -import { UpgradeAssistantTabProps } from '../types'; -import { DeprecationLoggingToggle } from './deprecation_logging_toggle'; - -// Leaving these here even if unused so they are picked up for i18n static analysis -// Keep this until last minor release (when next major is also released). -const WAIT_FOR_RELEASE_STEP = (majorVersion: number, nextMajorVersion: number) => ({ - title: i18n.translate('xpack.upgradeAssistant.overviewTab.steps.waitForReleaseStep.stepTitle', { - defaultMessage: 'Wait for the Elasticsearch {nextEsVersion} release', - values: { - nextEsVersion: `${nextMajorVersion}.0`, - }, - }), - 'data-test-subj': 'waitForReleaseStep', - children: ( - <> - -

- -

-
- - ), -}); - -// Swap in this step for the one above it on the last minor release. -// @ts-ignore -const START_UPGRADE_STEP = (isCloudEnabled: boolean, esDocBasePath: string) => ({ - title: i18n.translate('xpack.upgradeAssistant.overviewTab.steps.startUpgradeStep.stepTitle', { - defaultMessage: 'Start your upgrade', - }), - 'data-test-subj': 'startUpgradeStep', - children: ( - - -

- {isCloudEnabled ? ( - - ) : ( - - - - ), - }} - /> - )} -

-
-
- ), -}); - -export const Steps: FunctionComponent = ({ - checkupData, - setSelectedTabIndex, -}) => { - const checkupDataTyped = (checkupData! as unknown) as { [checkupType: string]: any[] }; - const countByType = Object.keys(checkupDataTyped).reduce((counts, checkupType) => { - counts[checkupType] = checkupDataTyped[checkupType].length; - return counts; - }, {} as { [checkupType: string]: number }); - - // Uncomment when START_UPGRADE_STEP is in use! - const { kibanaVersionInfo, docLinks /* , isCloudEnabled */ } = useAppContext(); - - const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks; - const esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; - - const { currentMajor, nextMajor } = kibanaVersionInfo; - - return ( - - {countByType.cluster ? ( - -

- setSelectedTabIndex(1)}> - - - ), - }} - /> -

-

- {countByType.cluster} - ), - }} - /> -

-
- ) : ( -

- -

- )} -
- ), - }, - { - title: countByType.indices - ? i18n.translate( - 'xpack.upgradeAssistant.overviewTab.steps.indicesStep.issuesRemainingStepTitle', - { - defaultMessage: 'Check for issues with your indices', - } - ) - : i18n.translate( - 'xpack.upgradeAssistant.overviewTab.steps.indicesStep.noIssuesRemainingStepTitle', - { - defaultMessage: 'Your index settings are ready', - } - ), - status: countByType.indices ? 'warning' : 'complete', - 'data-test-subj': 'indicesIssuesStep', - children: ( - - {countByType.indices ? ( - -

- setSelectedTabIndex(2)}> - - - ), - }} - /> -

-

- {countByType.indices} - ), - }} - /> -

-
- ) : ( -

- -

- )} -
- ), - }, - { - title: i18n.translate( - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.stepTitle', - { - defaultMessage: 'Review the Elasticsearch deprecation logs', - } - ), - 'data-test-subj': 'deprecationLoggingStep', - children: ( - - -

- - - - ), - nextEsVersion: `${nextMajor}.0`, - }} - /> -

-
- - - - - - -
- ), - }, - - // Swap in START_UPGRADE_STEP on the last minor release. - WAIT_FOR_RELEASE_STEP(currentMajor, nextMajor), - // START_UPGRADE_STEP(isCloudEnabled, esDocBasePath), - ]} - /> - ); -}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/page_content.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/page_content.tsx deleted file mode 100644 index db515f0c123a8..0000000000000 --- a/x-pack/plugins/upgrade_assistant/public/application/components/page_content.tsx +++ /dev/null @@ -1,44 +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 React from 'react'; -import { EuiPageHeader, EuiPageHeaderSection, EuiTitle } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { useAppContext } from '../app_context'; -import { ComingSoonPrompt } from './coming_soon_prompt'; -import { UpgradeAssistantTabs } from './tabs'; - -export const PageContent: React.FunctionComponent = () => { - const { kibanaVersionInfo, isReadOnlyMode } = useAppContext(); - const { nextMajor } = kibanaVersionInfo; - - // Read-only mode will be enabled up until the last minor before the next major release - if (isReadOnlyMode) { - return ; - } - - return ( - <> - - - -

- -

-
-
-
- - - - ); -}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs.tsx deleted file mode 100644 index 231d9705bd0d9..0000000000000 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs.tsx +++ /dev/null @@ -1,184 +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 { findIndex } from 'lodash'; -import React, { useEffect, useState, useMemo } from 'react'; - -import { - EuiEmptyPrompt, - EuiPageContent, - EuiPageContentBody, - EuiTabbedContent, - EuiTabbedContentTab, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { LatestMinorBanner } from './latest_minor_banner'; -import { DeprecationTab } from './es_deprecations'; -import { OverviewTab } from './overview'; -import { TelemetryState, UpgradeAssistantTabProps } from './types'; -import { useAppContext } from '../app_context'; - -export const UpgradeAssistantTabs: React.FunctionComponent = () => { - const [selectedTabIndex, setSelectedTabIndex] = useState(0); - const [telemetryState, setTelemetryState] = useState(TelemetryState.Complete); - - const { api } = useAppContext(); - - const { data: checkupData, isLoading, error, resendRequest } = api.useLoadUpgradeStatus(); - - const tabs = useMemo(() => { - const commonTabProps: UpgradeAssistantTabProps = { - loadingError: error, - isLoading, - refreshCheckupData: resendRequest, - setSelectedTabIndex, - // Remove this in last minor of the current major (e.g., 7.15) - alertBanner: , - }; - - return [ - { - id: 'overview', - 'data-test-subj': 'upgradeAssistantOverviewTab', - name: i18n.translate('xpack.upgradeAssistant.overviewTab.overviewTabTitle', { - defaultMessage: 'Overview', - }), - content: , - }, - { - id: 'cluster', - 'data-test-subj': 'upgradeAssistantClusterTab', - name: i18n.translate('xpack.upgradeAssistant.checkupTab.clusterTabLabel', { - defaultMessage: 'Cluster', - }), - content: ( - - ), - }, - { - id: 'indices', - 'data-test-subj': 'upgradeAssistantIndicesTab', - name: i18n.translate('xpack.upgradeAssistant.checkupTab.indicesTabLabel', { - defaultMessage: 'Indices', - }), - content: ( - - ), - }, - ]; - }, [checkupData, error, isLoading, resendRequest]); - - const tabName = tabs[selectedTabIndex].id; - - useEffect(() => { - if (isLoading === false) { - setTelemetryState(TelemetryState.Running); - - async function sendTelemetryData() { - await api.sendTelemetryData({ - [tabName]: true, - }); - setTelemetryState(TelemetryState.Complete); - } - - sendTelemetryData(); - } - }, [api, selectedTabIndex, tabName, isLoading]); - - const onTabClick = (selectedTab: EuiTabbedContentTab) => { - const newSelectedTabIndex = findIndex(tabs, { id: selectedTab.id }); - if (selectedTabIndex === -1) { - throw new Error('Clicked tab did not exist in tabs array'); - } - setSelectedTabIndex(newSelectedTabIndex); - }; - - if (error?.statusCode === 426 && error.attributes?.allNodesUpgraded === false) { - return ( - - - - -
- } - body={ -

- -

- } - /> - - - ); - } else if (error?.statusCode === 426 && error.attributes?.allNodesUpgraded === true) { - return ( - - - - - - } - body={ -

- -

- } - /> -
-
- ); - } - - return ( - - ); -}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/types.ts b/x-pack/plugins/upgrade_assistant/public/application/components/types.ts index 8be2fe3e0b0ab..d82b779110a89 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/types.ts +++ b/x-pack/plugins/upgrade_assistant/public/application/components/types.ts @@ -15,9 +15,9 @@ export interface UpgradeAssistantTabProps { checkupData?: UpgradeAssistantStatus | null; deprecations?: EnrichedDeprecationInfo[]; refreshCheckupData: () => void; - loadingError: ResponseError | null; + error: ResponseError | null; isLoading: boolean; - setSelectedTabIndex: (tabIndex: number) => void; + navigateToOverviewPage: () => void; } // eslint-disable-next-line react/prefer-stateless-function @@ -35,6 +35,7 @@ export enum LoadingState { export enum LevelFilterOption { all = 'all', critical = 'critical', + warning = 'warning', } export enum GroupByOption { @@ -47,3 +48,5 @@ export enum TelemetryState { Running, Complete, } + +export type EsTabs = 'cluster' | 'indices'; diff --git a/x-pack/plugins/upgrade_assistant/public/application/lib/breadcrumbs.ts b/x-pack/plugins/upgrade_assistant/public/application/lib/breadcrumbs.ts new file mode 100644 index 0000000000000..3f2ee4fa33657 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/lib/breadcrumbs.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { ManagementAppMountParams } from '../../../../../../src/plugins/management/public'; + +type SetBreadcrumbs = ManagementAppMountParams['setBreadcrumbs']; + +const i18nTexts = { + breadcrumbs: { + overview: i18n.translate('xpack.upgradeAssistant.breadcrumb.overviewLabel', { + defaultMessage: 'Upgrade Assistant', + }), + esDeprecations: i18n.translate('xpack.upgradeAssistant.breadcrumb.esDeprecationsLabel', { + defaultMessage: 'Elasticsearch deprecations', + }), + }, +}; + +export class BreadcrumbService { + private breadcrumbs: { + [key: string]: Array<{ + text: string; + href?: string; + }>; + } = { + overview: [ + { + text: i18nTexts.breadcrumbs.overview, + }, + ], + esDeprecations: [ + { + text: i18nTexts.breadcrumbs.overview, + href: '/', + }, + { + text: i18nTexts.breadcrumbs.esDeprecations, + }, + ], + }; + + private setBreadcrumbsHandler?: SetBreadcrumbs; + + public setup(setBreadcrumbsHandler: SetBreadcrumbs): void { + this.setBreadcrumbsHandler = setBreadcrumbsHandler; + } + + public setBreadcrumbs(type: 'overview' | 'esDeprecations'): void { + if (!this.setBreadcrumbsHandler) { + throw new Error('Breadcrumb service has not been initialized'); + } + + const newBreadcrumbs = this.breadcrumbs[type] + ? [...this.breadcrumbs[type]] + : [...this.breadcrumbs.home]; + + this.setBreadcrumbsHandler(newBreadcrumbs); + } +} + +export const breadcrumbService = new BreadcrumbService(); diff --git a/x-pack/plugins/upgrade_assistant/public/application/lib/es_deprecation_errors.ts b/x-pack/plugins/upgrade_assistant/public/application/lib/es_deprecation_errors.ts new file mode 100644 index 0000000000000..4220f0eef8d42 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/lib/es_deprecation_errors.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { ResponseError } from './api'; + +const i18nTexts = { + permissionsError: i18n.translate( + 'xpack.upgradeAssistant.esDeprecationErrors.permissionsErrorMessage', + { + defaultMessage: 'You are not authorized to view Elasticsearch deprecations.', + } + ), + partiallyUpgradedWarning: i18n.translate( + 'xpack.upgradeAssistant.esDeprecationErrors.partiallyUpgradedWarningMessage', + { + defaultMessage: + 'Upgrade Kibana to the same version as your Elasticsearch cluster. One or more nodes in the cluster is running a different version than Kibana.', + } + ), + upgradedMessage: i18n.translate( + 'xpack.upgradeAssistant.esDeprecationErrors.upgradedWarningMessage', + { + defaultMessage: + 'Your configuration is up to date. Kibana and all Elasticsearch nodes are running the same version.', + } + ), + loadingError: i18n.translate('xpack.upgradeAssistant.esDeprecationErrors.loadingErrorMessage', { + defaultMessage: 'Could not retrieve Elasticsearch deprecations.', + }), +}; + +export const getEsDeprecationError = (error: ResponseError) => { + if (error.statusCode === 403) { + return { + code: 'unauthorized_error', + message: i18nTexts.permissionsError, + }; + } else if (error?.statusCode === 426 && error.attributes?.allNodesUpgraded === false) { + return { + code: 'partially_upgraded_error', + message: i18nTexts.partiallyUpgradedWarning, + }; + } else if (error?.statusCode === 426 && error.attributes?.allNodesUpgraded === true) { + return { + code: 'upgraded_error', + message: i18nTexts.upgradedMessage, + }; + } else { + return { + code: 'request_error', + message: i18nTexts.loadingError, + }; + } +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts b/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts index 681beefdfd00c..575c85bb33ec0 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts +++ b/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts @@ -11,6 +11,7 @@ import { UA_READONLY_MODE } from '../../common/constants'; import { renderApp } from './render_app'; import { KibanaVersionContext } from './app_context'; import { apiService } from './lib/api'; +import { breadcrumbService } from './lib/breadcrumbs'; export async function mountManagementSection( coreSetup: CoreSetup, @@ -18,13 +19,15 @@ export async function mountManagementSection( params: ManagementAppMountParams, kibanaVersionInfo: KibanaVersionContext ) { - const [{ i18n, docLinks, notifications }] = await coreSetup.getStartServices(); + const [{ i18n, docLinks, notifications, application }] = await coreSetup.getStartServices(); + const { element, history, setBreadcrumbs } = params; const { http } = coreSetup; apiService.setup(http); + breadcrumbService.setup(setBreadcrumbs); return renderApp({ - element: params.element, + element, isCloudEnabled, http, i18n, @@ -32,6 +35,9 @@ export async function mountManagementSection( kibanaVersionInfo, notifications, isReadOnlyMode: UA_READONLY_MODE, + history, api: apiService, + breadcrumbs: breadcrumbService, + getUrlForApp: application.getUrlForApp, }); } diff --git a/x-pack/plugins/upgrade_assistant/public/application/render_app.tsx b/x-pack/plugins/upgrade_assistant/public/application/render_app.tsx index a393ae433c5af..248e6961a74e5 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/render_app.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/render_app.tsx @@ -8,11 +8,9 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { AppDependencies, RootComponent } from './app'; -import { ApiService } from './lib/api'; interface BootDependencies extends AppDependencies { element: HTMLElement; - api: ApiService; } export const renderApp = (deps: BootDependencies) => { diff --git a/x-pack/plugins/upgrade_assistant/public/shared_imports.ts b/x-pack/plugins/upgrade_assistant/public/shared_imports.ts index 6d3984fac68a6..9007fdc5db04d 100644 --- a/x-pack/plugins/upgrade_assistant/public/shared_imports.ts +++ b/x-pack/plugins/upgrade_assistant/public/shared_imports.ts @@ -11,4 +11,5 @@ export { SendRequestResponse, useRequest, UseRequestConfig, + SectionLoading, } from '../../../../src/plugins/es_ui_shared/public/'; diff --git a/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/indices.helpers.ts b/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/indices.helpers.ts index 5ab5c88cce4bc..a59aa009a912b 100644 --- a/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/indices.helpers.ts +++ b/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/indices.helpers.ts @@ -6,10 +6,14 @@ */ import { registerTestBed, TestBed, TestBedConfig } from '@kbn/test/jest'; -import { PageContent } from '../../public/application/components/page_content'; +import { EsDeprecationsContent } from '../../public/application/components/es_deprecations'; import { WithAppDependencies } from './setup_environment'; const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: ['/es_deprecations/indices'], + componentRoutePath: '/es_deprecations/:tabName', + }, doMountAsync: true, }; @@ -46,7 +50,10 @@ const createActions = (testBed: TestBed) => { }; export const setup = async (overrides?: Record): Promise => { - const initTestBed = registerTestBed(WithAppDependencies(PageContent, overrides), testBedConfig); + const initTestBed = registerTestBed( + WithAppDependencies(EsDeprecationsContent, overrides), + testBedConfig + ); const testBed = await initTestBed(); return { @@ -60,6 +67,9 @@ export type IndicesTestSubjects = | 'removeIndexSettingsButton' | 'deprecationsContainer' | 'permissionsError' - | 'upgradeStatusError' + | 'requestError' + | 'indexCount' + | 'upgradedCallout' + | 'partiallyUpgradedWarning' | 'noDeprecationsPrompt' | string; diff --git a/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/overview.helpers.ts b/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/overview.helpers.ts index 22d00290842f4..161364f6d45ce 100644 --- a/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/overview.helpers.ts +++ b/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/overview.helpers.ts @@ -6,27 +6,40 @@ */ import { registerTestBed, TestBed, TestBedConfig } from '@kbn/test/jest'; -import { PageContent } from '../../public/application/components/page_content'; +import { DeprecationsOverview } from '../../public/application/components/overview'; import { WithAppDependencies } from './setup_environment'; const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: [`/overview`], + componentRoutePath: '/overview', + }, doMountAsync: true, }; export type OverviewTestBed = TestBed; -export const setup = async (overrides?: any): Promise => { - const initTestBed = registerTestBed(WithAppDependencies(PageContent, overrides), testBedConfig); +export const setup = async (overrides?: Record): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(DeprecationsOverview, overrides), + testBedConfig + ); const testBed = await initTestBed(); return testBed; }; export type OverviewTestSubjects = - | 'comingSoonPrompt' - | 'upgradeAssistantPageContent' + | 'overviewPageContent' + | 'esStatsPanel' + | 'esStatsPanel.totalDeprecations' + | 'esStatsPanel.criticalDeprecations' + | 'deprecationLoggingFormRow' + | 'requestErrorIconTip' + | 'partiallyUpgradedErrorIconTip' + | 'upgradedErrorIconTip' + | 'unauthorizedErrorIconTip' | 'upgradedPrompt' | 'partiallyUpgradedPrompt' | 'upgradeAssistantDeprecationToggle' - | 'deprecationLoggingStep' | 'upgradeStatusError'; diff --git a/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/setup_environment.tsx b/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/setup_environment.tsx index fb0afef8cf587..7ee6114cd86a8 100644 --- a/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/setup_environment.tsx @@ -17,14 +17,15 @@ import { mockKibanaSemverVersion, UA_READONLY_MODE } from '../../common/constant import { AppContextProvider } from '../../public/application/app_context'; import { init as initHttpRequests } from './http_requests'; import { apiService } from '../../public/application/lib/api'; +import { breadcrumbService } from '../../public/application/lib/breadcrumbs'; const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); -export const WithAppDependencies = ( - Comp: React.FunctionComponent>, - overrides: Record = {} -) => (props: Record) => { +export const WithAppDependencies = (Comp: any, overrides: Record = {}) => ( + props: Record +) => { apiService.setup((mockHttpClient as unknown) as HttpSetup); + breadcrumbService.setup(() => ''); const contextValue = { http: (mockHttpClient as unknown) as HttpSetup, @@ -38,6 +39,8 @@ export const WithAppDependencies = ( isReadOnlyMode: UA_READONLY_MODE, notifications: notificationServiceMock.createStartContract(), api: apiService, + breadcrumbs: breadcrumbService, + getUrlForApp: () => '', }; return ( diff --git a/x-pack/plugins/upgrade_assistant/tests_client_integration/indices.test.ts b/x-pack/plugins/upgrade_assistant/tests_client_integration/indices.test.ts index 01d95f117827e..6363e57903c27 100644 --- a/x-pack/plugins/upgrade_assistant/tests_client_integration/indices.test.ts +++ b/x-pack/plugins/upgrade_assistant/tests_client_integration/indices.test.ts @@ -124,14 +124,7 @@ describe('Indices tab', () => { testBed = await setupIndicesPage({ isReadOnlyMode: false }); }); - const { actions, component } = testBed; - - component.update(); - - // Navigate to the indices tab - await act(async () => { - actions.clickTab('indices'); - }); + const { component } = testBed; component.update(); }); @@ -139,7 +132,7 @@ describe('Indices tab', () => { test('renders prompt', () => { const { exists, find } = testBed; expect(exists('noDeprecationsPrompt')).toBe(true); - expect(find('noDeprecationsPrompt').text()).toContain('All clear!'); + expect(find('noDeprecationsPrompt').text()).toContain('Ready to upgrade!'); }); }); @@ -163,7 +156,59 @@ describe('Indices tab', () => { expect(exists('permissionsError')).toBe(true); expect(find('permissionsError').text()).toContain( - 'You do not have sufficient privileges to view this page.' + 'You are not authorized to view Elasticsearch deprecations.' + ); + }); + + test('handles upgrade error', async () => { + const error = { + statusCode: 426, + error: 'Upgrade required', + message: 'There are some nodes running a different version of Elasticsearch', + attributes: { + allNodesUpgraded: true, + }, + }; + + httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); + + await act(async () => { + testBed = await setupIndicesPage({ isReadOnlyMode: false }); + }); + + const { component, exists, find } = testBed; + + component.update(); + + expect(exists('upgradedCallout')).toBe(true); + expect(find('upgradedCallout').text()).toContain( + 'Your configuration is up to date. Kibana and all Elasticsearch nodes are running the same version.' + ); + }); + + test('handles partially upgrade error', async () => { + const error = { + statusCode: 426, + error: 'Upgrade required', + message: 'There are some nodes running a different version of Elasticsearch', + attributes: { + allNodesUpgraded: false, + }, + }; + + httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); + + await act(async () => { + testBed = await setupIndicesPage({ isReadOnlyMode: false }); + }); + + const { component, exists, find } = testBed; + + component.update(); + + expect(exists('partiallyUpgradedWarning')).toBe(true); + expect(find('partiallyUpgradedWarning').text()).toContain( + 'Upgrade Kibana to the same version as your Elasticsearch cluster. One or more nodes in the cluster is running a different version than Kibana.' ); }); @@ -184,9 +229,9 @@ describe('Indices tab', () => { component.update(); - expect(exists('upgradeStatusError')).toBe(true); - expect(find('upgradeStatusError').text()).toContain( - 'An error occurred while retrieving the checkup results.' + expect(exists('requestError')).toBe(true); + expect(find('requestError').text()).toContain( + 'Could not retrieve Elasticsearch deprecations.' ); }); }); diff --git a/x-pack/plugins/upgrade_assistant/tests_client_integration/overview.test.ts b/x-pack/plugins/upgrade_assistant/tests_client_integration/overview.test.ts index 139c4ecb5a75d..cdbbd0a36cbdd 100644 --- a/x-pack/plugins/upgrade_assistant/tests_client_integration/overview.test.ts +++ b/x-pack/plugins/upgrade_assistant/tests_client_integration/overview.test.ts @@ -11,25 +11,9 @@ import { OverviewTestBed, setupOverviewPage, setupEnvironment } from './helpers' describe('Overview page', () => { let testBed: OverviewTestBed; + const { server, httpRequestsMockHelpers } = setupEnvironment(); beforeEach(async () => { - await act(async () => { - testBed = await setupOverviewPage(); - }); - }); - - describe('Coming soon prompt', () => { - // Default behavior up until the last minor before the next major release - test('renders the coming soon prompt by default', () => { - const { exists } = testBed; - - expect(exists('comingSoonPrompt')).toBe(true); - }); - }); - - describe('Overview content', () => { - const { server, httpRequestsMockHelpers } = setupEnvironment(); - const upgradeStatusMockResponse = { readyForUpgrade: false, cluster: [], @@ -39,148 +23,163 @@ describe('Overview page', () => { httpRequestsMockHelpers.setLoadStatusResponse(upgradeStatusMockResponse); httpRequestsMockHelpers.setLoadDeprecationLoggingResponse({ isEnabled: true }); - beforeEach(async () => { - await act(async () => { - // Override the default context value to verify tab content renders as expected - // This will be the default behavior on the last minor before the next major release (e.g., v7.15) - testBed = await setupOverviewPage({ isReadOnlyMode: false }); - }); - - testBed.component.update(); - }); - - afterAll(() => { - server.restore(); + await act(async () => { + testBed = await setupOverviewPage(); }); - test('renders the overview tab', () => { - const { exists } = testBed; + const { component } = testBed; + component.update(); + }); - expect(exists('comingSoonPrompt')).toBe(false); - expect(exists('upgradeAssistantPageContent')).toBe(true); - }); + afterAll(() => { + server.restore(); + }); - describe('Deprecation logging', () => { - test('toggles deprecation logging', async () => { - const { form, find, component } = testBed; + test('renders the overview page', () => { + const { exists, find } = testBed; - httpRequestsMockHelpers.setUpdateDeprecationLoggingResponse({ isEnabled: false }); + expect(exists('overviewPageContent')).toBe(true); + // Verify ES stats + expect(exists('esStatsPanel')).toBe(true); + expect(find('esStatsPanel.totalDeprecations').text()).toContain('0'); + expect(find('esStatsPanel.criticalDeprecations').text()).toContain('0'); + }); - expect(find('upgradeAssistantDeprecationToggle').props()['aria-checked']).toBe(true); - expect(find('upgradeAssistantDeprecationToggle').props().disabled).toBe(false); - expect(find('deprecationLoggingStep').find('.euiSwitch__label').text()).toContain('On'); + describe('Deprecation logging', () => { + test('toggles deprecation logging', async () => { + const { form, find, component } = testBed; - await act(async () => { - form.toggleEuiSwitch('upgradeAssistantDeprecationToggle'); - }); + httpRequestsMockHelpers.setUpdateDeprecationLoggingResponse({ isEnabled: false }); - component.update(); + expect(find('upgradeAssistantDeprecationToggle').props()['aria-checked']).toBe(true); + expect(find('upgradeAssistantDeprecationToggle').props().disabled).toBe(false); - expect(find('upgradeAssistantDeprecationToggle').props()['aria-checked']).toBe(false); - expect(find('upgradeAssistantDeprecationToggle').props().disabled).toBe(false); - expect(find('deprecationLoggingStep').find('.euiSwitch__label').text()).toContain('Off'); + await act(async () => { + form.toggleEuiSwitch('upgradeAssistantDeprecationToggle'); }); - test('handles network error', async () => { - const error = { - statusCode: 500, - error: 'Internal server error', - message: 'Internal server error', - }; + component.update(); - const { form, find, component } = testBed; + expect(find('upgradeAssistantDeprecationToggle').props()['aria-checked']).toBe(false); + expect(find('upgradeAssistantDeprecationToggle').props().disabled).toBe(false); + }); - httpRequestsMockHelpers.setUpdateDeprecationLoggingResponse(undefined, error); + test('handles network error', async () => { + const error = { + statusCode: 500, + error: 'Internal server error', + message: 'Internal server error', + }; - expect(find('upgradeAssistantDeprecationToggle').props()['aria-checked']).toBe(true); - expect(find('upgradeAssistantDeprecationToggle').props().disabled).toBe(false); - expect(find('deprecationLoggingStep').find('.euiSwitch__label').text()).toContain('On'); + const { form, find, component } = testBed; - await act(async () => { - form.toggleEuiSwitch('upgradeAssistantDeprecationToggle'); - }); + httpRequestsMockHelpers.setUpdateDeprecationLoggingResponse(undefined, error); - component.update(); + expect(find('upgradeAssistantDeprecationToggle').props()['aria-checked']).toBe(true); + expect(find('upgradeAssistantDeprecationToggle').props().disabled).toBe(false); + expect(find('deprecationLoggingFormRow').find('.euiSwitch__label').text()).toContain( + 'Enable deprecation logging' + ); - expect(find('upgradeAssistantDeprecationToggle').props()['aria-checked']).toBe(true); - expect(find('upgradeAssistantDeprecationToggle').props().disabled).toBe(true); - expect(find('deprecationLoggingStep').find('.euiSwitch__label').text()).toContain( - 'Could not load logging state' - ); + await act(async () => { + form.toggleEuiSwitch('upgradeAssistantDeprecationToggle'); }); + + component.update(); + + expect(find('upgradeAssistantDeprecationToggle').props()['aria-checked']).toBe(true); + expect(find('upgradeAssistantDeprecationToggle').props().disabled).toBe(true); + expect(find('deprecationLoggingFormRow').find('.euiSwitch__label').text()).toContain( + 'Could not load logging state' + ); }); + }); + + describe('Error handling', () => { + test('handles network failure', async () => { + const error = { + statusCode: 500, + error: 'Internal server error', + message: 'Internal server error', + }; + + httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); + + await act(async () => { + testBed = await setupOverviewPage(); + }); - describe('Error handling', () => { - test('handles network failure', async () => { - const error = { - statusCode: 500, - error: 'Internal server error', - message: 'Internal server error', - }; + const { component, exists } = testBed; - httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); + component.update(); - await act(async () => { - testBed = await setupOverviewPage({ isReadOnlyMode: false }); - }); + expect(exists('requestErrorIconTip')).toBe(true); + }); - const { component, exists, find } = testBed; + test('handles unauthorized error', async () => { + const error = { + statusCode: 403, + error: 'Forbidden', + message: 'Forbidden', + }; - component.update(); + httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); - expect(exists('upgradeStatusError')).toBe(true); - expect(find('upgradeStatusError').text()).toContain( - 'An error occurred while retrieving the checkup results.' - ); + await act(async () => { + testBed = await setupOverviewPage(); }); - test('handles partially upgraded error', async () => { - const error = { - statusCode: 426, - error: 'Upgrade required', - message: 'There are some nodes running a different version of Elasticsearch', - attributes: { - allNodesUpgraded: false, - }, - }; + const { component, exists } = testBed; - httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); + component.update(); - await act(async () => { - testBed = await setupOverviewPage({ isReadOnlyMode: false }); - }); + expect(exists('unauthorizedErrorIconTip')).toBe(true); + }); - const { component, exists, find } = testBed; + test('handles partially upgraded error', async () => { + const error = { + statusCode: 426, + error: 'Upgrade required', + message: 'There are some nodes running a different version of Elasticsearch', + attributes: { + allNodesUpgraded: false, + }, + }; - component.update(); + httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); - expect(exists('partiallyUpgradedPrompt')).toBe(true); - expect(find('partiallyUpgradedPrompt').text()).toContain('Your cluster is upgrading'); + await act(async () => { + testBed = await setupOverviewPage({ isReadOnlyMode: false }); }); - test('handles upgrade error', async () => { - const error = { - statusCode: 426, - error: 'Upgrade required', - message: 'There are some nodes running a different version of Elasticsearch', - attributes: { - allNodesUpgraded: true, - }, - }; + const { component, exists } = testBed; - httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); + component.update(); - await act(async () => { - testBed = await setupOverviewPage({ isReadOnlyMode: false }); - }); + expect(exists('partiallyUpgradedErrorIconTip')).toBe(true); + }); - const { component, exists, find } = testBed; + test('handles upgrade error', async () => { + const error = { + statusCode: 426, + error: 'Upgrade required', + message: 'There are some nodes running a different version of Elasticsearch', + attributes: { + allNodesUpgraded: true, + }, + }; - component.update(); + httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); - expect(exists('upgradedPrompt')).toBe(true); - expect(find('upgradedPrompt').text()).toContain('Your cluster has been upgraded'); + await act(async () => { + testBed = await setupOverviewPage({ isReadOnlyMode: false }); }); + + const { component, exists } = testBed; + + component.update(); + + expect(exists('upgradedErrorIconTip')).toBe(true); }); }); }); diff --git a/x-pack/test/accessibility/apps/upgrade_assistant.ts b/x-pack/test/accessibility/apps/upgrade_assistant.ts index 96b3e6673de70..8d2774c000b29 100644 --- a/x-pack/test/accessibility/apps/upgrade_assistant.ts +++ b/x-pack/test/accessibility/apps/upgrade_assistant.ts @@ -13,39 +13,39 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const retry = getService('retry'); - describe('Upgrade Assistant Home', () => { + describe('Upgrade Assistant', () => { before(async () => { await PageObjects.upgradeAssistant.navigateToPage(); }); - it('Overview page', async () => { - await retry.waitFor('Upgrade Assistant overview page to be visible', async () => { + it('Coming soon prompt', async () => { + await retry.waitFor('Upgrade Assistant coming soon prompt to be visible', async () => { return testSubjects.exists('comingSoonPrompt'); }); await a11y.testAppSnapshot(); }); // These tests will be skipped until the last minor of the next major release - describe.skip('tabs', () => { - it('Overview Tab', async () => { - await retry.waitFor('Upgrade Assistant overview tab to be visible', async () => { - return testSubjects.exists('upgradeAssistantOverviewTabDetail'); + describe.skip('Upgrade Assistant content', () => { + it('Overview page', async () => { + await retry.waitFor('Upgrade Assistant overview page to be visible', async () => { + return testSubjects.exists('overviewPageContent'); }); await a11y.testAppSnapshot(); }); - it('Cluster Tab', async () => { - await testSubjects.click('upgradeAssistantClusterTab'); + it('Elasticsearch cluster tab', async () => { + await testSubjects.click('esDeprecationsLink'); await retry.waitFor('Upgrade Assistant Cluster tab to be visible', async () => { - return testSubjects.exists('upgradeAssistantClusterTabDetail'); + return testSubjects.exists('clusterTabContent'); }); await a11y.testAppSnapshot(); }); - it('Indices Tab', async () => { + it('Elasticsearch indices tab', async () => { await testSubjects.click('upgradeAssistantIndicesTab'); - await retry.waitFor('Upgrade Assistant Cluster tab to be visible', async () => { - return testSubjects.exists('upgradeAssistantIndexTabDetail'); + await retry.waitFor('Upgrade Assistant Indices tab to be visible', async () => { + return testSubjects.exists('indexTabContent'); }); await a11y.testAppSnapshot(); }); From d679035664a46cd19eeb8d57ca299bacabbd433e Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Wed, 14 Apr 2021 11:27:36 -0500 Subject: [PATCH 25/43] Upgrade EUI to v32.0.4 (#96459) * eui to 31.12.0 * type updates * snapshot updates * snapshot updates * euiavatarprops * eui to 32.0.3 * euicard updates * update test --- package.json | 2 +- .../header/__snapshots__/header.test.tsx.snap | 82 ++++++++++++++----- src/core/public/chrome/ui/header/header.tsx | 2 +- .../dashboard_empty_screen.test.tsx.snap | 4 + .../__snapshots__/data_view.test.tsx.snap | 8 +- .../apps/discover/_data_grid_doc_table.ts | 4 +- .../List/__snapshots__/List.test.tsx.snap | 2 +- .../custom_element_modal.stories.storyshot | 39 ++------- .../element_card.stories.storyshot | 10 +-- .../element_grid.stories.storyshot | 6 +- .../saved_elements_modal.stories.storyshot | 8 +- .../text_style_picker.stories.storyshot | 48 +++++++---- .../__snapshots__/edit_var.stories.storyshot | 10 ++- .../workpad_templates.stories.storyshot | 2 +- .../epm/screens/detail/policies/persona.tsx | 2 +- .../__snapshots__/policy_table.test.tsx.snap | 1 + .../__snapshots__/add_license.test.js.snap | 4 +- .../request_trial_extension.test.js.snap | 8 +- .../revert_to_basic.test.js.snap | 6 +- .../__snapshots__/start_trial.test.js.snap | 8 +- .../upload_license.test.tsx.snap | 10 +++ .../__snapshots__/no_data.test.js.snap | 2 + .../__snapshots__/page_loading.test.js.snap | 1 + .../__snapshots__/setup_mode.test.js.snap | 10 +-- .../roles_grid_page.test.tsx.snap | 2 + .../nav_control/nav_control_service.test.ts | 26 +++--- .../reset_session_page.test.tsx.snap | 2 +- .../rules/select_rule_type/index.tsx | 5 -- .../__snapshots__/index.test.tsx.snap | 32 ++++---- .../__snapshots__/index.test.tsx.snap | 44 +++++----- .../spaces_grid_pages.test.tsx.snap | 6 -- .../space_avatar_internal.test.tsx.snap | 10 +-- .../space_avatar/space_avatar_internal.tsx | 13 ++- .../location_status_tags.test.tsx.snap | 4 +- yarn.lock | 14 ++-- 35 files changed, 238 insertions(+), 199 deletions(-) diff --git a/package.json b/package.json index 1d31aa627129c..9b4958c30022c 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath/npm_module", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.4", "@elastic/ems-client": "7.12.0", - "@elastic/eui": "31.10.0", + "@elastic/eui": "32.0.4", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", "@elastic/maki": "6.3.0", diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 00cc827a1e83f..29407c54e2834 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -4072,8 +4072,34 @@ exports[`Header renders 1`] = ` aria-expanded={false} aria-haspopup="true" aria-label="Help menu" - buttonRef={null} - className="euiHeaderSectionItem__button" + buttonRef={ + Object { + "current": , + } + } + className="euiHeaderSectionItemButton" color="text" onClick={[Function]} > @@ -4081,7 +4107,7 @@ exports[`Header renders 1`] = ` aria-expanded={false} aria-haspopup="true" aria-label="Help menu" - className="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItem__button" + className="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItemButton" disabled={false} onClick={[Function]} type="button" @@ -4101,15 +4127,19 @@ exports[`Header renders 1`] = ` - - - + type="help" + > + +
+ @@ -4226,7 +4256,7 @@ exports[`Header renders 1`] = ` aria-expanded="false" aria-label="Toggle primary navigation" aria-pressed="false" - class="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItem__button" + class="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItemButton" data-test-subj="toggleNavButton" type="button" > @@ -4237,14 +4267,18 @@ exports[`Header renders 1`] = ` class="euiButtonEmpty__text" > + class="euiHeaderSectionItemButton__content" + > + + , } } - className="euiHeaderSectionItem__button" + className="euiHeaderSectionItemButton" color="text" data-test-subj="toggleNavButton" onClick={[Function]} @@ -4254,7 +4288,7 @@ exports[`Header renders 1`] = ` aria-expanded={false} aria-label="Toggle primary navigation" aria-pressed={false} - className="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItem__button" + className="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItemButton" data-test-subj="toggleNavButton" disabled={false} onClick={[Function]} @@ -4275,15 +4309,19 @@ exports[`Header renders 1`] = ` - - - + type="menu" + > + + + diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 16c89fdca380a..67cdd24aae848 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -98,7 +98,7 @@ export function Header({ ); } - const toggleCollapsibleNavRef = createRef(); + const toggleCollapsibleNavRef = createRef void }>(); const navId = htmlIdGenerator()(); const className = classnames('hide-for-sharing', 'headerGlobalNav'); diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index 9e3018fb512c3..4cd3eb13f3609 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -617,9 +617,11 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = `
"`; +exports[`RevertToBasic component should display when license is about to expire 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; -exports[`RevertToBasic component should display when license is expired 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; +exports[`RevertToBasic component should display when license is expired 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; -exports[`RevertToBasic component should display when trial is active 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; +exports[`RevertToBasic component should display when trial is active 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap index 9f08c5f11c2a2..1cacadb824630 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`StartTrial component when trial is allowed display for basic license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed display for basic license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for expired enterprise license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for expired enterprise license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for expired platinum license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for expired platinum license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for gold license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for gold license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap index c89d183282219..c785ed7c99bda 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap @@ -268,9 +268,11 @@ exports[`UploadLicense should display a modal when license requires acknowledgem
- + - + renders permission denied if required 1`] = `
{ aria-expanded="false" aria-haspopup="true" aria-label="Account menu" - class="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItem__button" + class="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItemButton" data-test-subj="userMenuButton" type="button" > @@ -80,18 +80,22 @@ describe('SecurityNavControlService', () => { -
- -
+ +
+ diff --git a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap index bcb8a6c975359..785c57490e8ef 100644 --- a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap +++ b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ResetSessionPage renders as expected 1`] = `"MockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; +exports[`ResetSessionPage renders as expected 1`] = `"MockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx index 64f0f5f65b1ee..5650c2c55488e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx @@ -111,7 +111,6 @@ export const SelectRuleType: React.FC = ({ icon={} selectable={querySelectableConfig} layout="horizontal" - textAlign="left" /> )} @@ -131,7 +130,6 @@ export const SelectRuleType: React.FC = ({ isDisabled={mlSelectableConfig.isDisabled && !mlSelectableConfig.isSelected} selectable={mlSelectableConfig} layout="horizontal" - textAlign="left" /> )} @@ -145,7 +143,6 @@ export const SelectRuleType: React.FC = ({ icon={} selectable={thresholdSelectableConfig} layout="horizontal" - textAlign="left" /> )} @@ -159,7 +156,6 @@ export const SelectRuleType: React.FC = ({ icon={} selectable={eqlSelectableConfig} layout="horizontal" - textAlign="left" /> )} @@ -173,7 +169,6 @@ export const SelectRuleType: React.FC = ({ icon={} selectable={threatMatchSelectableConfig} layout="horizontal" - textAlign="left" /> )} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap index efae0a4b8b3aa..220494b3a5694 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap @@ -2919,7 +2919,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
`; @@ -44,7 +46,6 @@ exports[`renders with a space name entirely made of whitespace 1`] = ` = (props: Props) => { const spaceColor = getSpaceColor(space); + const spaceInitials = getSpaceInitials(space); + + const spaceImageUrl = getSpaceImageUrl(space); + + const avatarConfig: Partial = spaceImageUrl + ? { imageUrl: spaceImageUrl } + : { initials: spaceInitials, initialsLength: MAX_SPACE_INITIALS }; + return ( = (props: Props) => { 'aria-hidden': true, })} size={size || 'm'} - initialsLength={MAX_SPACE_INITIALS} - initials={getSpaceInitials(space)} color={isValidHex(spaceColor) ? spaceColor : ''} - imageUrl={getSpaceImageUrl(space)} + {...avatarConfig} {...rest} /> ); diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__snapshots__/location_status_tags.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__snapshots__/location_status_tags.test.tsx.snap index 8e2a4b1bd1777..44a2021cce611 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__snapshots__/location_status_tags.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__snapshots__/location_status_tags.test.tsx.snap @@ -996,7 +996,7 @@ exports[`LocationStatusTags component renders when there are many location 1`] = aria-controls="generated-id" aria-current="true" aria-label="Page 1 of 2" - class="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--xSmall euiButtonEmpty-isDisabled euiPaginationButton euiPaginationButton-isActive euiPaginationButton--hideOnMobile" + class="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--small euiButtonEmpty-isDisabled euiPaginationButton euiPaginationButton-isActive euiPaginationButton--hideOnMobile" data-test-subj="pagination-button-0" disabled="" type="button" @@ -1018,7 +1018,7 @@ exports[`LocationStatusTags component renders when there are many location 1`] = Date: Wed, 14 Apr 2021 19:35:23 +0300 Subject: [PATCH 26/43] Update VisualizationNoResults component (#97092) * Update VisualizationNoResults component * update JEST * fix font size --- .../visualization_noresults.test.js.snap | 33 +++++++++---------- .../public/components/visualization_error.tsx | 10 ++++-- .../components/visualization_noresults.tsx | 27 +++++++-------- 3 files changed, 33 insertions(+), 37 deletions(-) diff --git a/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap b/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap index 94c5da872b1cb..25ec05c83a8c6 100644 --- a/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap +++ b/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap @@ -6,32 +6,29 @@ exports[`VisualizationNoResults should render according to snapshot 1`] = ` data-test-subj="visNoResult" >
-
+
+
-
-

+ class="euiText euiText--extraSmall" + > No results found -

+
-
+
-
`; diff --git a/src/plugins/visualizations/public/components/visualization_error.tsx b/src/plugins/visualizations/public/components/visualization_error.tsx index 81600a4e3601c..c72933df43491 100644 --- a/src/plugins/visualizations/public/components/visualization_error.tsx +++ b/src/plugins/visualizations/public/components/visualization_error.tsx @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { EuiEmptyPrompt } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; import React from 'react'; interface VisualizationNoResultsProps { onInit?: () => void; - error: string; + error: string | Error; } export class VisualizationError extends React.Component { @@ -21,7 +21,11 @@ export class VisualizationError extends React.Component{this.props.error}

} + body={ + + {typeof this.props.error === 'string' ? this.props.error : this.props.error.message} + + } /> ); } diff --git a/src/plugins/visualizations/public/components/visualization_noresults.tsx b/src/plugins/visualizations/public/components/visualization_noresults.tsx index 92983982dd152..71bf1e8a7e4b0 100644 --- a/src/plugins/visualizations/public/components/visualization_noresults.tsx +++ b/src/plugins/visualizations/public/components/visualization_noresults.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { EuiIcon, EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; @@ -15,26 +15,21 @@ interface VisualizationNoResultsProps { } export class VisualizationNoResults extends React.Component { - private containerDiv = React.createRef(); - public render() { return ( -
-
-
- - - - - -

+

+ {i18n.translate('visualizations.noResultsFoundTitle', { defaultMessage: 'No results found', })} -

- -
-
+ + } + />
); } From ff7c5330ad97ebfea26dfd37854dcf7117134b8c Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Wed, 14 Apr 2021 12:53:46 -0400 Subject: [PATCH 27/43] [Security Solution] Converge detection engine on single schema representation (#96186) * Replace validation function in signal executor * Remove more RuleTypeParams usage * Add security solution rules migration to alerting plugin * Handle and test null value in threshold.field * Remove runtime normalization of threshold field * Remove signalParamsSchema Co-authored-by: Davis Plumlee Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/saved_objects/migrations.test.ts | 276 +++++++++ .../server/saved_objects/migrations.ts | 72 +++ .../schemas/common/schemas.ts | 4 +- .../schemas/request/rule_schemas.ts | 136 ++--- .../response/find_rules_schema.mocks.ts | 16 - .../response/find_rules_schema.test.ts | 128 ----- .../schemas/response/find_rules_schema.ts | 22 - .../schemas/response/index.ts | 1 - .../common/detection_engine/utils.ts | 9 +- .../security_solution/common/validate.ts | 2 +- .../security_solution/cypress/objects/rule.ts | 2 +- .../rules_notification_alert_type.test.ts | 15 +- .../rules_notification_alert_type.ts | 4 +- .../schedule_notification_actions.ts | 4 +- .../notifications/types.test.ts | 5 +- .../routes/__mocks__/request_responses.ts | 113 +--- .../routes/__mocks__/utils.ts | 13 +- .../rules/create_rules_bulk_route.test.ts | 5 +- .../routes/rules/create_rules_bulk_route.ts | 9 +- .../routes/rules/create_rules_route.test.ts | 5 +- .../routes/rules/create_rules_route.ts | 9 +- .../routes/rules/delete_rules_route.test.ts | 5 +- .../routes/rules/delete_rules_route.ts | 15 +- .../routes/rules/find_rules_route.test.ts | 5 +- .../rules/find_rules_status_route.test.ts | 11 +- .../routes/rules/import_rules_route.test.ts | 5 +- .../rules/patch_rules_bulk_route.test.ts | 5 +- .../routes/rules/patch_rules_route.test.ts | 7 +- .../rules/update_rules_bulk_route.test.ts | 5 +- .../routes/rules/update_rules_route.test.ts | 7 +- .../routes/rules/utils.test.ts | 85 +-- .../detection_engine/routes/rules/utils.ts | 76 +-- .../routes/rules/validate.test.ts | 67 +-- .../detection_engine/routes/rules/validate.ts | 58 +- .../lib/detection_engine/routes/utils.test.ts | 7 +- .../detection_engine/rules/create_rules.ts | 3 +- .../lib/detection_engine/rules/find_rules.ts | 4 +- .../get_existing_prepackaged_rules.test.ts | 29 +- .../rules/get_export_all.test.ts | 110 ++-- .../rules/get_export_by_object_ids.test.ts | 45 +- .../rules/get_rules_to_install.test.ts | 11 +- .../rules/get_rules_to_update.test.ts | 51 +- .../rules/patch_rules.mock.ts | 143 +---- .../lib/detection_engine/rules/patch_rules.ts | 15 +- .../detection_engine/rules/read_rules.test.ts | 21 +- .../lib/detection_engine/rules/read_rules.ts | 4 +- .../lib/detection_engine/rules/types.ts | 11 +- .../rules/update_prepacked_rules.ts | 5 +- .../rules/update_rules.test.ts | 9 +- .../detection_engine/rules/update_rules.ts | 12 +- .../schemas/rule_converters.ts | 109 ++-- .../schemas/rule_schemas.mock.ts | 70 ++- .../detection_engine/schemas/rule_schemas.ts | 20 +- .../signals/__mocks__/es_results.ts | 78 +-- .../signals/build_bulk_body.test.ts | 543 ++---------------- .../signals/build_bulk_body.ts | 64 +-- .../signals/build_rule.test.ts | 412 ++----------- .../detection_engine/signals/build_rule.ts | 189 +----- .../signals/bulk_create_ml_signals.ts | 19 +- .../detection_engine/signals/executors/eql.ts | 5 +- .../detection_engine/signals/executors/ml.ts | 17 +- .../signals/executors/query.ts | 17 +- .../signals/executors/threat_match.ts | 17 +- .../signals/executors/threshold.ts | 29 +- .../signals/search_after_bulk_create.test.ts | 151 +---- .../signals/search_after_bulk_create.ts | 33 +- .../signals/send_telemetry_events.ts | 2 - .../signals/signal_params_schema.mock.ts | 55 -- .../signals/signal_params_schema.test.ts | 158 ----- .../signals/signal_params_schema.ts | 86 --- .../signals/signal_rule_alert_type.test.ts | 37 +- .../signals/signal_rule_alert_type.ts | 44 +- .../signals/single_bulk_create.test.ts | 93 +-- .../signals/single_bulk_create.ts | 65 +-- .../threat_mapping/create_threat_signal.ts | 24 +- .../threat_mapping/create_threat_signals.ts | 25 +- .../signals/threat_mapping/types.ts | 36 +- .../bulk_create_threshold_signals.test.ts | 164 +----- .../bulk_create_threshold_signals.ts | 33 +- .../threshold/find_threshold_signals.test.ts | 103 +--- .../threshold/find_threshold_signals.ts | 7 +- .../lib/detection_engine/signals/types.ts | 57 +- .../lib/detection_engine/signals/utils.ts | 21 + .../detection_engine/tags/read_tags.test.ts | 55 +- 84 files changed, 1200 insertions(+), 3319 deletions(-) delete mode 100644 x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.mocks.ts delete mode 100644 x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.test.ts delete mode 100644 x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.mock.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.test.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts index 676ce1d27d2fc..4df75ab60b496 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts @@ -699,6 +699,282 @@ describe('7.11.2', () => { }); }); +describe('7.13.0', () => { + beforeEach(() => { + jest.resetAllMocks(); + encryptedSavedObjectsSetup.createMigration.mockImplementation( + (shouldMigrateWhenPredicate, migration) => migration + ); + }); + test('security solution alerts get migrated and remove null values', () => { + const migration713 = getMigrations(encryptedSavedObjectsSetup)['7.13.0']; + const alert = getMockData({ + alertTypeId: 'siem.signals', + params: { + author: ['Elastic'], + buildingBlockType: null, + description: + "This rule detects a known command and control pattern in network events. The FIN7 threat group is known to use this command and control technique, while maintaining persistence in their target's network.", + ruleId: '4a4e23cf-78a2-449c-bac3-701924c269d3', + index: ['packetbeat-*'], + falsePositives: [ + "This rule could identify benign domains that are formatted similarly to FIN7's command and control algorithm. Alerts should be investigated by an analyst to assess the validity of the individual observations.", + ], + from: 'now-6m', + immutable: true, + query: + 'event.category:(network OR network_traffic) AND type:(tls OR http) AND network.transport:tcp AND destination.domain:/[a-zA-Z]{4,5}.(pw|us|club|info|site|top)/ AND NOT destination.domain:zoom.us', + language: 'lucene', + license: 'Elastic License', + outputIndex: '.siem-signals-rylandherrick_2-default', + savedId: null, + timelineId: null, + timelineTitle: null, + meta: null, + filters: null, + maxSignals: 100, + riskScore: 73, + riskScoreMapping: [], + ruleNameOverride: null, + severity: 'high', + severityMapping: null, + threat: null, + threatFilters: null, + timestampOverride: null, + to: 'now', + type: 'query', + references: [ + 'https://www.fireeye.com/blog/threat-research/2018/08/fin7-pursuing-an-enigmatic-and-evasive-global-criminal-operation.html', + ], + note: + 'In the event this rule identifies benign domains in your environment, the `destination.domain` field in the rule can be modified to include those domains. Example: `...AND NOT destination.domain:(zoom.us OR benign.domain1 OR benign.domain2)`.', + version: 1, + exceptionsList: null, + threshold: { + field: null, + value: 5, + }, + }, + }); + + expect(migration713(alert, migrationContext)).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + params: { + author: ['Elastic'], + description: + "This rule detects a known command and control pattern in network events. The FIN7 threat group is known to use this command and control technique, while maintaining persistence in their target's network.", + ruleId: '4a4e23cf-78a2-449c-bac3-701924c269d3', + index: ['packetbeat-*'], + falsePositives: [ + "This rule could identify benign domains that are formatted similarly to FIN7's command and control algorithm. Alerts should be investigated by an analyst to assess the validity of the individual observations.", + ], + from: 'now-6m', + immutable: true, + query: + 'event.category:(network OR network_traffic) AND type:(tls OR http) AND network.transport:tcp AND destination.domain:/[a-zA-Z]{4,5}.(pw|us|club|info|site|top)/ AND NOT destination.domain:zoom.us', + language: 'lucene', + license: 'Elastic License', + outputIndex: '.siem-signals-rylandherrick_2-default', + maxSignals: 100, + riskScore: 73, + riskScoreMapping: [], + severity: 'high', + severityMapping: [], + threat: [], + to: 'now', + type: 'query', + references: [ + 'https://www.fireeye.com/blog/threat-research/2018/08/fin7-pursuing-an-enigmatic-and-evasive-global-criminal-operation.html', + ], + note: + 'In the event this rule identifies benign domains in your environment, the `destination.domain` field in the rule can be modified to include those domains. Example: `...AND NOT destination.domain:(zoom.us OR benign.domain1 OR benign.domain2)`.', + version: 1, + exceptionsList: [], + threshold: { + field: [], + value: 5, + cardinality: [], + }, + }, + }, + }); + }); + + test('non-null values in security solution alerts are not modified', () => { + const migration713 = getMigrations(encryptedSavedObjectsSetup)['7.13.0']; + const alert = getMockData({ + alertTypeId: 'siem.signals', + params: { + author: ['Elastic'], + buildingBlockType: 'default', + description: + "This rule detects a known command and control pattern in network events. The FIN7 threat group is known to use this command and control technique, while maintaining persistence in their target's network.", + ruleId: '4a4e23cf-78a2-449c-bac3-701924c269d3', + index: ['packetbeat-*'], + falsePositives: [ + "This rule could identify benign domains that are formatted similarly to FIN7's command and control algorithm. Alerts should be investigated by an analyst to assess the validity of the individual observations.", + ], + from: 'now-6m', + immutable: true, + query: + 'event.category:(network OR network_traffic) AND type:(tls OR http) AND network.transport:tcp AND destination.domain:/[a-zA-Z]{4,5}.(pw|us|club|info|site|top)/ AND NOT destination.domain:zoom.us', + language: 'lucene', + license: 'Elastic License', + outputIndex: '.siem-signals-rylandherrick_2-default', + savedId: 'saved-id', + timelineId: 'timeline-id', + timelineTitle: 'timeline-title', + meta: { + field: 'value', + }, + filters: ['filters'], + maxSignals: 100, + riskScore: 73, + riskScoreMapping: ['risk-score-mapping'], + ruleNameOverride: 'field.name', + severity: 'high', + severityMapping: ['severity-mapping'], + threat: [ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0011', + name: 'Command and Control', + reference: 'https://attack.mitre.org/tactics/TA0011/', + }, + technique: [ + { + id: 'T1483', + name: 'Domain Generation Algorithms', + reference: 'https://attack.mitre.org/techniques/T1483/', + }, + ], + }, + ], + threatFilters: ['threat-filter'], + timestampOverride: 'event.ingested', + to: 'now', + type: 'query', + references: [ + 'https://www.fireeye.com/blog/threat-research/2018/08/fin7-pursuing-an-enigmatic-and-evasive-global-criminal-operation.html', + ], + note: + 'In the event this rule identifies benign domains in your environment, the `destination.domain` field in the rule can be modified to include those domains. Example: `...AND NOT destination.domain:(zoom.us OR benign.domain1 OR benign.domain2)`.', + version: 1, + exceptionsList: ['exceptions-list'], + }, + }); + + expect(migration713(alert, migrationContext)).toEqual(alert); + }); + + test('security solution threshold alert with string in threshold.field is migrated to array', () => { + const migration713 = getMigrations(encryptedSavedObjectsSetup)['7.13.0']; + const alert = getMockData({ + alertTypeId: 'siem.signals', + params: { + threshold: { + field: 'host.id', + value: 5, + }, + }, + }); + + expect(migration713(alert, migrationContext)).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + params: { + threshold: { + field: ['host.id'], + value: 5, + cardinality: [], + }, + exceptionsList: [], + riskScoreMapping: [], + severityMapping: [], + threat: [], + }, + }, + }); + }); + + test('security solution threshold alert with empty string in threshold.field is migrated to empty array', () => { + const migration713 = getMigrations(encryptedSavedObjectsSetup)['7.13.0']; + const alert = getMockData({ + alertTypeId: 'siem.signals', + params: { + threshold: { + field: '', + value: 5, + }, + }, + }); + + expect(migration713(alert, migrationContext)).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + params: { + threshold: { + field: [], + value: 5, + cardinality: [], + }, + exceptionsList: [], + riskScoreMapping: [], + severityMapping: [], + threat: [], + }, + }, + }); + }); + + test('security solution threshold alert with array in threshold.field and cardinality is left alone', () => { + const migration713 = getMigrations(encryptedSavedObjectsSetup)['7.13.0']; + const alert = getMockData({ + alertTypeId: 'siem.signals', + params: { + threshold: { + field: ['host.id'], + value: 5, + cardinality: [ + { + field: 'source.ip', + value: 10, + }, + ], + }, + }, + }); + + expect(migration713(alert, migrationContext)).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + params: { + threshold: { + field: ['host.id'], + value: 5, + cardinality: [ + { + field: 'source.ip', + value: 10, + }, + ], + }, + exceptionsList: [], + riskScoreMapping: [], + severityMapping: [], + threat: [], + }, + }, + }); + }); +}); + function getUpdatedAt(): string { const updatedAt = new Date(); updatedAt.setHours(updatedAt.getHours() + 2); diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.ts index 729290498561f..8ebeb401b313c 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.ts @@ -11,6 +11,7 @@ import { SavedObjectMigrationFn, SavedObjectMigrationContext, SavedObjectAttributes, + SavedObjectAttribute, } from '../../../../../src/core/server'; import { RawAlert, RawAlertAction } from '../types'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; @@ -30,6 +31,9 @@ export const isAnyActionSupportIncidents = (doc: SavedObjectUnsanitizedDoc): boolean => + doc.attributes.alertTypeId === 'siem.signals'; + export function getMigrations( encryptedSavedObjects: EncryptedSavedObjectsPluginSetup ): SavedObjectMigrationMap { @@ -59,10 +63,16 @@ export function getMigrations( pipeMigrations(restructureConnectorsThatSupportIncident) ); + const migrationSecurityRules713 = encryptedSavedObjects.createMigration( + (doc): doc is SavedObjectUnsanitizedDoc => isSecuritySolutionRule(doc), + pipeMigrations(removeNullsFromSecurityRules) + ); + return { '7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtAndNotifyWhen, '7.11.0'), '7.11.2': executeMigrationWithErrorHandling(migrationActions7112, '7.11.2'), + '7.13.0': executeMigrationWithErrorHandling(migrationSecurityRules713, '7.13.0'), }; } @@ -333,6 +343,68 @@ function restructureConnectorsThatSupportIncident( }; } +function convertNullToUndefined(attribute: SavedObjectAttribute) { + return attribute != null ? attribute : undefined; +} + +function removeNullsFromSecurityRules( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { + const { + attributes: { params }, + } = doc; + return { + ...doc, + attributes: { + ...doc.attributes, + params: { + ...params, + buildingBlockType: convertNullToUndefined(params.buildingBlockType), + note: convertNullToUndefined(params.note), + index: convertNullToUndefined(params.index), + language: convertNullToUndefined(params.language), + license: convertNullToUndefined(params.license), + outputIndex: convertNullToUndefined(params.outputIndex), + savedId: convertNullToUndefined(params.savedId), + timelineId: convertNullToUndefined(params.timelineId), + timelineTitle: convertNullToUndefined(params.timelineTitle), + meta: convertNullToUndefined(params.meta), + query: convertNullToUndefined(params.query), + filters: convertNullToUndefined(params.filters), + riskScoreMapping: params.riskScoreMapping != null ? params.riskScoreMapping : [], + ruleNameOverride: convertNullToUndefined(params.ruleNameOverride), + severityMapping: params.severityMapping != null ? params.severityMapping : [], + threat: params.threat != null ? params.threat : [], + threshold: + params.threshold != null && + typeof params.threshold === 'object' && + !Array.isArray(params.threshold) + ? { + field: Array.isArray(params.threshold.field) + ? params.threshold.field + : params.threshold.field === '' || params.threshold.field == null + ? [] + : [params.threshold.field], + value: params.threshold.value, + cardinality: + params.threshold.cardinality != null ? params.threshold.cardinality : [], + } + : undefined, + timestampOverride: convertNullToUndefined(params.timestampOverride), + exceptionsList: + params.exceptionsList != null + ? params.exceptionsList + : params.exceptions_list != null + ? params.exceptions_list + : params.lists != null + ? params.lists + : [], + threatFilters: convertNullToUndefined(params.threatFilters), + }, + }, + }; +} + function pipeMigrations(...migrations: AlertMigration[]): AlertMigration { return (doc: SavedObjectUnsanitizedDoc) => migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); 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 76ccfb0a433bd..c61ab85f43270 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 @@ -494,7 +494,7 @@ export const threshold = t.intersection([ thresholdField, t.exact( t.partial({ - cardinality: t.union([t.array(thresholdCardinalityField), t.null]), + cardinality: t.array(thresholdCardinalityField), }) ), ]); @@ -507,7 +507,7 @@ export const thresholdNormalized = t.intersection([ thresholdFieldNormalized, t.exact( t.partial({ - cardinality: t.union([t.array(thresholdCardinalityField), t.null]), + cardinality: t.array(thresholdCardinalityField), }) ), ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts index 5cf2b6242b2f8..c7b33372e5953 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts @@ -57,16 +57,16 @@ import { interval, enabled, updated_at, + updated_by, created_at, + created_by, job_status, status_date, last_success_at, last_success_message, last_failure_at, last_failure_message, - throttleOrNull, - createdByOrNull, - updatedByOrNull, + throttle, } from '../common/schemas'; const createSchema = < @@ -137,7 +137,7 @@ interface APIParams< defaultable: Defaultable; } -const commonParams = { +const baseParams = { required: { name, description, @@ -159,12 +159,11 @@ const commonParams = { tags, interval, enabled, - throttle: throttleOrNull, + throttle, actions, author, false_positives, from, - rule_id, // maxSignals not used in ML rules but probably should be used max_signals, risk_score_mapping, @@ -177,10 +176,26 @@ const commonParams = { }, }; const { - create: commonCreateParams, - patch: commonPatchParams, - response: commonResponseParams, -} = buildAPISchemas(commonParams); + create: baseCreateParams, + patch: basePatchParams, + response: baseResponseParams, +} = buildAPISchemas(baseParams); + +// "shared" types are the same across all rule types, and built from "baseParams" above +// with some variations for each route. These intersect with type specific schemas below +// to create the full schema for each route. +export const sharedCreateSchema = t.intersection([ + baseCreateParams, + t.exact(t.partial({ rule_id })), +]); +export type SharedCreateSchema = t.TypeOf; + +export const sharedUpdateSchema = t.intersection([ + baseCreateParams, + t.exact(t.partial({ rule_id })), + t.exact(t.partial({ id })), +]); +export type SharedUpdateSchema = t.TypeOf; const eqlRuleParams = { required: { @@ -318,74 +333,28 @@ const createTypeSpecific = t.union([ export type CreateTypeSpecific = t.TypeOf; // Convenience types for building specific types of rules -export const eqlCreateSchema = t.intersection([eqlCreateParams, commonCreateParams]); -export type EqlCreateSchema = t.TypeOf; - -export const threatMatchCreateSchema = t.intersection([ - threatMatchCreateParams, - commonCreateParams, -]); -export type ThreatMatchCreateSchema = t.TypeOf; - -export const queryCreateSchema = t.intersection([queryCreateParams, commonCreateParams]); -export type QueryCreateSchema = t.TypeOf; - -export const savedQueryCreateSchema = t.intersection([savedQueryCreateParams, commonCreateParams]); -export type SavedQueryCreateSchema = t.TypeOf; - -export const thresholdCreateSchema = t.intersection([thresholdCreateParams, commonCreateParams]); -export type ThresholdCreateSchema = t.TypeOf; - -export const machineLearningCreateSchema = t.intersection([ - machineLearningCreateParams, - commonCreateParams, -]); -export type MachineLearningCreateSchema = t.TypeOf; - -export const createRulesSchema = t.intersection([commonCreateParams, createTypeSpecific]); +type CreateSchema = SharedCreateSchema & T; +export type EqlCreateSchema = CreateSchema>; +export type ThreatMatchCreateSchema = CreateSchema>; +export type QueryCreateSchema = CreateSchema>; +export type SavedQueryCreateSchema = CreateSchema>; +export type ThresholdCreateSchema = CreateSchema>; +export type MachineLearningCreateSchema = CreateSchema< + t.TypeOf +>; + +export const createRulesSchema = t.intersection([sharedCreateSchema, createTypeSpecific]); export type CreateRulesSchema = t.TypeOf; -export const eqlUpdateSchema = t.intersection([ - eqlCreateParams, - commonCreateParams, - t.exact(t.partial({ id })), -]); -export type EqlUpdateSchema = t.TypeOf; - -export const threatMatchUpdateSchema = t.intersection([ - threatMatchCreateParams, - commonCreateParams, - t.exact(t.partial({ id })), -]); -export type ThreatMatchUpdateSchema = t.TypeOf; - -export const queryUpdateSchema = t.intersection([ - queryCreateParams, - commonCreateParams, - t.exact(t.partial({ id })), -]); -export type QueryUpdateSchema = t.TypeOf; - -export const savedQueryUpdateSchema = t.intersection([ - savedQueryCreateParams, - commonCreateParams, - t.exact(t.partial({ id })), -]); -export type SavedQueryUpdateSchema = t.TypeOf; - -export const thresholdUpdateSchema = t.intersection([ - thresholdCreateParams, - commonCreateParams, - t.exact(t.partial({ id })), -]); -export type ThresholdUpdateSchema = t.TypeOf; - -export const machineLearningUpdateSchema = t.intersection([ - machineLearningCreateParams, - commonCreateParams, - t.exact(t.partial({ id })), -]); -export type MachineLearningUpdateSchema = t.TypeOf; +type UpdateSchema = SharedUpdateSchema & T; +export type EqlUpdateSchema = UpdateSchema>; +export type ThreatMatchUpdateSchema = UpdateSchema>; +export type QueryUpdateSchema = UpdateSchema>; +export type SavedQueryUpdateSchema = UpdateSchema>; +export type ThresholdUpdateSchema = UpdateSchema>; +export type MachineLearningUpdateSchema = UpdateSchema< + t.TypeOf +>; const patchTypeSpecific = t.union([ eqlPatchParams, @@ -406,26 +375,23 @@ const responseTypeSpecific = t.union([ ]); export type ResponseTypeSpecific = t.TypeOf; -export const updateRulesSchema = t.intersection([ - commonCreateParams, - createTypeSpecific, - t.exact(t.partial({ id })), -]); +export const updateRulesSchema = t.intersection([createTypeSpecific, sharedUpdateSchema]); export type UpdateRulesSchema = t.TypeOf; export const fullPatchSchema = t.intersection([ - commonPatchParams, + basePatchParams, patchTypeSpecific, t.exact(t.partial({ id })), ]); const responseRequiredFields = { id, + rule_id, immutable, updated_at, - updated_by: updatedByOrNull, + updated_by, created_at, - created_by: createdByOrNull, + created_by, }; const responseOptionalFields = { status: job_status, @@ -437,7 +403,7 @@ const responseOptionalFields = { }; export const fullResponseSchema = t.intersection([ - commonResponseParams, + baseResponseParams, responseTypeSpecific, t.exact(t.type(responseRequiredFields)), t.exact(t.partial(responseOptionalFields)), diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.mocks.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.mocks.ts deleted file mode 100644 index 67964a7ab26c3..0000000000000 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.mocks.ts +++ /dev/null @@ -1,16 +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 { FindRulesSchema } from './find_rules_schema'; -import { getRulesSchemaMock } from './rules_schema.mocks'; - -export const getFindRulesSchemaMock = (): FindRulesSchema => ({ - page: 1, - perPage: 1, - total: 1, - data: [getRulesSchemaMock()], -}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.test.ts deleted file mode 100644 index f9cd405db935d..0000000000000 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.test.ts +++ /dev/null @@ -1,128 +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 { findRulesSchema, FindRulesSchema } from './find_rules_schema'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { left } from 'fp-ts/lib/Either'; -import { RulesSchema } from './rules_schema'; -import { exactCheck } from '../../../exact_check'; -import { foldLeftRight, getPaths } from '../../../test_utils'; -import { getRulesSchemaMock } from './rules_schema.mocks'; -import { getFindRulesSchemaMock } from './find_rules_schema.mocks'; - -describe('find_rules_schema', () => { - test('it should validate a typical single find rules response', () => { - const payload = getFindRulesSchemaMock(); - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(getFindRulesSchemaMock()); - }); - - test('it should validate an empty find rules response', () => { - const payload = getFindRulesSchemaMock(); - payload.data = []; - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - const expected = getFindRulesSchemaMock(); - expected.data = []; - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(expected); - }); - - test('it should invalidate a typical single find rules response if it is has an extra property on it', () => { - const payload: FindRulesSchema & { invalid_data?: 'invalid' } = getFindRulesSchemaMock(); - payload.invalid_data = 'invalid'; - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['invalid keys "invalid_data"']); - expect(message.schema).toEqual({}); - }); - - test('it should invalidate a typical single find rules response if the rules are invalid within it', () => { - const payload = getFindRulesSchemaMock(); - const invalidRule: RulesSchema & { invalid_extra_data?: string } = getRulesSchemaMock(); - invalidRule.invalid_extra_data = 'invalid_data'; - payload.data = [invalidRule]; - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['invalid keys "invalid_extra_data"']); - expect(message.schema).toEqual({}); - }); - - test('it should invalidate a typical single find rules response if the rule is missing a required field such as name', () => { - const payload = getFindRulesSchemaMock(); - const invalidRule = getRulesSchemaMock(); - // @ts-expect-error - delete invalidRule.name; - payload.data = [invalidRule]; - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "name"', - ]); - expect(message.schema).toEqual({}); - }); - - test('it should invalidate a typical single find rules response if it is missing perPage', () => { - const payload = getFindRulesSchemaMock(); - // @ts-expect-error - delete payload.perPage; - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "perPage"', - ]); - expect(message.schema).toEqual({}); - }); - - test('it should invalidate a typical single find rules response if it has a negative perPage number', () => { - const payload = getFindRulesSchemaMock(); - payload.perPage = -1; - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to "perPage"']); - expect(message.schema).toEqual({}); - }); - - test('it should invalidate a typical single find rules response if it has a negative page number', () => { - const payload = getFindRulesSchemaMock(); - payload.page = -1; - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to "page"']); - expect(message.schema).toEqual({}); - }); - - test('it should invalidate a typical single find rules response if it has a negative total', () => { - const payload = getFindRulesSchemaMock(); - payload.total = -1; - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to "total"']); - expect(message.schema).toEqual({}); - }); -}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.ts deleted file mode 100644 index c477bc108a7d2..0000000000000 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.ts +++ /dev/null @@ -1,22 +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 * as t from 'io-ts'; - -import { rulesSchema } from './rules_schema'; -import { page, perPage, total } from '../common/schemas'; - -export const findRulesSchema = t.exact( - t.type({ - page, - perPage, - total, - data: t.array(rulesSchema), - }) -); - -export type FindRulesSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts index 021cab086438c..fa8ebaf597f47 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts @@ -6,7 +6,6 @@ */ export * from './error_schema'; -export * from './find_rules_schema'; export * from './import_rules_schema'; export * from './prepackaged_rules_schema'; export * from './prepackaged_rules_status_schema'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.ts index a2c362b08dc7a..1f4e4e140ce18 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/utils.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/utils.ts @@ -12,7 +12,7 @@ import { EntriesArray, ExceptionListItemSchema, } from '../shared_imports'; -import { Type, JobStatus } from './schemas/common/schemas'; +import { Type, JobStatus, Threshold, ThresholdNormalized } from './schemas/common/schemas'; export const hasLargeValueItem = ( exceptionItems: Array @@ -55,5 +55,12 @@ export const normalizeThresholdField = ( : [thresholdField!]; }; +export const normalizeThresholdObject = (threshold: Threshold): ThresholdNormalized => { + return { + ...threshold, + field: normalizeThresholdField(threshold.field), + }; +}; + export const getRuleStatusText = (value: JobStatus | null | undefined): JobStatus | null => value === 'partial failure' ? 'warning' : value != null ? value : null; diff --git a/x-pack/plugins/security_solution/common/validate.ts b/x-pack/plugins/security_solution/common/validate.ts index 79a0351b824e8..1ac41ecbfb88b 100644 --- a/x-pack/plugins/security_solution/common/validate.ts +++ b/x-pack/plugins/security_solution/common/validate.ts @@ -27,7 +27,7 @@ export const validate = ( }; export const validateNonExact = ( - obj: object, + obj: unknown, schema: T ): [t.TypeOf | null, string | null] => { const decoded = schema.decode(obj); diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 68c7796f7ca3b..e85b3f45b4ea6 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -332,5 +332,5 @@ export const editedRule = { export const expectedExportedRule = (ruleResponse: Cypress.Response) => { const jsonrule = ruleResponse.body; - return `{"author":[],"actions":[],"created_at":"${jsonrule.created_at}","updated_at":"${jsonrule.updated_at}","created_by":"elastic","description":"${jsonrule.description}","enabled":false,"false_positives":[],"from":"now-17520h","id":"${jsonrule.id}","immutable":false,"index":["exceptions-*"],"interval":"10s","rule_id":"rule_testing","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":${jsonrule.risk_score},"risk_score_mapping":[],"name":"${jsonrule.name}","query":"${jsonrule.query}","references":[],"severity":"${jsonrule.severity}","severity_mapping":[],"updated_by":"elastic","tags":[],"to":"now","type":"query","threat":[],"throttle":"no_actions","version":1,"exceptions_list":[]}\n{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n`; + return `{"id":"${jsonrule.id}","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","created_at":"${jsonrule.created_at}","created_by":"elastic","name":"${jsonrule.name}","tags":[],"interval":"10s","enabled":false,"description":"${jsonrule.description}","risk_score":${jsonrule.risk_score},"severity":"${jsonrule.severity}","output_index":".siem-signals-default","author":[],"false_positives":[],"from":"now-17520h","rule_id":"rule_testing","max_signals":100,"risk_score_mapping":[],"severity_mapping":[],"threat":[],"to":"now","references":[],"version":1,"exceptions_list":[],"immutable":false,"type":"query","language":"kuery","index":["exceptions-*"],"query":"${jsonrule.query}","throttle":"no_actions","actions":[]}\n{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n`; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts index 762d7e724f80a..8d9779672c3aa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts @@ -6,7 +6,7 @@ */ import { loggingSystemMock } from 'src/core/server/mocks'; -import { getResult } from '../routes/__mocks__/request_responses'; +import { getAlertMock } from '../routes/__mocks__/request_responses'; import { rulesNotificationAlertType } from './rules_notification_alert_type'; import { buildSignalsSearchQuery } from './build_signals_query'; import { alertsMock, AlertServicesMock } from '../../../../../alerting/server/mocks'; @@ -19,6 +19,7 @@ import { import { DEFAULT_RULE_NOTIFICATION_QUERY_SIZE } from '../../../../common/constants'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; jest.mock('./build_signals_query'); describe('rules_notification_alert_type', () => { @@ -65,7 +66,7 @@ describe('rules_notification_alert_type', () => { }); it('should call buildSignalsSearchQuery with proper params', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', @@ -92,7 +93,7 @@ describe('rules_notification_alert_type', () => { }); it('should resolve results_link when meta is undefined to use "/app/security"', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); delete ruleAlert.params.meta; alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'rule-id', @@ -120,7 +121,7 @@ describe('rules_notification_alert_type', () => { }); it('should resolve results_link when meta is an empty object to use "/app/security"', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); ruleAlert.params.meta = {}; alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'rule-id', @@ -147,7 +148,7 @@ describe('rules_notification_alert_type', () => { }); it('should resolve results_link to custom kibana link when given one', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); ruleAlert.params.meta = { kibana_siem_app_url: 'http://localhost', }; @@ -176,7 +177,7 @@ describe('rules_notification_alert_type', () => { }); it('should not call alertInstanceFactory if signalsCount was 0', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', @@ -193,7 +194,7 @@ describe('rules_notification_alert_type', () => { }); it('should call scheduleActions if signalsCount was greater than 0', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts index 799fb3814f1f0..c1393924e3d29 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts @@ -14,7 +14,7 @@ import { } from '../../../../common/constants'; import { NotificationAlertTypeDefinition } from './types'; -import { RuleAlertAttributes } from '../signals/types'; +import { AlertAttributes } from '../signals/types'; import { siemRuleActionGroups } from '../signals/siem_rule_action_groups'; import { scheduleNotificationActions } from './schedule_notification_actions'; import { getNotificationResultsLink } from './utils'; @@ -38,7 +38,7 @@ export const rulesNotificationAlertType = ({ }, minimumLicenseRequired: 'basic', async executor({ startedAt, previousStartedAt, alertId, services, params }) { - const ruleAlertSavedObject = await services.savedObjectsClient.get( + const ruleAlertSavedObject = await services.savedObjectsClient.get( 'alert', params.ruleAlertId ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.ts index 729de70b5f9c4..e7db10380eea1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.ts @@ -7,10 +7,10 @@ import { mapKeys, snakeCase } from 'lodash/fp'; import { AlertInstance } from '../../../../../alerting/server'; +import { RuleParams } from '../schemas/rule_schemas'; import { SignalSource } from '../signals/types'; -import { RuleTypeParams } from '../types'; -export type NotificationRuleTypeParams = RuleTypeParams & { +export type NotificationRuleTypeParams = RuleParams & { name: string; id: string; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/types.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/types.test.ts index 0eb4cf70935d0..a8678c664f331 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/types.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/types.test.ts @@ -6,9 +6,10 @@ */ import { loggingSystemMock } from 'src/core/server/mocks'; -import { getNotificationResult, getResult } from '../routes/__mocks__/request_responses'; +import { getNotificationResult, getAlertMock } from '../routes/__mocks__/request_responses'; import { isAlertTypes, isNotificationAlertExecutor } from './types'; import { rulesNotificationAlertType } from './rules_notification_alert_type'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; describe('types', () => { it('isAlertTypes should return true if is RuleNotificationAlertType type', () => { @@ -16,7 +17,7 @@ describe('types', () => { }); it('isAlertTypes should return false if is not RuleNotificationAlertType', () => { - expect(isAlertTypes([getResult()])).toEqual(false); + expect(isAlertTypes([getAlertMock(getQueryRuleParams())])).toEqual(false); }); it('isNotificationAlertExecutor should return true it passed object is NotificationAlertTypeDefinition type', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 649ce9ed64365..4337725101917 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -31,10 +31,11 @@ import { QuerySignalsSchemaDecoded } from '../../../../../common/detection_engin import { SetSignalsStatusSchemaDecoded } from '../../../../../common/detection_engine/schemas/request/set_signal_status_schema'; import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock'; import { getFinalizeSignalsMigrationSchemaMock } from '../../../../../common/detection_engine/schemas/request/finalize_signals_migration_schema.mock'; -import { getListArrayMock } from '../../../../../common/detection_engine/schemas/types/lists.mock'; import { EqlSearchResponse } from '../../../../../common/detection_engine/types'; -import { getThreatMock } from '../../../../../common/detection_engine/schemas/types/threat.mock'; import { getSignalsMigrationStatusSchemaMock } from '../../../../../common/detection_engine/schemas/request/get_signals_migration_status_schema.mock'; +import { RuleParams } from '../../schemas/rule_schemas'; +import { Alert } from '../../../../../../alerting/common'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; export const typicalSetStatusSignalByIdsPayload = (): SetSignalsStatusSchemaDecoded => ({ signal_ids: ['somefakeid1', 'somefakeid2'], @@ -171,7 +172,7 @@ export const getFindResultWithSingleHit = (): FindHit => ({ page: 1, perPage: 1, total: 1, - data: [getResult()], + data: [getAlertMock(getQueryRuleParams())], }); export const nonRuleFindResult = (): FindHit => ({ @@ -337,71 +338,20 @@ export const createActionResult = (): ActionResult => ({ }); export const nonRuleAlert = () => ({ - ...getResult(), + // Defaulting to QueryRuleParams because ts doesn't like empty objects + ...getAlertMock(getQueryRuleParams()), id: '04128c15-0d1b-4716-a4c5-46997ac7f3bc', name: 'Non-Rule Alert', alertTypeId: 'something', }); -export const getResult = (): RuleAlertType => ({ +export const getAlertMock = (params: T): Alert => ({ id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', name: 'Detect Root/Admin Users', tags: [`${INTERNAL_RULE_ID_KEY}:rule-1`, `${INTERNAL_IMMUTABLE_KEY}:false`], alertTypeId: 'siem.signals', consumer: 'siem', - params: { - author: ['Elastic'], - buildingBlockType: undefined, - anomalyThreshold: undefined, - description: 'Detecting root and admin users', - ruleId: 'rule-1', - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - eventCategoryOverride: undefined, - falsePositives: [], - from: 'now-6m', - immutable: false, - savedId: undefined, - query: 'user.name: root or user.name: admin', - language: 'kuery', - license: 'Elastic License', - machineLearningJobId: undefined, - outputIndex: '.siem-signals', - timelineId: 'some-timeline-id', - timelineTitle: 'some-timeline-title', - meta: { someMeta: 'someField' }, - filters: [ - { - query: { - match_phrase: { - 'host.name': 'some-host', - }, - }, - }, - ], - riskScore: 50, - riskScoreMapping: [], - ruleNameOverride: undefined, - maxSignals: 100, - severity: 'high', - severityMapping: [], - to: 'now', - type: 'query', - threat: getThreatMock(), - threshold: undefined, - timestampOverride: undefined, - threatFilters: undefined, - threatMapping: undefined, - threatLanguage: undefined, - threatIndex: undefined, - threatIndicatorPath: undefined, - threatQuery: undefined, - references: ['http://www.example.com', 'https://ww.example.com'], - note: '# Investigative notes', - version: 1, - exceptionsList: getListArrayMock(), - concurrentSearches: undefined, - itemsPerSearch: undefined, - }, + params, createdAt: new Date('2019-12-13T16:40:33.400Z'), updatedAt: new Date('2019-12-13T16:40:33.400Z'), schedule: { interval: '5m' }, @@ -422,53 +372,6 @@ export const getResult = (): RuleAlertType => ({ }, }); -export const getMlResult = (): RuleAlertType => { - const result = getResult(); - - return { - ...result, - params: { - ...result.params, - query: undefined, - language: undefined, - filters: undefined, - index: undefined, - type: 'machine_learning', - anomalyThreshold: 44, - machineLearningJobId: 'some_job_id', - }, - }; -}; - -export const getThresholdResult = (): RuleAlertType => { - const result = getResult(); - - return { - ...result, - params: { - ...result.params, - type: 'threshold', - threshold: { - field: 'host.ip', - value: 5, - }, - }, - }; -}; - -export const getEqlResult = (): RuleAlertType => { - const result = getResult(); - - return { - ...result, - params: { - ...result.params, - type: 'eql', - query: 'process where true', - }, - }; -}; - export const updateActionResult = (): ActionResult => ({ id: 'result-1', actionTypeId: 'action-id-1', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts index 662be3e8c7ab4..0dcecf3fe3789 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts @@ -40,6 +40,7 @@ export const getOutputRuleAlertForRest = (): Omit< > => ({ author: ['Elastic'], actions: [], + building_block_type: 'default', created_by: 'elastic', created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', @@ -54,15 +55,23 @@ export const getOutputRuleAlertForRest = (): Omit< risk_score: 50, risk_score_mapping: [], rule_id: 'rule-1', + rule_name_override: undefined, + saved_id: undefined, language: 'kuery', + last_failure_at: undefined, + last_failure_message: undefined, + last_success_at: undefined, + last_success_message: undefined, license: 'Elastic License', - max_signals: 100, + max_signals: 10000, name: 'Detect Root/Admin Users', output_index: '.siem-signals', query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], + references: ['http://example.com', 'https://example.com'], severity: 'high', severity_mapping: [], + status: undefined, + status_date: undefined, updated_by: 'elastic', tags: [], throttle: 'no_actions', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index ef7236084508d..311e2fcc41a0b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -13,7 +13,7 @@ import { getNonEmptyIndex, getFindResultWithSingleHit, getEmptyFindResult, - getResult, + getAlertMock, createBulkMlRuleRequest, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; @@ -21,6 +21,7 @@ import { createRulesBulkRoute } from './create_rules_bulk_route'; import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -36,7 +37,7 @@ describe('create_rules_bulk', () => { clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no existing rules - clients.alertsClient.create.mockResolvedValue(getResult()); // successful creation + clients.alertsClient.create.mockResolvedValue(getAlertMock(getQueryRuleParams())); // successful creation // eslint-disable-next-line @typescript-eslint/no-explicit-any (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValue( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts index e54c9a4cbb03e..cd0e1883e78f5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts @@ -23,8 +23,6 @@ import { buildRouteValidation } from '../../../../utils/build_validation/route_v import { transformBulkError, createBulkErrorObject, buildSiemResponse } from '../utils'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { convertCreateAPIToInternalSchema } from '../../schemas/rule_converters'; -import { RuleTypeParams } from '../../types'; -import { Alert } from '../../../../../../alerting/common'; export const createRulesBulkRoute = ( router: SecuritySolutionPluginRouter, @@ -101,12 +99,9 @@ export const createRulesBulkRoute = ( }); } - /** - * TODO: Remove this use of `as` by utilizing the proper type - */ - const createdRule = (await alertsClient.create({ + const createdRule = await alertsClient.create({ data: internalRule, - })) as Alert; + }); const ruleActions = await updateRulesNotifications({ ruleAlertId: createdRule.id, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index d6693dc1f7a0b..b04f178363f99 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -8,7 +8,7 @@ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { getEmptyFindResult, - getResult, + getAlertMock, getCreateRequest, getFindResultStatus, getNonEmptyIndex, @@ -23,6 +23,7 @@ import { updateRulesNotifications } from '../../rules/update_rules_notifications import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../rules/update_rules_notifications'); jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -38,7 +39,7 @@ describe('create_rules', () => { clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no current rules - clients.alertsClient.create.mockResolvedValue(getResult()); // creation succeeds + clients.alertsClient.create.mockResolvedValue(getAlertMock(getQueryRuleParams())); // creation succeeds clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); // needed to transform // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts index 95539319b5a12..1e34bbbbe4749 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -20,8 +20,6 @@ import { createRulesSchema } from '../../../../../common/detection_engine/schema import { newTransformValidate } from './validate'; import { createRuleValidateTypeDependents } from '../../../../../common/detection_engine/schemas/request/create_rules_type_dependents'; import { convertCreateAPIToInternalSchema } from '../../schemas/rule_converters'; -import { RuleTypeParams } from '../../types'; -import { Alert } from '../../../../../../alerting/common'; export const createRulesRoute = ( router: SecuritySolutionPluginRouter, @@ -91,12 +89,9 @@ export const createRulesRoute = ( // This will create the endpoint list if it does not exist yet await context.lists?.getExceptionListClient().createEndpointList(); - /** - * TODO: Remove this use of `as` by utilizing the proper type - */ - const createdRule = (await alertsClient.create({ + const createdRule = await alertsClient.create({ data: internalRule, - })) as Alert; + }); const ruleActions = await updateRulesNotifications({ ruleAlertId: createdRule.id, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts index 72aec9471c4a0..e820487dc0c5d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts @@ -8,7 +8,7 @@ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { getEmptyFindResult, - getResult, + getAlertMock, getDeleteRequest, getFindResultWithSingleHit, getDeleteRequestById, @@ -16,6 +16,7 @@ import { } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { deleteRulesRoute } from './delete_rules_route'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; describe('delete_rules', () => { let server: ReturnType; @@ -39,7 +40,7 @@ describe('delete_rules', () => { }); test('returns 200 when deleting a single rule with a valid actionClient and alertClient by id', async () => { - clients.alertsClient.get.mockResolvedValue(getResult()); + clients.alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); const response = await server.inject(getDeleteRequestById(), context); expect(response.status).toEqual(200); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.ts index d48eb0ddfa59d..3bd7c7f8730b3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.ts @@ -14,8 +14,7 @@ import { buildRouteValidation } from '../../../../utils/build_validation/route_v import type { SecuritySolutionPluginRouter } from '../../../../types'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { deleteRules } from '../../rules/delete_rules'; -import { getIdError } from './utils'; -import { transformValidate } from './validate'; +import { getIdError, transform } from './utils'; import { transformError, buildSiemResponse } from '../utils'; import { deleteNotifications } from '../../notifications/delete_notifications'; import { deleteRuleActionsSavedObject } from '../../rule_actions/delete_rule_actions_saved_object'; @@ -69,15 +68,11 @@ export const deleteRulesRoute = (router: SecuritySolutionPluginRouter) => { searchFields: ['alertId'], }); ruleStatuses.saved_objects.forEach(async (obj) => ruleStatusClient.delete(obj.id)); - const [validated, errors] = transformValidate( - rule, - undefined, - ruleStatuses.saved_objects[0] - ); - if (errors != null) { - return siemResponse.error({ statusCode: 500, body: errors }); + const transformed = transform(rule, undefined, ruleStatuses.saved_objects[0]); + if (transformed == null) { + return siemResponse.error({ statusCode: 500, body: 'failed to transform alert' }); } else { - return response.ok({ body: validated ?? {} }); + return response.ok({ body: transformed ?? {} }); } } else { const error = getIdError({ id, ruleId }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.test.ts index f44df412b7fb1..434ef0f88b196 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.test.ts @@ -7,13 +7,14 @@ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { - getResult, + getAlertMock, getFindRequest, getFindResultWithSingleHit, getFindResultStatus, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { findRulesRoute } from './find_rules_route'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../signals/rule_status_service'); describe('find_rules', () => { @@ -25,7 +26,7 @@ describe('find_rules', () => { ({ clients, context } = requestContextMock.createTools()); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); - clients.alertsClient.get.mockResolvedValue(getResult()); + clients.alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); findRulesRoute(server.router); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts index 33d566ab6f0c7..c3a53a1f393ec 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts @@ -6,11 +6,16 @@ */ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; -import { getFindResultStatus, ruleStatusRequest, getResult } from '../__mocks__/request_responses'; +import { + getFindResultStatus, + ruleStatusRequest, + getAlertMock, +} from '../__mocks__/request_responses'; import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { findRulesStatusesRoute } from './find_rules_status_route'; import { RuleStatusResponse } from '../../rules/types'; import { AlertExecutionStatusErrorReasons } from '../../../../../../alerting/common'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../signals/rule_status_service'); @@ -22,7 +27,7 @@ describe('find_statuses', () => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); // successful status search - clients.alertsClient.get.mockResolvedValue(getResult()); + clients.alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); findRulesStatusesRoute(server.router); }); @@ -54,7 +59,7 @@ describe('find_statuses', () => { test('returns success if rule status client writes an error status', async () => { // 0. task manager tried to run the rule but couldn't, so the alerting framework // wrote an error to the executionStatus. - const failingExecutionRule = getResult(); + const failingExecutionRule = getAlertMock(getQueryRuleParams()); failingExecutionRule.executionStatus = { status: 'error', lastExecutionDate: failingExecutionRule.executionStatus.lastExecutionDate, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index b0b4232651803..0a680d1b0d1c1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -10,7 +10,7 @@ import { getImportRulesRequest, getImportRulesRequestOverwriteTrue, getEmptyFindResult, - getResult, + getAlertMock, getFindResultWithSingleHit, getNonEmptyIndex, } from '../__mocks__/request_responses'; @@ -26,6 +26,7 @@ import { } from '../../../../../common/detection_engine/schemas/request/import_rules_schema.mock'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -170,7 +171,7 @@ describe('import_rules_route', () => { describe('single rule import', () => { test('returns 200 if rule imported successfully', async () => { - clients.alertsClient.create.mockResolvedValue(getResult()); + clients.alertsClient.create.mockResolvedValue(getAlertMock(getQueryRuleParams())); const response = await server.inject(request, context); expect(response.status).toEqual(200); expect(response.body).toEqual({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts index 93fdf9c5f8194..b83dad92d43b5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts @@ -12,12 +12,13 @@ import { getEmptyFindResult, getFindResultWithSingleHit, getPatchBulkRequest, - getResult, + getAlertMock, typicalMlRulePayload, } from '../__mocks__/request_responses'; import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { patchRulesBulkRoute } from './patch_rules_bulk_route'; import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -32,7 +33,7 @@ describe('patch_rules_bulk', () => { ml = mlServicesMock.createSetupContract(); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // rule exists - clients.alertsClient.update.mockResolvedValue(getResult()); // update succeeds + clients.alertsClient.update.mockResolvedValue(getAlertMock(getQueryRuleParams())); // update succeeds patchRulesBulkRoute(server.router, ml); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts index 6e62f65f44858..2fa72ae2a097e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts @@ -11,7 +11,7 @@ import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, getFindResultStatus, - getResult, + getAlertMock, getPatchRequest, getFindResultWithSingleHit, nonRuleFindResult, @@ -20,6 +20,7 @@ import { import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { patchRulesRoute } from './patch_rules_route'; import { getPatchRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/patch_rules_schema.mock'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -33,9 +34,9 @@ describe('patch_rules', () => { ({ clients, context } = requestContextMock.createTools()); ml = mlServicesMock.createSetupContract(); - clients.alertsClient.get.mockResolvedValue(getResult()); // existing rule + clients.alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); // existing rule clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // existing rule - clients.alertsClient.update.mockResolvedValue(getResult()); // successful update + clients.alertsClient.update.mockResolvedValue(getAlertMock(getQueryRuleParams())); // successful update clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); // successful transform patchRulesRoute(server.router, ml); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts index 41b31b04e3424..a57bed7a895f9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts @@ -10,7 +10,7 @@ import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../mach import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, - getResult, + getAlertMock, getFindResultWithSingleHit, getUpdateBulkRequest, getFindResultStatus, @@ -20,6 +20,7 @@ import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { updateRulesBulkRoute } from './update_rules_bulk_route'; import { BulkError } from '../utils'; import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -34,7 +35,7 @@ describe('update_rules_bulk', () => { ml = mlServicesMock.createSetupContract(); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); - clients.alertsClient.update.mockResolvedValue(getResult()); + clients.alertsClient.update.mockResolvedValue(getAlertMock(getQueryRuleParams())); clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); updateRulesBulkRoute(server.router, ml); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts index c80d32e09ccab..cf121d1610d39 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts @@ -9,7 +9,7 @@ import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../mach import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, - getResult, + getAlertMock, getUpdateRequest, getFindResultWithSingleHit, getFindResultStatusEmpty, @@ -21,6 +21,7 @@ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { updateRulesRoute } from './update_rules_route'; import { getUpdateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); jest.mock('../../rules/update_rules_notifications'); @@ -35,9 +36,9 @@ describe('update_rules', () => { ({ clients, context } = requestContextMock.createTools()); ml = mlServicesMock.createSetupContract(); - clients.alertsClient.get.mockResolvedValue(getResult()); // existing rule + clients.alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); // existing rule clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // rule exists - clients.alertsClient.update.mockResolvedValue(getResult()); // successful update + clients.alertsClient.update.mockResolvedValue(getAlertMock(getQueryRuleParams())); // successful update clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatusEmpty()); // successful transform updateRulesRoute(server.router, ml); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts index cf7d2e9eea2fa..ffa699daf9c95 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts @@ -21,9 +21,9 @@ import { getDuplicates, getTupleDuplicateErrorsAndUniqueRules, } from './utils'; -import { getResult } from '../__mocks__/request_responses'; +import { getAlertMock } from '../__mocks__/request_responses'; import { INTERNAL_IDENTIFIER } from '../../../../../common/constants'; -import { PartialFilter, RuleTypeParams } from '../../types'; +import { PartialFilter } from '../../types'; import { BulkError, ImportSuccessError } from '../utils'; import { getOutputRuleAlertForRest } from '../__mocks__/utils'; import { PartialAlert } from '../../../../../../alerting/server'; @@ -34,58 +34,32 @@ import { ImportRulesSchemaDecoded } from '../../../../../common/detection_engine import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock'; import { ThreatMapping } from '../../../../../common/detection_engine/schemas/types/threat_mapping'; import { CreateRulesBulkSchema } from '../../../../../common/detection_engine/schemas/request'; +import { + getMlRuleParams, + getQueryRuleParams, + getThreatRuleParams, +} from '../../schemas/rule_schemas.mock'; type PromiseFromStreams = ImportRulesSchemaDecoded | Error; describe('utils', () => { describe('transformAlertToRule', () => { test('should work with a full data set', () => { - const fullRule = getResult(); + const fullRule = getAlertMock(getQueryRuleParams()); const rule = transformAlertToRule(fullRule); expect(rule).toEqual(getOutputRuleAlertForRest()); }); - test('should work with a partial data set missing data', () => { - const fullRule = getResult(); - const { from, language, ...omitParams } = fullRule.params; - fullRule.params = omitParams as RuleTypeParams; + test('should omit note if note is undefined', () => { + const fullRule = getAlertMock(getQueryRuleParams()); + fullRule.params.note = undefined; const rule = transformAlertToRule(fullRule); - const { - from: from2, - language: language2, - ...expectedWithoutFromWithoutLanguage - } = getOutputRuleAlertForRest(); - expect(rule).toEqual(expectedWithoutFromWithoutLanguage); - }); - - test('should omit query if query is undefined', () => { - const fullRule = getResult(); - fullRule.params.query = undefined; - const rule = transformAlertToRule(fullRule); - const { query, ...expectedWithoutQuery } = getOutputRuleAlertForRest(); - expect(rule).toEqual(expectedWithoutQuery); - }); - - test('should omit a mix of undefined, null, and missing fields', () => { - const fullRule = getResult(); - fullRule.params.query = undefined; - fullRule.params.language = undefined; - const { from, ...omitParams } = fullRule.params; - fullRule.params = omitParams as RuleTypeParams; - const { enabled, ...omitEnabled } = fullRule; - const rule = transformAlertToRule(omitEnabled as RuleAlertType); - const { - from: from2, - enabled: enabled2, - language, - query, - ...expectedWithoutFromEnabledLanguageQuery - } = getOutputRuleAlertForRest(); - expect(rule).toEqual(expectedWithoutFromEnabledLanguageQuery); + const { note, ...expectedWithoutNote } = getOutputRuleAlertForRest(); + expect(rule).toEqual(expectedWithoutNote); }); test('should return enabled is equal to false', () => { - const fullRule = getResult(); + const fullRule = getAlertMock(getQueryRuleParams()); fullRule.enabled = false; const ruleWithEnabledFalse = transformAlertToRule(fullRule); const expected = getOutputRuleAlertForRest(); @@ -94,7 +68,7 @@ describe('utils', () => { }); test('should return immutable is equal to false', () => { - const fullRule = getResult(); + const fullRule = getAlertMock(getQueryRuleParams()); fullRule.params.immutable = false; const ruleWithEnabledFalse = transformAlertToRule(fullRule); const expected = getOutputRuleAlertForRest(); @@ -102,7 +76,7 @@ describe('utils', () => { }); test('should work with tags but filter out any internal tags', () => { - const fullRule = getResult(); + const fullRule = getAlertMock(getQueryRuleParams()); fullRule.tags = ['tag 1', 'tag 2', `${INTERNAL_IDENTIFIER}_some_other_value`]; const rule = transformAlertToRule(fullRule); const expected = getOutputRuleAlertForRest(); @@ -111,7 +85,7 @@ describe('utils', () => { }); test('transforms ML Rule fields', () => { - const mlRule = getResult(); + const mlRule = getAlertMock(getMlRuleParams()); mlRule.params.anomalyThreshold = 55; mlRule.params.machineLearningJobId = 'some_job_id'; mlRule.params.type = 'machine_learning'; @@ -127,7 +101,7 @@ describe('utils', () => { }); test('transforms threat_matching fields', () => { - const threatRule = getResult(); + const threatRule = getAlertMock(getThreatRuleParams()); const threatFilters: PartialFilter[] = [ { query: { @@ -178,7 +152,10 @@ describe('utils', () => { // This has to stay here until we do data migration of saved objects and lists is removed from: // signal_params_schema.ts test('does not leak a lists structure in the transform which would cause validation issues', () => { - const result: RuleAlertType & { lists: [] } = { lists: [], ...getResult() }; + const result: RuleAlertType & { lists: [] } = { + lists: [], + ...getAlertMock(getQueryRuleParams()), + }; const rule = transformAlertToRule(result); expect(rule).toEqual( expect.not.objectContaining({ @@ -192,7 +169,7 @@ describe('utils', () => { test('does not leak an exceptions_list structure in the transform which would cause validation issues', () => { const result: RuleAlertType & { exceptions_list: [] } = { exceptions_list: [], - ...getResult(), + ...getAlertMock(getQueryRuleParams()), }; const rule = transformAlertToRule(result); expect(rule).toEqual( @@ -289,7 +266,7 @@ describe('utils', () => { page: 1, perPage: 0, total: 0, - data: [getResult()], + data: [getAlertMock(getQueryRuleParams())], }, [] ); @@ -319,7 +296,7 @@ describe('utils', () => { describe('transform', () => { test('outputs 200 if the data is of type siem alert', () => { - const output = transform(getResult()); + const output = transform(getAlertMock(getQueryRuleParams())); const expected = getOutputRuleAlertForRest(); expect(output).toEqual(expected); }); @@ -434,7 +411,7 @@ describe('utils', () => { describe('transformOrBulkError', () => { test('outputs 200 if the data is of type siem alert', () => { - const output = transformOrBulkError('rule-1', getResult(), { + const output = transformOrBulkError('rule-1', getAlertMock(getQueryRuleParams()), { id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', actions: [], ruleThrottle: 'no_actions', @@ -466,15 +443,15 @@ describe('utils', () => { }); test('given single alert will return the alert transformed', () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); const transformed = transformAlertsToRules([result1]); const expected = getOutputRuleAlertForRest(); expect(transformed).toEqual([expected]); }); test('given two alerts will return the two alerts transformed', () => { - const result1 = getResult(); - const result2 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = 'some other id'; result2.params.ruleId = 'some other id'; @@ -489,7 +466,7 @@ describe('utils', () => { describe('transformOrImportError', () => { test('returns 1 given success if the alert is an alert type and the existing success count is 0', () => { - const output = transformOrImportError('rule-1', getResult(), { + const output = transformOrImportError('rule-1', getAlertMock(getQueryRuleParams()), { success: true, success_count: 0, errors: [], @@ -503,7 +480,7 @@ describe('utils', () => { }); test('returns 2 given successes if the alert is an alert type and the existing success count is 1', () => { - const output = transformOrImportError('rule-1', getResult(), { + const output = transformOrImportError('rule-1', getAlertMock(getQueryRuleParams()), { success: true, success_count: 1, errors: [], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts index a28cc9bcb9b69..466b8dd184227 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { pickBy, countBy } from 'lodash/fp'; +import { countBy } from 'lodash/fp'; import { SavedObject, SavedObjectsFindResponse } from 'kibana/server'; import uuid from 'uuid'; @@ -32,7 +32,8 @@ import { OutputError, } from '../utils'; import { RuleActions } from '../../rule_actions/types'; -import { RuleTypeParams } from '../../types'; +import { internalRuleToAPIResponse } from '../../schemas/rule_converters'; +import { RuleParams } from '../../schemas/rule_schemas'; type PromiseFromStreams = ImportRulesSchemaDecoded | Error; @@ -106,68 +107,7 @@ export const transformAlertToRule = ( ruleActions?: RuleActions | null, ruleStatus?: SavedObject ): Partial => { - return pickBy((value: unknown) => value != null, { - author: alert.params.author ?? [], - actions: ruleActions?.actions ?? [], - building_block_type: alert.params.buildingBlockType, - created_at: alert.createdAt.toISOString(), - updated_at: alert.updatedAt.toISOString(), - created_by: alert.createdBy ?? 'elastic', - description: alert.params.description, - enabled: alert.enabled, - anomaly_threshold: alert.params.anomalyThreshold, - event_category_override: alert.params.eventCategoryOverride, - false_positives: alert.params.falsePositives, - filters: alert.params.filters, - from: alert.params.from, - id: alert.id, - immutable: alert.params.immutable, - index: alert.params.index, - interval: alert.schedule.interval, - rule_id: alert.params.ruleId, - language: alert.params.language, - license: alert.params.license, - output_index: alert.params.outputIndex, - max_signals: alert.params.maxSignals, - machine_learning_job_id: alert.params.machineLearningJobId, - risk_score: alert.params.riskScore, - risk_score_mapping: alert.params.riskScoreMapping ?? [], - rule_name_override: alert.params.ruleNameOverride, - name: alert.name, - query: alert.params.query, - references: alert.params.references, - saved_id: alert.params.savedId, - timeline_id: alert.params.timelineId, - timeline_title: alert.params.timelineTitle, - meta: alert.params.meta, - severity: alert.params.severity, - severity_mapping: alert.params.severityMapping ?? [], - updated_by: alert.updatedBy ?? 'elastic', - tags: transformTags(alert.tags), - to: alert.params.to, - type: alert.params.type, - threat: alert.params.threat ?? [], - threshold: alert.params.threshold, - threat_filters: alert.params.threatFilters, - threat_index: alert.params.threatIndex, - threat_indicator_path: alert.params.threatIndicatorPath, - threat_query: alert.params.threatQuery, - threat_mapping: alert.params.threatMapping, - threat_language: alert.params.threatLanguage, - concurrent_searches: alert.params.concurrentSearches, - items_per_search: alert.params.itemsPerSearch, - throttle: ruleActions?.ruleThrottle || 'no_actions', - timestamp_override: alert.params.timestampOverride, - note: alert.params.note, - version: alert.params.version, - status: ruleStatus?.attributes.status ?? undefined, - status_date: ruleStatus?.attributes.statusDate, - last_failure_at: ruleStatus?.attributes.lastFailureAt ?? undefined, - last_success_at: ruleStatus?.attributes.lastSuccessAt ?? undefined, - last_failure_message: ruleStatus?.attributes.lastFailureMessage ?? undefined, - last_success_message: ruleStatus?.attributes.lastSuccessMessage ?? undefined, - exceptions_list: alert.params.exceptionsList ?? [], - }); + return internalRuleToAPIResponse(alert, ruleActions, ruleStatus); }; export const transformAlertsToRules = (alerts: RuleAlertType[]): Array> => { @@ -175,7 +115,7 @@ export const transformAlertsToRules = (alerts: RuleAlertType[]): Array, + findResults: FindResult, ruleActions: Array, ruleStatuses?: Array> ): { @@ -206,7 +146,7 @@ export const transformFindAlerts = ( }; export const transform = ( - alert: PartialAlert, + alert: PartialAlert, ruleActions?: RuleActions | null, ruleStatus?: SavedObject ): Partial | null => { @@ -223,7 +163,7 @@ export const transform = ( export const transformOrBulkError = ( ruleId: string, - alert: PartialAlert, + alert: PartialAlert, ruleActions: RuleActions, ruleStatus?: unknown ): Partial | BulkError => { @@ -244,7 +184,7 @@ export const transformOrBulkError = ( export const transformOrImportError = ( ruleId: string, - alert: PartialAlert, + alert: PartialAlert, existingImportSuccessError: ImportSuccessError ): ImportSuccessError => { if (isAlertType(alert)) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts index 5bb63ada7f9a4..f971a5606f6c6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts @@ -5,22 +5,18 @@ * 2.0. */ -import { - transformValidate, - transformValidateFindAlerts, - transformValidateBulkError, -} from './validate'; -import { FindResult } from '../../../../../../alerting/server'; +import { transformValidate, transformValidateBulkError } from './validate'; import { BulkError } from '../utils'; import { RulesSchema } from '../../../../../common/detection_engine/schemas/response'; -import { getResult, getFindResultStatus } from '../__mocks__/request_responses'; +import { getAlertMock, getFindResultStatus } from '../__mocks__/request_responses'; import { getListArrayMock } from '../../../../../common/detection_engine/schemas/types/lists.mock'; import { getThreatMock } from '../../../../../common/detection_engine/schemas/types/threat.mock'; -import { RuleTypeParams } from '../../types'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; export const ruleOutput = (): RulesSchema => ({ actions: [], author: ['Elastic'], + building_block_type: 'default', created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', created_by: 'elastic', @@ -35,12 +31,12 @@ export const ruleOutput = (): RulesSchema => ({ language: 'kuery', license: 'Elastic License', output_index: '.siem-signals', - max_signals: 100, + max_signals: 10000, risk_score: 50, risk_score_mapping: [], name: 'Detect Root/Admin Users', query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], + references: ['http://example.com', 'https://example.com'], severity: 'high', severity_mapping: [], updated_by: 'elastic', @@ -72,14 +68,14 @@ export const ruleOutput = (): RulesSchema => ({ describe('validate', () => { describe('transformValidate', () => { test('it should do a validation correctly of a partial alert', () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); const [validated, errors] = transformValidate(ruleAlert); expect(validated).toEqual(ruleOutput()); expect(errors).toEqual(null); }); test('it should do an in-validation correctly of a partial alert', () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); // @ts-expect-error delete ruleAlert.name; const [validated, errors] = transformValidate(ruleAlert); @@ -88,54 +84,15 @@ describe('validate', () => { }); }); - describe('transformValidateFindAlerts', () => { - test('it should do a validation correctly of a find alert', () => { - const findResult: FindResult = { - data: [getResult()], - page: 1, - perPage: 0, - total: 0, - }; - const [validated, errors] = transformValidateFindAlerts(findResult, []); - const expected: { - page: number; - perPage: number; - total: number; - data: Array>; - } | null = { - data: [ruleOutput()], - page: 1, - perPage: 0, - total: 0, - }; - expect(validated).toEqual(expected); - expect(errors).toEqual(null); - }); - - test('it should do an in-validation correctly of a partial alert', () => { - const findResult: FindResult = { - data: [getResult()], - page: 1, - perPage: 0, - total: 0, - }; - // @ts-expect-error - delete findResult.page; - const [validated, errors] = transformValidateFindAlerts(findResult, []); - expect(validated).toEqual(null); - expect(errors).toEqual('Invalid value "undefined" supplied to "page"'); - }); - }); - describe('transformValidateBulkError', () => { test('it should do a validation correctly of a rule id', () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); const validatedOrError = transformValidateBulkError('rule-1', ruleAlert); expect(validatedOrError).toEqual(ruleOutput()); }); test('it should do an in-validation correctly of a rule id', () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); // @ts-expect-error delete ruleAlert.name; const validatedOrError = transformValidateBulkError('rule-1', ruleAlert); @@ -151,7 +108,7 @@ describe('validate', () => { test('it should do a validation correctly of a rule id with ruleStatus passed in', () => { const ruleStatus = getFindResultStatus(); - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); const validatedOrError = transformValidateBulkError('rule-1', ruleAlert, null, ruleStatus); const expected: RulesSchema = { ...ruleOutput(), @@ -164,7 +121,7 @@ describe('validate', () => { }); test('it should return error object if "alert" is not expected alert type', () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); // @ts-expect-error delete ruleAlert.alertTypeId; const validatedOrError = transformValidateBulkError('rule-1', ruleAlert); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.ts index cff7413308a4c..ac9ac960d6f06 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.ts @@ -6,23 +6,17 @@ */ import { SavedObject, SavedObjectsFindResponse } from 'kibana/server'; -import { fold } from 'fp-ts/lib/Either'; -import { pipe } from 'fp-ts/lib/pipeable'; -import * as t from 'io-ts'; import { FullResponseSchema, fullResponseSchema, } from '../../../../../common/detection_engine/schemas/request'; -import { validate } from '../../../../../common/validate'; -import { findRulesSchema } from '../../../../../common/detection_engine/schemas/response/find_rules_schema'; +import { validateNonExact } from '../../../../../common/validate'; import { RulesSchema, rulesSchema, } from '../../../../../common/detection_engine/schemas/response/rules_schema'; -import { formatErrors } from '../../../../../common/format_errors'; -import { exactCheck } from '../../../../../common/exact_check'; -import { PartialAlert, FindResult } from '../../../../../../alerting/server'; +import { PartialAlert } from '../../../../../../alerting/server'; import { isAlertType, IRuleSavedAttributesSavedObjectAttributes, @@ -30,42 +24,12 @@ import { IRuleStatusSOAttributes, } from '../../rules/types'; import { createBulkErrorObject, BulkError } from '../utils'; -import { transformFindAlerts, transform, transformAlertToRule } from './utils'; +import { transform, transformAlertToRule } from './utils'; import { RuleActions } from '../../rule_actions/types'; -import { RuleTypeParams } from '../../types'; - -export const transformValidateFindAlerts = ( - findResults: FindResult, - ruleActions: Array, - ruleStatuses?: Array> -): [ - { - page: number; - perPage: number; - total: number; - data: Array>; - } | null, - string | null -] => { - const transformed = transformFindAlerts(findResults, ruleActions, ruleStatuses); - if (transformed == null) { - return [null, 'Internal error transforming']; - } else { - const decoded = findRulesSchema.decode(transformed); - const checked = exactCheck(transformed, decoded); - const left = (errors: t.Errors): string[] => formatErrors(errors); - const right = (): string[] => []; - const piped = pipe(checked, fold(left, right)); - if (piped.length === 0) { - return [transformed, null]; - } else { - return [null, piped.join(',')]; - } - } -}; +import { RuleParams } from '../../schemas/rule_schemas'; export const transformValidate = ( - alert: PartialAlert, + alert: PartialAlert, ruleActions?: RuleActions | null, ruleStatus?: SavedObject ): [RulesSchema | null, string | null] => { @@ -73,12 +37,12 @@ export const transformValidate = ( if (transformed == null) { return [null, 'Internal error transforming']; } else { - return validate(transformed, rulesSchema); + return validateNonExact(transformed, rulesSchema); } }; export const newTransformValidate = ( - alert: PartialAlert, + alert: PartialAlert, ruleActions?: RuleActions | null, ruleStatus?: SavedObject ): [FullResponseSchema | null, string | null] => { @@ -86,13 +50,13 @@ export const newTransformValidate = ( if (transformed == null) { return [null, 'Internal error transforming']; } else { - return validate(transformed, fullResponseSchema); + return validateNonExact(transformed, fullResponseSchema); } }; export const transformValidateBulkError = ( ruleId: string, - alert: PartialAlert, + alert: PartialAlert, ruleActions?: RuleActions | null, ruleStatus?: SavedObjectsFindResponse ): RulesSchema | BulkError => { @@ -103,7 +67,7 @@ export const transformValidateBulkError = ( ruleActions, ruleStatus?.saved_objects[0] ?? ruleStatus ); - const [validated, errors] = validate(transformed, rulesSchema); + const [validated, errors] = validateNonExact(transformed, rulesSchema); if (errors != null || validated == null) { return createBulkErrorObject({ ruleId, @@ -115,7 +79,7 @@ export const transformValidateBulkError = ( } } else { const transformed = transformAlertToRule(alert); - const [validated, errors] = validate(transformed, rulesSchema); + const [validated, errors] = validateNonExact(transformed, rulesSchema); if (errors != null || validated == null) { return createBulkErrorObject({ ruleId, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/utils.test.ts index 43fba889c04d5..cca7e871f5b8b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/utils.test.ts @@ -28,8 +28,9 @@ import { } from './utils'; import { responseMock } from './__mocks__'; import { exampleRuleStatus, exampleFindRuleStatusResponse } from '../signals/__mocks__/es_results'; -import { getResult } from './__mocks__/request_responses'; +import { getAlertMock } from './__mocks__/request_responses'; import { AlertExecutionStatusErrorReasons } from '../../../../../alerting/common'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; let alertsClient: ReturnType; @@ -479,12 +480,12 @@ describe('utils', () => { alertsClient = alertsClientMock.create(); }); it('getFailingRules finds no failing rules', async () => { - alertsClient.get.mockResolvedValue(getResult()); + alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); const res = await getFailingRules(['my-fake-id'], alertsClient); expect(res).toEqual({}); }); it('getFailingRules finds a failing rule', async () => { - const foundRule = getResult(); + const foundRule = getAlertMock(getQueryRuleParams()); foundRule.executionStatus = { status: 'error', lastExecutionDate: foundRule.executionStatus.lastExecutionDate, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts index a654dd6a10e32..2a3d83f4baca7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { normalizeThresholdObject } from '../../../../common/detection_engine/utils'; import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { SanitizedAlert } from '../../../../../alerting/common'; import { SERVER_APP_ID, SIGNALS_ID } from '../../../../common/constants'; @@ -97,7 +98,7 @@ export const createRules = async ({ severity, severityMapping, threat, - threshold, + threshold: threshold ? normalizeThresholdObject(threshold) : undefined, /** * TODO: Fix typing inconsistancy between `RuleTypeParams` and `CreateRulesOptions` */ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/find_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/find_rules.ts index 26151745b00d9..754aaf67c3224 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/find_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/find_rules.ts @@ -7,7 +7,7 @@ import { FindResult } from '../../../../../alerting/server'; import { SIGNALS_ID } from '../../../../common/constants'; -import { RuleTypeParams } from '../types'; +import { RuleParams } from '../schemas/rule_schemas'; import { FindRuleOptions } from './types'; export const getFilter = (filter: string | null | undefined) => { @@ -26,7 +26,7 @@ export const findRules = async ({ filter, sortField, sortOrder, -}: FindRuleOptions): Promise> => { +}: FindRuleOptions): Promise> => { return alertsClient.find({ options: { fields, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_existing_prepackaged_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_existing_prepackaged_rules.test.ts index ead4fac811372..da67bea0ca970 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_existing_prepackaged_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_existing_prepackaged_rules.test.ts @@ -7,10 +7,11 @@ import { alertsClientMock } from '../../../../../alerting/server/mocks'; import { - getResult, + getAlertMock, getFindResultWithSingleHit, getFindResultWithMultiHits, } from '../routes/__mocks__/request_responses'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; import { getExistingPrepackagedRules, getNonPackagedRules, @@ -29,21 +30,21 @@ describe('get_existing_prepackaged_rules', () => { const alertsClient = alertsClientMock.create(); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); const rules = await getExistingPrepackagedRules({ alertsClient }); - expect(rules).toEqual([getResult()]); + expect(rules).toEqual([getAlertMock(getQueryRuleParams())]); }); test('should return 3 items over 1 page with all on one page', async () => { const alertsClient = alertsClientMock.create(); - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.params.immutable = true; result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.params.immutable = true; result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; - const result3 = getResult(); + const result3 = getAlertMock(getQueryRuleParams()); result3.params.immutable = true; result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a'; @@ -77,16 +78,16 @@ describe('get_existing_prepackaged_rules', () => { const alertsClient = alertsClientMock.create(); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); const rules = await getNonPackagedRules({ alertsClient }); - expect(rules).toEqual([getResult()]); + expect(rules).toEqual([getAlertMock(getQueryRuleParams())]); }); test('should return 2 items over 1 page', async () => { const alertsClient = alertsClientMock.create(); - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; // first result mock which is for returning the total @@ -111,13 +112,13 @@ describe('get_existing_prepackaged_rules', () => { test('should return 3 items over 1 page with all on one page', async () => { const alertsClient = alertsClientMock.create(); - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; - const result3 = getResult(); + const result3 = getAlertMock(getQueryRuleParams()); result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a'; // first result mock which is for returning the total @@ -150,16 +151,16 @@ describe('get_existing_prepackaged_rules', () => { const alertsClient = alertsClientMock.create(); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); const rules = await getRules({ alertsClient, filter: '' }); - expect(rules).toEqual([getResult()]); + expect(rules).toEqual([getAlertMock(getQueryRuleParams())]); }); test('should return 2 items over two pages, one per page', async () => { const alertsClient = alertsClientMock.create(); - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; // first result mock which is for returning the total diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts index 8ead079c9502e..4c937b2e4ca8a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts @@ -6,7 +6,7 @@ */ import { - getResult, + getAlertMock, getFindResultWithSingleHit, FindHit, } from '../routes/__mocks__/request_responses'; @@ -14,60 +14,72 @@ import { alertsClientMock } from '../../../../../alerting/server/mocks'; import { getExportAll } from './get_export_all'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; import { getThreatMock } from '../../../../common/detection_engine/schemas/types/threat.mock'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; describe('getExportAll', () => { test('it exports everything from the alerts client', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); - alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + const result = getFindResultWithSingleHit(); + const alert = getAlertMock(getQueryRuleParams()); + alert.params = { + ...alert.params, + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + threat: getThreatMock(), + meta: { someMeta: 'someField' }, + timelineId: 'some-timeline-id', + timelineTitle: 'some-timeline-title', + }; + result.data = [alert]; + alertsClient.find.mockResolvedValue(result); const exports = await getExportAll(alertsClient); - expect(exports).toEqual({ - rulesNdjson: `${JSON.stringify({ - author: ['Elastic'], - actions: [], - created_at: '2019-12-13T16:40:33.400Z', - updated_at: '2019-12-13T16:40:33.400Z', - created_by: 'elastic', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - rule_id: 'rule-1', - language: 'kuery', - license: 'Elastic License', - output_index: '.siem-signals', - max_signals: 100, - risk_score: 50, - risk_score_mapping: [], - name: 'Detect Root/Admin Users', - query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], - timeline_id: 'some-timeline-id', - timeline_title: 'some-timeline-title', - meta: { someMeta: 'someField' }, - severity: 'high', - severity_mapping: [], - updated_by: 'elastic', - tags: [], - to: 'now', - type: 'query', - threat: getThreatMock(), - throttle: 'no_actions', - note: '# Investigative notes', - version: 1, - exceptions_list: getListArrayMock(), - })}\n`, - exportDetails: `${JSON.stringify({ - exported_count: 1, - missing_rules: [], - missing_rules_count: 0, - })}\n`, + const rulesJson = JSON.parse(exports.rulesNdjson); + const detailsJson = JSON.parse(exports.exportDetails); + expect(rulesJson).toEqual({ + author: ['Elastic'], + actions: [], + building_block_type: 'default', + created_at: '2019-12-13T16:40:33.400Z', + updated_at: '2019-12-13T16:40:33.400Z', + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + from: 'now-6m', + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + rule_id: 'rule-1', + language: 'kuery', + license: 'Elastic License', + output_index: '.siem-signals', + max_signals: 10000, + risk_score: 50, + risk_score_mapping: [], + name: 'Detect Root/Admin Users', + query: 'user.name: root or user.name: admin', + references: ['http://example.com', 'https://example.com'], + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', + meta: { someMeta: 'someField' }, + severity: 'high', + severity_mapping: [], + updated_by: 'elastic', + tags: [], + to: 'now', + type: 'query', + threat: getThreatMock(), + throttle: 'no_actions', + note: '# Investigative notes', + version: 1, + exceptions_list: getListArrayMock(), + }); + expect(detailsJson).toEqual({ + exported_count: 1, + missing_rules: [], + missing_rules_count: 0, }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts index 537a45115e83e..b14b805a31fc3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts @@ -7,7 +7,7 @@ import { getExportByObjectIds, getRulesFromObjects, RulesErrors } from './get_export_by_object_ids'; import { - getResult, + getAlertMock, getFindResultWithSingleHit, FindHit, } from '../routes/__mocks__/request_responses'; @@ -15,6 +15,7 @@ import * as readRules from './read_rules'; import { alertsClientMock } from '../../../../../alerting/server/mocks'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; import { getThreatMock } from '../../../../common/detection_engine/schemas/types/threat.mock'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; describe('get_export_by_object_ids', () => { beforeEach(() => { @@ -25,15 +26,19 @@ describe('get_export_by_object_ids', () => { describe('getExportByObjectIds', () => { test('it exports object ids into an expected string with new line characters', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); const objects = [{ rule_id: 'rule-1' }]; const exports = await getExportByObjectIds(alertsClient, objects); - expect(exports).toEqual({ - rulesNdjson: `${JSON.stringify({ + const exportsObj = { + rulesNdjson: JSON.parse(exports.rulesNdjson), + exportDetails: JSON.parse(exports.exportDetails), + }; + expect(exportsObj).toEqual({ + rulesNdjson: { author: ['Elastic'], actions: [], + building_block_type: 'default', created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', created_by: 'elastic', @@ -50,12 +55,12 @@ describe('get_export_by_object_ids', () => { language: 'kuery', license: 'Elastic License', output_index: '.siem-signals', - max_signals: 100, + max_signals: 10000, risk_score: 50, risk_score_mapping: [], name: 'Detect Root/Admin Users', query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], + references: ['http://example.com', 'https://example.com'], timeline_id: 'some-timeline-id', timeline_title: 'some-timeline-title', meta: { someMeta: 'someField' }, @@ -70,18 +75,18 @@ describe('get_export_by_object_ids', () => { note: '# Investigative notes', version: 1, exceptions_list: getListArrayMock(), - })}\n`, - exportDetails: `${JSON.stringify({ + }, + exportDetails: { exported_count: 1, missing_rules: [], missing_rules_count: 0, - })}\n`, + }, }); }); test('it does not export immutable rules', async () => { const alertsClient = alertsClientMock.create(); - const result = getResult(); + const result = getAlertMock(getQueryRuleParams()); result.params.immutable = true; const findResult: FindHit = { @@ -91,7 +96,7 @@ describe('get_export_by_object_ids', () => { data: [result], }; - alertsClient.get.mockResolvedValue(getResult()); + alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); alertsClient.find.mockResolvedValue(findResult); const objects = [{ rule_id: 'rule-1' }]; @@ -107,7 +112,6 @@ describe('get_export_by_object_ids', () => { describe('getRulesFromObjects', () => { test('it returns transformed rules from objects sent in', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); const objects = [{ rule_id: 'rule-1' }]; @@ -119,6 +123,7 @@ describe('get_export_by_object_ids', () => { { actions: [], author: ['Elastic'], + building_block_type: 'default', created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', created_by: 'elastic', @@ -133,14 +138,22 @@ describe('get_export_by_object_ids', () => { interval: '5m', rule_id: 'rule-1', language: 'kuery', + last_failure_at: undefined, + last_failure_message: undefined, + last_success_at: undefined, + last_success_message: undefined, license: 'Elastic License', output_index: '.siem-signals', - max_signals: 100, + max_signals: 10000, risk_score: 50, risk_score_mapping: [], + rule_name_override: undefined, + saved_id: undefined, + status: undefined, + status_date: undefined, name: 'Detect Root/Admin Users', query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], + references: ['http://example.com', 'https://example.com'], timeline_id: 'some-timeline-id', timeline_title: 'some-timeline-title', meta: { someMeta: 'someField' }, @@ -163,7 +176,7 @@ describe('get_export_by_object_ids', () => { test('it returns error when readRules throws error', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); + alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); jest.spyOn(readRules, 'readRules').mockImplementation(async () => { throw new Error('Test error'); @@ -180,7 +193,7 @@ describe('get_export_by_object_ids', () => { test('it does not transform the rule if the rule is an immutable rule and designates it as a missing rule', async () => { const alertsClient = alertsClientMock.create(); - const result = getResult(); + const result = getAlertMock(getQueryRuleParams()); result.params.immutable = true; const findResult: FindHit = { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_rules_to_install.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_rules_to_install.test.ts index 53da230d70c56..7482097aafd22 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_rules_to_install.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_rules_to_install.test.ts @@ -6,8 +6,9 @@ */ import { getRulesToInstall } from './get_rules_to_install'; -import { getResult } from '../routes/__mocks__/request_responses'; +import { getAlertMock } from '../routes/__mocks__/request_responses'; import { getAddPrepackagedRulesSchemaDecodedMock } from '../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema.mock'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; describe('get_rules_to_install', () => { test('should return empty array if both rule sets are empty', () => { @@ -19,7 +20,7 @@ describe('get_rules_to_install', () => { const ruleFromFileSystem = getAddPrepackagedRulesSchemaDecodedMock(); ruleFromFileSystem.rule_id = 'rule-1'; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; const update = getRulesToInstall([ruleFromFileSystem], [installedRule]); expect(update).toEqual([]); @@ -29,7 +30,7 @@ describe('get_rules_to_install', () => { const ruleFromFileSystem = getAddPrepackagedRulesSchemaDecodedMock(); ruleFromFileSystem.rule_id = 'rule-1'; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-2'; const update = getRulesToInstall([ruleFromFileSystem], [installedRule]); expect(update).toEqual([ruleFromFileSystem]); @@ -42,7 +43,7 @@ describe('get_rules_to_install', () => { const ruleFromFileSystem2 = getAddPrepackagedRulesSchemaDecodedMock(); ruleFromFileSystem2.rule_id = 'rule-2'; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-3'; const update = getRulesToInstall([ruleFromFileSystem1, ruleFromFileSystem2], [installedRule]); expect(update).toEqual([ruleFromFileSystem1, ruleFromFileSystem2]); @@ -58,7 +59,7 @@ describe('get_rules_to_install', () => { const ruleFromFileSystem3 = getAddPrepackagedRulesSchemaDecodedMock(); ruleFromFileSystem3.rule_id = 'rule-3'; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-3'; const update = getRulesToInstall( [ruleFromFileSystem1, ruleFromFileSystem2, ruleFromFileSystem3], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_rules_to_update.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_rules_to_update.test.ts index dfcfc6c41c3c0..163585e7594ab 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_rules_to_update.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_rules_to_update.test.ts @@ -6,8 +6,9 @@ */ import { filterInstalledRules, getRulesToUpdate, mergeExceptionLists } from './get_rules_to_update'; -import { getResult } from '../routes/__mocks__/request_responses'; +import { getAlertMock } from '../routes/__mocks__/request_responses'; import { getAddPrepackagedRulesSchemaDecodedMock } from '../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema.mock'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; describe('get_rules_to_update', () => { describe('get_rules_to_update', () => { @@ -21,7 +22,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 2; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-2'; installedRule.params.version = 1; const update = getRulesToUpdate([ruleFromFileSystem], [installedRule]); @@ -33,7 +34,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 1; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; installedRule.params.version = 2; const update = getRulesToUpdate([ruleFromFileSystem], [installedRule]); @@ -45,7 +46,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 1; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; installedRule.params.version = 1; const update = getRulesToUpdate([ruleFromFileSystem], [installedRule]); @@ -57,7 +58,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 2; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; installedRule.params.version = 1; installedRule.params.exceptionsList = []; @@ -71,12 +72,12 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = []; - const installedRule2 = getResult(); + const installedRule2 = getAlertMock(getQueryRuleParams()); installedRule2.params.ruleId = 'rule-2'; installedRule2.params.version = 1; installedRule2.params.exceptionsList = []; @@ -94,12 +95,12 @@ describe('get_rules_to_update', () => { ruleFromFileSystem2.rule_id = 'rule-2'; ruleFromFileSystem2.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = []; - const installedRule2 = getResult(); + const installedRule2 = getAlertMock(getQueryRuleParams()); installedRule2.params.ruleId = 'rule-2'; installedRule2.params.version = 1; installedRule2.params.exceptionsList = []; @@ -124,7 +125,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem1.rule_id = 'rule-1'; ruleFromFileSystem1.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = []; @@ -146,7 +147,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem1.rule_id = 'rule-1'; ruleFromFileSystem1.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = [ @@ -178,7 +179,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem1.rule_id = 'rule-1'; ruleFromFileSystem1.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = [ @@ -200,7 +201,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem1.rule_id = 'rule-1'; ruleFromFileSystem1.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = [ @@ -227,7 +228,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem2.rule_id = 'rule-2'; ruleFromFileSystem2.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = [ @@ -238,7 +239,7 @@ describe('get_rules_to_update', () => { type: 'endpoint', }, ]; - const installedRule2 = getResult(); + const installedRule2 = getAlertMock(getQueryRuleParams()); installedRule2.params.ruleId = 'rule-2'; installedRule2.params.version = 1; installedRule2.params.exceptionsList = [ @@ -277,7 +278,7 @@ describe('get_rules_to_update', () => { }, ]; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = [ @@ -289,7 +290,7 @@ describe('get_rules_to_update', () => { }, ]; - const installedRule2 = getResult(); + const installedRule2 = getAlertMock(getQueryRuleParams()); installedRule2.params.ruleId = 'rule-2'; installedRule2.params.version = 1; installedRule2.params.exceptionsList = [ @@ -319,7 +320,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 2; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-2'; installedRule.params.version = 1; const shouldUpdate = filterInstalledRules(ruleFromFileSystem, [installedRule]); @@ -331,7 +332,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 1; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; installedRule.params.version = 2; const shouldUpdate = filterInstalledRules(ruleFromFileSystem, [installedRule]); @@ -343,7 +344,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 1; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; installedRule.params.version = 1; const shouldUpdate = filterInstalledRules(ruleFromFileSystem, [installedRule]); @@ -355,7 +356,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 2; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; installedRule.params.version = 1; installedRule.params.exceptionsList = []; @@ -379,7 +380,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem1.rule_id = 'rule-1'; ruleFromFileSystem1.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = []; @@ -401,7 +402,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem1.rule_id = 'rule-1'; ruleFromFileSystem1.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = [ @@ -433,7 +434,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem1.rule_id = 'rule-1'; ruleFromFileSystem1.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = [ @@ -455,7 +456,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem1.rule_id = 'rule-1'; ruleFromFileSystem1.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = [ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts index 796496e20809c..d42b6c5aeefaa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts @@ -8,143 +8,8 @@ import { PatchRulesOptions } from './types'; import { alertsClientMock } from '../../../../../alerting/server/mocks'; import { savedObjectsClientMock } from '../../../../../../../src/core/server/mocks'; -import { INTERNAL_RULE_ID_KEY, INTERNAL_IMMUTABLE_KEY } from '../../../../common/constants'; -import { SanitizedAlert } from '../../../../../alerting/common'; -import { RuleTypeParams } from '../types'; - -const rule: SanitizedAlert = { - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - name: 'Detect Root/Admin Users', - tags: [`${INTERNAL_RULE_ID_KEY}:rule-1`, `${INTERNAL_IMMUTABLE_KEY}:false`], - alertTypeId: 'siem.signals', - consumer: 'siem', - params: { - anomalyThreshold: undefined, - description: 'Detecting root and admin users', - ruleId: 'rule-1', - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - falsePositives: [], - from: 'now-6m', - immutable: false, - query: 'user.name: root or user.name: admin', - language: 'kuery', - machineLearningJobId: undefined, - outputIndex: '.siem-signals', - timelineId: 'some-timeline-id', - timelineTitle: 'some-timeline-title', - meta: { someMeta: 'someField' }, - filters: [ - { - query: { - match_phrase: { - 'host.name': 'some-host', - }, - }, - }, - ], - riskScore: 50, - maxSignals: 100, - severity: 'high', - to: 'now', - type: 'query', - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: 'TA0040', - name: 'impact', - reference: 'https://attack.mitre.org/tactics/TA0040/', - }, - technique: [ - { - id: 'T1499', - name: 'endpoint denial of service', - reference: 'https://attack.mitre.org/techniques/T1499/', - subtechnique: [], - }, - ], - }, - ], - references: ['http://www.example.com', 'https://ww.example.com'], - note: '# Investigative notes', - version: 1, - exceptionsList: [ - /** - TODO: fix this mock. Which the typing has revealed is wrong - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - },*/ - ], - /** - * The fields below were missing as the type was partial and hence not technically correct - */ - author: [], - buildingBlockType: undefined, - eventCategoryOverride: undefined, - license: undefined, - savedId: undefined, - interval: undefined, - riskScoreMapping: undefined, - ruleNameOverride: undefined, - name: undefined, - severityMapping: undefined, - tags: undefined, - threshold: undefined, - threatFilters: undefined, - threatIndex: undefined, - threatIndicatorPath: undefined, - threatQuery: undefined, - threatMapping: undefined, - threatLanguage: undefined, - concurrentSearches: undefined, - itemsPerSearch: undefined, - timestampOverride: undefined, - }, - createdAt: new Date('2019-12-13T16:40:33.400Z'), - updatedAt: new Date('2019-12-13T16:40:33.400Z'), - schedule: { interval: '5m' }, - enabled: true, - actions: [], - throttle: null, - notifyWhen: null, - createdBy: 'elastic', - updatedBy: 'elastic', - apiKeyOwner: 'elastic', - muteAll: false, - mutedInstanceIds: [], - scheduledTaskId: '2dabe330-0702-11ea-8b50-773b89126888', - executionStatus: { - status: 'unknown', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - }, -}; +import { getAlertMock } from '../routes/__mocks__/request_responses'; +import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; export const getPatchRulesOptionsMock = (): PatchRulesOptions => ({ author: ['Elastic'], @@ -194,7 +59,7 @@ export const getPatchRulesOptionsMock = (): PatchRulesOptions => ({ version: 1, exceptionsList: [], actions: [], - rule, + rule: getAlertMock(getQueryRuleParams()), }); export const getPatchMlRulesOptionsMock = (): PatchRulesOptions => ({ @@ -245,5 +110,5 @@ export const getPatchMlRulesOptionsMock = (): PatchRulesOptions => ({ version: 1, exceptionsList: [], actions: [], - rule, + rule: getAlertMock(getMlRuleParams()), }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts index 755a8cd6f1e58..bf769e46ab7bd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts @@ -13,8 +13,8 @@ import { PatchRulesOptions } from './types'; import { addTags } from './add_tags'; import { calculateVersion, calculateName, calculateInterval, removeUndefined } from './utils'; import { ruleStatusSavedObjectsClientFactory } from '../signals/rule_status_saved_objects_client'; -import { internalRuleUpdate } from '../schemas/rule_schemas'; -import { RuleTypeParams } from '../types'; +import { internalRuleUpdate, RuleParams } from '../schemas/rule_schemas'; +import { normalizeThresholdObject } from '../../../../common/detection_engine/utils'; class PatchError extends Error { public readonly statusCode: number; @@ -73,7 +73,7 @@ export const patchRules = async ({ anomalyThreshold, machineLearningJobId, actions, -}: PatchRulesOptions): Promise | null> => { +}: PatchRulesOptions): Promise | null> => { if (rule == null) { return null; } @@ -151,7 +151,7 @@ export const patchRules = async ({ severity, severityMapping, threat, - threshold, + threshold: threshold ? normalizeThresholdObject(threshold) : undefined, threatFilters, threatIndex, threatQuery, @@ -187,13 +187,10 @@ export const patchRules = async ({ throw new PatchError(`Applying patch would create invalid rule: ${errors}`, 400); } - /** - * TODO: Remove this use of `as` by utilizing the proper type - */ - const update = (await alertsClient.update({ + const update = await alertsClient.update({ id: rule.id, data: validated, - })) as PartialAlert; + }); if (rule.enabled && enabled === false) { await alertsClient.disable({ id: rule.id }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.test.ts index 21e7ba5bc626f..ce82384291303 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.test.ts @@ -7,7 +7,8 @@ import { readRules } from './read_rules'; import { alertsClientMock } from '../../../../../alerting/server/mocks'; -import { getResult, getFindResultWithSingleHit } from '../routes/__mocks__/request_responses'; +import { getAlertMock, getFindResultWithSingleHit } from '../routes/__mocks__/request_responses'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; export class TestError extends Error { constructor() { @@ -29,18 +30,18 @@ describe('read_rules', () => { describe('readRules', () => { test('should return the output from alertsClient if id is set but ruleId is undefined', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); + alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); const rule = await readRules({ alertsClient, id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', ruleId: undefined, }); - expect(rule).toEqual(getResult()); + expect(rule).toEqual(getAlertMock(getQueryRuleParams())); }); test('should return null if saved object found by alerts client given id is not alert type', async () => { const alertsClient = alertsClientMock.create(); - const result = getResult(); + const result = getAlertMock(getQueryRuleParams()); // @ts-expect-error delete result.alertTypeId; alertsClient.get.mockResolvedValue(result); @@ -85,7 +86,7 @@ describe('read_rules', () => { test('should return the output from alertsClient if id is undefined but ruleId is set', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); + alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); const rule = await readRules({ @@ -93,12 +94,12 @@ describe('read_rules', () => { id: undefined, ruleId: 'rule-1', }); - expect(rule).toEqual(getResult()); + expect(rule).toEqual(getAlertMock(getQueryRuleParams())); }); test('should return null if the output from alertsClient with ruleId set is empty', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); + alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); alertsClient.find.mockResolvedValue({ data: [], page: 0, perPage: 1, total: 0 }); const rule = await readRules({ @@ -111,7 +112,7 @@ describe('read_rules', () => { test('should return the output from alertsClient if id is null but ruleId is set', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); + alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); const rule = await readRules({ @@ -119,12 +120,12 @@ describe('read_rules', () => { id: undefined, ruleId: 'rule-1', }); - expect(rule).toEqual(getResult()); + expect(rule).toEqual(getAlertMock(getQueryRuleParams())); }); test('should return null if id and ruleId are undefined', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); + alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); const rule = await readRules({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.ts index ed84c1a0aba43..62f8e7642cc64 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.ts @@ -7,7 +7,7 @@ import { SanitizedAlert } from '../../../../../alerting/common'; import { INTERNAL_RULE_ID_KEY } from '../../../../common/constants'; -import { RuleTypeParams } from '../types'; +import { RuleParams } from '../schemas/rule_schemas'; import { findRules } from './find_rules'; import { isAlertType, ReadRuleOptions } from './types'; @@ -23,7 +23,7 @@ export const readRules = async ({ alertsClient, id, ruleId, -}: ReadRuleOptions): Promise | null> => { +}: ReadRuleOptions): Promise | null> => { if (id != null) { try { const rule = await alertsClient.get({ id }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index 13a255d1b56d4..2a87b00829321 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -102,10 +102,11 @@ import { import { AlertsClient, PartialAlert } from '../../../../../alerting/server'; import { Alert, SanitizedAlert } from '../../../../../alerting/common'; import { SIGNALS_ID } from '../../../../common/constants'; -import { RuleTypeParams, PartialFilter } from '../types'; +import { PartialFilter } from '../types'; import { ListArrayOrUndefined, ListArray } from '../../../../common/detection_engine/schemas/types'; +import { RuleParams } from '../schemas/rule_schemas'; -export type RuleAlertType = Alert; +export type RuleAlertType = Alert; // eslint-disable-next-line @typescript-eslint/no-explicit-any export interface IRuleStatusSOAttributes extends Record { @@ -174,13 +175,13 @@ export interface Clients { } export const isAlertTypes = ( - partialAlert: Array> + partialAlert: Array> ): partialAlert is RuleAlertType[] => { return partialAlert.every((rule) => isAlertType(rule)); }; export const isAlertType = ( - partialAlert: PartialAlert + partialAlert: PartialAlert ): partialAlert is RuleAlertType => { return partialAlert.alertTypeId === SIGNALS_ID; }; @@ -310,7 +311,7 @@ export interface PatchRulesOptions { version: VersionOrUndefined; exceptionsList: ListArrayOrUndefined; actions: RuleAlertAction[] | undefined; - rule: SanitizedAlert | null; + rule: SanitizedAlert | null; } export interface ReadRuleOptions { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts index 44e68587ac503..f3ee7e251c02d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -11,7 +11,8 @@ import { AddPrepackagedRulesSchemaDecoded } from '../../../../common/detection_e import { AlertsClient, PartialAlert } from '../../../../../alerting/server'; import { patchRules } from './patch_rules'; import { readRules } from './read_rules'; -import { PartialFilter, RuleTypeParams } from '../types'; +import { PartialFilter } from '../types'; +import { RuleParams } from '../schemas/rule_schemas'; /** * How many rules to update at a time is set to 50 from errors coming from @@ -73,7 +74,7 @@ export const createPromises = ( savedObjectsClient: SavedObjectsClientContract, rules: AddPrepackagedRulesSchemaDecoded[], outputIndex: string -): Array | null>> => { +): Array | null>> => { return rules.map(async (rule) => { const { author, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.test.ts index 083191329878b..48b8905384566 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.test.ts @@ -5,17 +5,18 @@ * 2.0. */ -import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; +import { getAlertMock } from '../routes/__mocks__/request_responses'; import { updateRules } from './update_rules'; import { getUpdateRulesOptionsMock, getUpdateMlRulesOptionsMock } from './update_rules.mock'; import { AlertsClientMock } from '../../../../../alerting/server/alerts_client.mock'; +import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; describe('updateRules', () => { it('should call alertsClient.disable if the rule was enabled and enabled is false', async () => { const rulesOptionsMock = getUpdateRulesOptionsMock(); rulesOptionsMock.ruleUpdate.enabled = false; ((rulesOptionsMock.alertsClient as unknown) as AlertsClientMock).get.mockResolvedValue( - getResult() + getAlertMock(getQueryRuleParams()) ); await updateRules(rulesOptionsMock); @@ -32,7 +33,7 @@ describe('updateRules', () => { rulesOptionsMock.ruleUpdate.enabled = true; ((rulesOptionsMock.alertsClient as unknown) as AlertsClientMock).get.mockResolvedValue({ - ...getResult(), + ...getAlertMock(getQueryRuleParams()), enabled: false, }); @@ -50,7 +51,7 @@ describe('updateRules', () => { rulesOptionsMock.ruleUpdate.enabled = true; ((rulesOptionsMock.alertsClient as unknown) as AlertsClientMock).get.mockResolvedValue( - getMlResult() + getAlertMock(getMlRuleParams()) ); await updateRules(rulesOptionsMock); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts index fc9c32bca1c4c..b0c8cd6c4dd69 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts @@ -15,15 +15,14 @@ import { UpdateRulesOptions } from './types'; import { addTags } from './add_tags'; import { ruleStatusSavedObjectsClientFactory } from '../signals/rule_status_saved_objects_client'; import { typeSpecificSnakeToCamel } from '../schemas/rule_converters'; -import { InternalRuleUpdate } from '../schemas/rule_schemas'; -import { RuleTypeParams } from '../types'; +import { InternalRuleUpdate, RuleParams } from '../schemas/rule_schemas'; export const updateRules = async ({ alertsClient, savedObjectsClient, defaultOutputIndex, ruleUpdate, -}: UpdateRulesOptions): Promise | null> => { +}: UpdateRulesOptions): Promise | null> => { const existingRule = await readRules({ alertsClient, ruleId: ruleUpdate.rule_id, @@ -79,13 +78,10 @@ export const updateRules = async ({ notifyWhen: null, }; - /** - * TODO: Remove this use of `as` by utilizing the proper type - */ - const update = (await alertsClient.update({ + const update = await alertsClient.update({ id: existingRule.id, data: newInternalRule, - })) as PartialAlert; + }); if (existingRule.enabled && enabled === false) { await alertsClient.disable({ id: existingRule.id }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts index 58ce1e7e14460..65cf1d2f723c6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts @@ -6,8 +6,14 @@ */ import uuid from 'uuid'; -import { InternalRuleCreate, InternalRuleResponse, TypeSpecificRuleParams } from './rule_schemas'; -import { normalizeThresholdField } from '../../../../common/detection_engine/utils'; +import { SavedObject } from 'kibana/server'; +import { normalizeThresholdObject } from '../../../../common/detection_engine/utils'; +import { + InternalRuleCreate, + RuleParams, + TypeSpecificRuleParams, + BaseRuleParams, +} from './rule_schemas'; import { assertUnreachable } from '../../../../common/utility_types'; import { CreateRulesSchema, @@ -20,6 +26,9 @@ import { AppClient } from '../../../types'; import { addTags } from '../rules/add_tags'; import { DEFAULT_MAX_SIGNALS, SERVER_APP_ID, SIGNALS_ID } from '../../../../common/constants'; import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; +import { Alert } from '../../../../../alerting/common'; +import { IRuleSavedAttributesSavedObjectAttributes } from '../rules/types'; +import { transformTags } from '../routes/rules/utils'; // These functions provide conversions from the request API schema to the internal rule schema and from the internal rule schema // to the response API schema. This provides static type-check assurances that the internal schema is in sync with the API schema for @@ -87,7 +96,7 @@ export const typeSpecificSnakeToCamel = (params: CreateTypeSpecific): TypeSpecif query: params.query, filters: params.filters, savedId: params.saved_id, - threshold: params.threshold, + threshold: normalizeThresholdObject(params.threshold), }; } case 'machine_learning': { @@ -176,6 +185,7 @@ export const typeSpecificCamelToSnake = (params: TypeSpecificRuleParams): Respon threat_mapping: params.threatMapping, threat_language: params.threatLanguage, threat_index: params.threatIndex, + threat_indicator_path: params.threatIndicatorPath, concurrent_searches: params.concurrentSearches, items_per_search: params.itemsPerSearch, }; @@ -208,10 +218,7 @@ export const typeSpecificCamelToSnake = (params: TypeSpecificRuleParams): Respon query: params.query, filters: params.filters, saved_id: params.savedId, - threshold: { - ...params.threshold, - field: normalizeThresholdField(params.threshold.field), - }, + threshold: params.threshold, }; } case 'machine_learning': { @@ -227,47 +234,67 @@ export const typeSpecificCamelToSnake = (params: TypeSpecificRuleParams): Respon } }; +// TODO: separate out security solution defined common params from Alerting framework common params +// so we can explicitly specify the return type of this function +export const commonParamsCamelToSnake = (params: BaseRuleParams) => { + return { + description: params.description, + risk_score: params.riskScore, + severity: params.severity, + building_block_type: params.buildingBlockType, + note: params.note, + license: params.license, + output_index: params.outputIndex, + timeline_id: params.timelineId, + timeline_title: params.timelineTitle, + meta: params.meta, + rule_name_override: params.ruleNameOverride, + timestamp_override: params.timestampOverride, + author: params.author, + false_positives: params.falsePositives, + from: params.from, + rule_id: params.ruleId, + max_signals: params.maxSignals, + risk_score_mapping: params.riskScoreMapping, + severity_mapping: params.severityMapping, + threat: params.threat, + to: params.to, + references: params.references, + version: params.version, + exceptions_list: params.exceptionsList, + immutable: params.immutable, + }; +}; + export const internalRuleToAPIResponse = ( - rule: InternalRuleResponse, - ruleActions: RuleActions + rule: Alert, + ruleActions?: RuleActions | null, + ruleStatus?: SavedObject ): FullResponseSchema => { return { + // Alerting framework params id: rule.id, - immutable: rule.params.immutable, - updated_at: rule.updatedAt, - updated_by: rule.updatedBy, - created_at: rule.createdAt, - created_by: rule.createdBy, + updated_at: rule.updatedAt.toISOString(), + updated_by: rule.updatedBy ?? 'elastic', + created_at: rule.createdAt.toISOString(), + created_by: rule.createdBy ?? 'elastic', name: rule.name, - tags: rule.tags, + tags: transformTags(rule.tags), interval: rule.schedule.interval, enabled: rule.enabled, - throttle: ruleActions.ruleThrottle, - actions: ruleActions.actions, - description: rule.params.description, - risk_score: rule.params.riskScore, - severity: rule.params.severity, - building_block_type: rule.params.buildingBlockType, - note: rule.params.note, - license: rule.params.license, - output_index: rule.params.outputIndex, - timeline_id: rule.params.timelineId, - timeline_title: rule.params.timelineTitle, - meta: rule.params.meta, - rule_name_override: rule.params.ruleNameOverride, - timestamp_override: rule.params.timestampOverride, - author: rule.params.author ?? [], - false_positives: rule.params.falsePositives, - from: rule.params.from, - rule_id: rule.params.ruleId, - max_signals: rule.params.maxSignals, - risk_score_mapping: rule.params.riskScoreMapping ?? [], - severity_mapping: rule.params.severityMapping ?? [], - threat: rule.params.threat, - to: rule.params.to, - references: rule.params.references, - version: rule.params.version, - exceptions_list: rule.params.exceptionsList ?? [], + // Security solution shared rule params + ...commonParamsCamelToSnake(rule.params), + // Type specific security solution rule params ...typeSpecificCamelToSnake(rule.params), + // Actions + throttle: ruleActions?.ruleThrottle || 'no_actions', + actions: ruleActions?.actions ?? [], + // Rule status + status: ruleStatus?.attributes.status ?? undefined, + status_date: ruleStatus?.attributes.statusDate ?? undefined, + last_failure_at: ruleStatus?.attributes.lastFailureAt ?? undefined, + last_success_at: ruleStatus?.attributes.lastSuccessAt ?? undefined, + last_failure_message: ruleStatus?.attributes.lastFailureMessage ?? undefined, + last_success_message: ruleStatus?.attributes.lastSuccessMessage ?? undefined, }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts index a855bcb7cb6d0..8c5825325bd2e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts @@ -5,11 +5,15 @@ * 2.0. */ +import { getThreatMock } from '../../../../common/detection_engine/schemas/types/threat.mock'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; +import { getThreatMappingMock } from '../signals/threat_mapping/build_threat_mapping_filter.mock'; import { BaseRuleParams, EqlRuleParams, MachineLearningRuleParams, + QueryRuleParams, + ThreatRuleParams, ThresholdRuleParams, } from './rule_schemas'; @@ -27,17 +31,19 @@ const getBaseRuleParams = (): BaseRuleParams => { severityMapping: [], license: 'Elastic License', outputIndex: '.siem-signals', - references: ['http://google.com'], + references: ['http://example.com', 'https://example.com'], riskScore: 50, riskScoreMapping: [], ruleNameOverride: undefined, maxSignals: 10000, - note: '', - timelineId: undefined, - timelineTitle: undefined, + note: '# Investigative notes', + timelineId: 'some-timeline-id', + timelineTitle: 'some-timeline-title', timestampOverride: undefined, - meta: undefined, - threat: [], + meta: { + someMeta: 'someField', + }, + threat: getThreatMock(), version: 1, exceptionsList: getListArrayMock(), }; @@ -48,13 +54,19 @@ export const getThresholdRuleParams = (): ThresholdRuleParams => { ...getBaseRuleParams(), type: 'threshold', language: 'kuery', - index: ['some-index'], - query: 'host.name: *', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + query: 'user.name: root or user.name: admin', filters: undefined, savedId: undefined, threshold: { - field: 'host.id', + field: ['host.id'], value: 5, + cardinality: [ + { + field: 'source.ip', + value: 11, + }, + ], }, }; }; @@ -79,3 +91,43 @@ export const getMlRuleParams = (): MachineLearningRuleParams => { machineLearningJobId: 'my-job', }; }; + +export const getQueryRuleParams = (): QueryRuleParams => { + return { + ...getBaseRuleParams(), + type: 'query', + language: 'kuery', + query: 'user.name: root or user.name: admin', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + filters: [ + { + query: { + match_phrase: { + 'host.name': 'some-host', + }, + }, + }, + ], + savedId: undefined, + }; +}; + +export const getThreatRuleParams = (): ThreatRuleParams => { + return { + ...getBaseRuleParams(), + type: 'threat_match', + language: 'kuery', + query: '*:*', + index: ['some-index'], + filters: undefined, + savedId: undefined, + threatQuery: 'threat-query', + threatFilters: undefined, + threatIndex: ['some-threat-index'], + threatLanguage: 'kuery', + threatMapping: getThreatMappingMock(), + threatIndicatorPath: '', + concurrentSearches: undefined, + itemsPerSearch: undefined, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts index 144b751491b2c..cd2b5d0b9eda7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts @@ -7,7 +7,7 @@ import * as t from 'io-ts'; -import { listArrayOrUndefined } from '../../../../common/detection_engine/schemas/types/lists'; +import { listArray } from '../../../../common/detection_engine/schemas/types/lists'; import { threat_mapping, threat_index, @@ -17,7 +17,7 @@ import { threatIndicatorPathOrUndefined, } from '../../../../common/detection_engine/schemas/types/threat_mapping'; import { - authorOrUndefined, + author, buildingBlockTypeOrUndefined, description, enabled, @@ -39,10 +39,10 @@ import { machine_learning_job_id, max_signals, risk_score, - riskScoreMappingOrUndefined, + risk_score_mapping, ruleNameOverrideOrUndefined, severity, - severityMappingOrUndefined, + severity_mapping, tags, timestampOverrideOrUndefined, threats, @@ -52,7 +52,7 @@ import { eventCategoryOverrideOrUndefined, savedIdOrUndefined, saved_id, - threshold, + thresholdNormalized, anomaly_threshold, actionsCamel, throttleOrNull, @@ -66,7 +66,7 @@ import { SIGNALS_ID, SERVER_APP_ID } from '../../../../common/constants'; const nonEqlLanguages = t.keyof({ kuery: null, lucene: null }); export const baseRuleParams = t.exact( t.type({ - author: authorOrUndefined, + author, buildingBlockType: buildingBlockTypeOrUndefined, description, note: noteOrUndefined, @@ -82,16 +82,16 @@ export const baseRuleParams = t.exact( // maxSignals not used in ML rules but probably should be used maxSignals: max_signals, riskScore: risk_score, - riskScoreMapping: riskScoreMappingOrUndefined, + riskScoreMapping: risk_score_mapping, ruleNameOverride: ruleNameOverrideOrUndefined, severity, - severityMapping: severityMappingOrUndefined, + severityMapping: severity_mapping, timestampOverride: timestampOverrideOrUndefined, threat: threats, to, references, version, - exceptionsList: listArrayOrUndefined, + exceptionsList: listArray, }) ); export type BaseRuleParams = t.TypeOf; @@ -159,7 +159,7 @@ const thresholdSpecificRuleParams = t.type({ query, filters: filtersOrUndefined, savedId: savedIdOrUndefined, - threshold, + threshold: thresholdNormalized, }); export const thresholdRuleParams = t.intersection([baseRuleParams, thresholdSpecificRuleParams]); export type ThresholdRuleParams = t.TypeOf; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 8c9b19a0929d2..2ef72c22bbecf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -11,68 +11,20 @@ import type { SignalSearchResponse, BulkResponse, BulkItem, - RuleAlertAttributes, SignalHit, WrappedSignalHit, + AlertAttributes, } from '../types'; import { SavedObject, SavedObjectsFindResponse } from '../../../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../../../src/core/server/mocks'; -import { RuleTypeParams } from '../../types'; import { IRuleStatusSOAttributes } from '../../rules/types'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { getListArrayMock } from '../../../../../common/detection_engine/schemas/types/lists.mock'; import { RulesSchema } from '../../../../../common/detection_engine/schemas/response'; +import { RuleParams } from '../../schemas/rule_schemas'; +import { getThreatMock } from '../../../../../common/detection_engine/schemas/types/threat.mock'; -export const sampleRuleAlertParams = ( - maxSignals?: number | undefined, - riskScore?: number | undefined -): RuleTypeParams => ({ - author: ['Elastic'], - buildingBlockType: 'default', - ruleId: 'rule-1', - description: 'Detecting root and admin users', - eventCategoryOverride: undefined, - falsePositives: [], - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - type: 'query', - from: 'now-6m', - to: 'now', - severity: 'high', - severityMapping: [], - query: 'user.name: root or user.name: admin', - language: 'kuery', - license: 'Elastic License', - outputIndex: '.siem-signals', - references: ['http://google.com'], - riskScore: riskScore ? riskScore : 50, - riskScoreMapping: [], - ruleNameOverride: undefined, - maxSignals: maxSignals ? maxSignals : 10000, - note: '', - anomalyThreshold: undefined, - machineLearningJobId: undefined, - filters: undefined, - savedId: undefined, - threshold: undefined, - threatFilters: undefined, - threatQuery: undefined, - threatMapping: undefined, - threatIndex: undefined, - threatIndicatorPath: undefined, - threatLanguage: undefined, - timelineId: undefined, - timelineTitle: undefined, - timestampOverride: undefined, - meta: undefined, - threat: undefined, - version: 1, - exceptionsList: getListArrayMock(), - concurrentSearches: undefined, - itemsPerSearch: undefined, -}); - -export const sampleRuleSO = (): SavedObject => { +export const sampleRuleSO = (params: T): SavedObject> => { return { id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', type: 'alert', @@ -90,7 +42,7 @@ export const sampleRuleSO = (): SavedObject => { interval: '5m', }, throttle: 'no_actions', - params: sampleRuleAlertParams(), + params, }, references: [], }; @@ -110,21 +62,33 @@ export const expectedRule = (): RulesSchema => { output_index: '.siem-signals', description: 'Detecting root and admin users', from: 'now-6m', + filters: [ + { + query: { + match_phrase: { + 'host.name': 'some-host', + }, + }, + }, + ], immutable: false, index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], interval: '5m', language: 'kuery', license: 'Elastic License', + meta: { + someMeta: 'someField', + }, name: 'rule-name', query: 'user.name: root or user.name: admin', - references: ['http://google.com'], + references: ['http://example.com', 'https://example.com'], severity: 'high', severity_mapping: [], tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], + threat: getThreatMock(), type: 'query', to: 'now', - note: '', + note: '# Investigative notes', enabled: true, created_by: 'sample user', updated_by: 'sample user', @@ -132,6 +96,8 @@ export const expectedRule = (): RulesSchema => { updated_at: '2020-03-27T22:55:59.577Z', created_at: '2020-03-27T22:55:59.577Z', throttle: 'no_actions', + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', exceptions_list: getListArrayMock(), }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts index 708aefc4d8614..743d9580218a3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts @@ -6,13 +6,12 @@ */ import { - sampleRuleAlertParams, sampleDocNoSortId, - sampleRuleGuid, sampleIdGuid, sampleDocWithAncestors, sampleRuleSO, sampleWrappedSignalHit, + expectedRule, } from './__mocks__/es_results'; import { buildBulkBody, @@ -22,8 +21,8 @@ import { objectArrayIntersection, } from './build_bulk_body'; import { SignalHit, SignalSourceHit } from './types'; -import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; import { SIGNALS_TEMPLATE_VERSION } from '../routes/index/get_signals_template'; +import { getQueryRuleParams, getThresholdRuleParams } from '../schemas/rule_schemas.mock'; describe('buildBulkBody', () => { beforeEach(() => { @@ -31,31 +30,11 @@ describe('buildBulkBody', () => { }); test('bulk body builds well-defined body', () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.source; - const fakeSignalSourceHit = buildBulkBody({ - doc, - ruleParams: { - ...sampleParams, - threshold: { - field: ['host.name'], - value: 100, - }, - }, - id: sampleRuleGuid, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); + const fakeSignalSourceHit = buildBulkBody(ruleSO, doc); // Timestamp will potentially always be different so remove it for the test // @ts-expect-error delete fakeSignalSourceHit['@timestamp']; @@ -92,47 +71,7 @@ describe('buildBulkBody', () => { ], original_time: '2020-04-20T21:27:45+0000', status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - threshold: { - field: ['host.name'], - value: 100, - }, - throttle: 'no_actions', - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'elastic', - updated_by: 'elastic', - version: 1, - created_at: fakeSignalSourceHit.signal.rule?.created_at, - updated_at: fakeSignalSourceHit.signal.rule?.updated_at, - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 1, }, }; @@ -140,7 +79,7 @@ describe('buildBulkBody', () => { }); test('bulk body builds well-defined body with threshold results', () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getThresholdRuleParams()); const baseDoc = sampleDocNoSortId(); const doc: SignalSourceHit = { ...baseDoc, @@ -159,27 +98,7 @@ describe('buildBulkBody', () => { }; // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.source; - const fakeSignalSourceHit = buildBulkBody({ - doc, - ruleParams: { - ...sampleParams, - threshold: { - field: [], - value: 4, - }, - }, - id: sampleRuleGuid, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); + const fakeSignalSourceHit = buildBulkBody(ruleSO, doc); // Timestamp will potentially always be different so remove it for the test // @ts-expect-error delete fakeSignalSourceHit['@timestamp']; @@ -217,45 +136,19 @@ describe('buildBulkBody', () => { original_time: '2020-04-20T21:27:45+0000', status: 'open', rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], + ...expectedRule(), + filters: undefined, + type: 'threshold', threshold: { - field: [], - value: 4, + field: ['host.id'], + value: 5, + cardinality: [ + { + field: 'source.ip', + value: 11, + }, + ], }, - throttle: 'no_actions', - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'elastic', - updated_by: 'elastic', - version: 1, - created_at: fakeSignalSourceHit.signal.rule?.created_at, - updated_at: fakeSignalSourceHit.signal.rule?.updated_at, - exceptions_list: getListArrayMock(), }, threshold_result: { terms: [ @@ -272,7 +165,7 @@ describe('buildBulkBody', () => { }); test('bulk body builds original_event if it exists on the event to begin with', () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.source; @@ -283,21 +176,7 @@ describe('buildBulkBody', () => { dataset: 'socket', kind: 'event', }; - const fakeSignalSourceHit = buildBulkBody({ - doc, - ruleParams: sampleParams, - id: sampleRuleGuid, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); + const fakeSignalSourceHit = buildBulkBody(ruleSO, doc); // Timestamp will potentially always be different so remove it for the test // @ts-expect-error delete fakeSignalSourceHit['@timestamp']; @@ -343,43 +222,7 @@ describe('buildBulkBody', () => { ], original_time: '2020-04-20T21:27:45+0000', status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'elastic', - updated_by: 'elastic', - version: 1, - created_at: fakeSignalSourceHit.signal.rule?.created_at, - updated_at: fakeSignalSourceHit.signal.rule?.updated_at, - throttle: 'no_actions', - threat: [], - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 1, }, }; @@ -387,7 +230,7 @@ describe('buildBulkBody', () => { }); test('bulk body builds original_event if it exists on the event to begin with but no kind information', () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.source; @@ -397,21 +240,7 @@ describe('buildBulkBody', () => { module: 'system', dataset: 'socket', }; - const fakeSignalSourceHit = buildBulkBody({ - doc, - ruleParams: sampleParams, - id: sampleRuleGuid, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); + const fakeSignalSourceHit = buildBulkBody(ruleSO, doc); // Timestamp will potentially always be different so remove it for the test // @ts-expect-error delete fakeSignalSourceHit['@timestamp']; @@ -456,43 +285,7 @@ describe('buildBulkBody', () => { ], original_time: '2020-04-20T21:27:45+0000', status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - threat: [], - tags: ['some fake tag 1', 'some fake tag 2'], - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'elastic', - updated_by: 'elastic', - version: 1, - created_at: fakeSignalSourceHit.signal.rule?.created_at, - updated_at: fakeSignalSourceHit.signal.rule?.updated_at, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 1, }, }; @@ -500,7 +293,7 @@ describe('buildBulkBody', () => { }); test('bulk body builds original_event if it exists on the event to begin with with only kind information', () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.source; @@ -508,21 +301,7 @@ describe('buildBulkBody', () => { doc._source.event = { kind: 'event', }; - const fakeSignalSourceHit = buildBulkBody({ - doc, - ruleParams: sampleParams, - id: sampleRuleGuid, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); + const fakeSignalSourceHit = buildBulkBody(ruleSO, doc); // Timestamp will potentially always be different so remove it for the test // @ts-expect-error delete fakeSignalSourceHit['@timestamp']; @@ -562,43 +341,7 @@ describe('buildBulkBody', () => { ], original_time: '2020-04-20T21:27:45+0000', status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'elastic', - updated_by: 'elastic', - version: 1, - updated_at: fakeSignalSourceHit.signal.rule?.updated_at, - created_at: fakeSignalSourceHit.signal.rule?.created_at, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 1, }, }; @@ -606,7 +349,7 @@ describe('buildBulkBody', () => { }); test('bulk body builds "original_signal" if it exists already as a numeric', () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const sampleDoc = sampleDocNoSortId(); // @ts-expect-error @elastic/elasticsearch _source is optional delete sampleDoc._source.source; @@ -617,21 +360,7 @@ describe('buildBulkBody', () => { signal: 123, }, } as unknown) as SignalSourceHit; - const { '@timestamp': timestamp, ...fakeSignalSourceHit } = buildBulkBody({ - doc, - ruleParams: sampleParams, - id: sampleRuleGuid, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); + const { '@timestamp': timestamp, ...fakeSignalSourceHit } = buildBulkBody(ruleSO, doc); const expected: Omit & { someKey: string } = { someKey: 'someValue', event: { @@ -666,43 +395,7 @@ describe('buildBulkBody', () => { ], original_time: '2020-04-20T21:27:45+0000', status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'elastic', - updated_by: 'elastic', - version: 1, - updated_at: fakeSignalSourceHit.signal.rule?.updated_at, - created_at: fakeSignalSourceHit.signal.rule?.created_at, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 1, }, }; @@ -710,7 +403,7 @@ describe('buildBulkBody', () => { }); test('bulk body builds "original_signal" if it exists already as an object', () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const sampleDoc = sampleDocNoSortId(); // @ts-expect-error @elastic/elasticsearch _source is optional delete sampleDoc._source.source; @@ -721,21 +414,7 @@ describe('buildBulkBody', () => { signal: { child_1: { child_2: 'nested data' } }, }, } as unknown) as SignalSourceHit; - const { '@timestamp': timestamp, ...fakeSignalSourceHit } = buildBulkBody({ - doc, - ruleParams: sampleParams, - id: sampleRuleGuid, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); + const { '@timestamp': timestamp, ...fakeSignalSourceHit } = buildBulkBody(ruleSO, doc); const expected: Omit & { someKey: string } = { someKey: 'someValue', event: { @@ -770,43 +449,7 @@ describe('buildBulkBody', () => { ], original_time: '2020-04-20T21:27:45+0000', status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'elastic', - updated_by: 'elastic', - version: 1, - updated_at: fakeSignalSourceHit.signal.rule?.updated_at, - created_at: fakeSignalSourceHit.signal.rule?.created_at, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 1, }, }; @@ -822,7 +465,7 @@ describe('buildSignalFromSequence', () => { const block2 = sampleWrappedSignalHit(); block2._source.new_key = 'new_key_value'; const blocks = [block1, block2]; - const ruleSO = sampleRuleSO(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const signal = buildSignalFromSequence(blocks, ruleSO); // Timestamp will potentially always be different so remove it for the test // @ts-expect-error @@ -893,43 +536,7 @@ describe('buildSignalFromSequence', () => { }, ], status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'sample user', - updated_by: 'sample user', - version: 1, - updated_at: ruleSO.updated_at ?? '', - created_at: ruleSO.attributes.createdAt, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 2, group: { id: '269c1f5754bff92fb8040283b687258e99b03e8b2ab1262cc20c82442e5de5ea', @@ -944,7 +551,7 @@ describe('buildSignalFromSequence', () => { const block2 = sampleWrappedSignalHit(); block2._source['@timestamp'] = '2021-05-20T22:28:46+0000'; block2._source.someKey = 'someOtherValue'; - const ruleSO = sampleRuleSO(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const signal = buildSignalFromSequence([block1, block2], ruleSO); // Timestamp will potentially always be different so remove it for the test // @ts-expect-error @@ -1014,43 +621,7 @@ describe('buildSignalFromSequence', () => { }, ], status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'sample user', - updated_by: 'sample user', - version: 1, - updated_at: ruleSO.updated_at ?? '', - created_at: ruleSO.attributes.createdAt, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 2, group: { id: '269c1f5754bff92fb8040283b687258e99b03e8b2ab1262cc20c82442e5de5ea', @@ -1066,7 +637,7 @@ describe('buildSignalFromEvent', () => { const ancestor = sampleDocWithAncestors().hits.hits[0]; // @ts-expect-error @elastic/elasticsearch _source is optional delete ancestor._source.source; - const ruleSO = sampleRuleSO(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const signal = buildSignalFromEvent(ancestor, ruleSO, true); // Timestamp will potentially always be different so remove it for the test // @ts-expect-error @@ -1113,43 +684,7 @@ describe('buildSignalFromEvent', () => { }, ], status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'sample user', - updated_by: 'sample user', - version: 1, - updated_at: ruleSO.updated_at ?? '', - created_at: ruleSO.attributes.createdAt, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 2, }, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts index 0c03c0837e8e1..10cc168700447 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts @@ -7,68 +7,26 @@ import { SavedObject } from 'src/core/types'; import { + AlertAttributes, SignalSourceHit, SignalHit, Signal, - RuleAlertAttributes, BaseSignalHit, SignalSource, WrappedSignalHit, } from './types'; -import { buildRule, buildRuleWithoutOverrides, buildRuleWithOverrides } from './build_rule'; +import { buildRuleWithoutOverrides, buildRuleWithOverrides } from './build_rule'; import { additionalSignalFields, buildSignal } from './build_signal'; import { buildEventTypeSignal } from './build_event_type_signal'; -import { EqlSequence, RuleAlertAction } from '../../../../common/detection_engine/types'; -import { RuleTypeParams } from '../types'; +import { EqlSequence } from '../../../../common/detection_engine/types'; import { generateSignalId, wrapBuildingBlocks, wrapSignal } from './utils'; -interface BuildBulkBodyParams { - doc: SignalSourceHit; - ruleParams: RuleTypeParams; - id: string; - actions: RuleAlertAction[]; - name: string; - createdAt: string; - createdBy: string; - updatedAt: string; - updatedBy: string; - interval: string; - enabled: boolean; - tags: string[]; - throttle: string; -} - // format search_after result for signals index. -export const buildBulkBody = ({ - doc, - ruleParams, - id, - name, - actions, - createdAt, - createdBy, - updatedAt, - updatedBy, - interval, - enabled, - tags, - throttle, -}: BuildBulkBodyParams): SignalHit => { - const rule = buildRule({ - actions, - ruleParams, - id, - name, - enabled, - createdAt, - createdBy, - doc, - updatedAt, - updatedBy, - interval, - tags, - throttle, - }); +export const buildBulkBody = ( + ruleSO: SavedObject, + doc: SignalSourceHit +): SignalHit => { + const rule = buildRuleWithOverrides(ruleSO, doc._source!); const signal: Signal = { ...buildSignal([doc], rule), ...additionalSignalFields(doc), @@ -96,7 +54,7 @@ export const buildBulkBody = ({ */ export const buildSignalGroupFromSequence = ( sequence: EqlSequence, - ruleSO: SavedObject, + ruleSO: SavedObject, outputIndex: string ): WrappedSignalHit[] => { const wrappedBuildingBlocks = wrapBuildingBlocks( @@ -137,7 +95,7 @@ export const buildSignalGroupFromSequence = ( export const buildSignalFromSequence = ( events: WrappedSignalHit[], - ruleSO: SavedObject + ruleSO: SavedObject ): SignalHit => { const rule = buildRuleWithoutOverrides(ruleSO); const signal: Signal = buildSignal(events, rule); @@ -161,7 +119,7 @@ export const buildSignalFromSequence = ( export const buildSignalFromEvent = ( event: BaseSignalHit, - ruleSO: SavedObject, + ruleSO: SavedObject, applyOverrides: boolean ): SignalHit => { const rule = applyOverrides diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts index 757e6728f244e..412ccf7a40e33 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts @@ -5,34 +5,40 @@ * 2.0. */ -import { - buildRule, - removeInternalTagsFromRule, - buildRuleWithOverrides, - buildRuleWithoutOverrides, -} from './build_rule'; +import { buildRuleWithOverrides, buildRuleWithoutOverrides } from './build_rule'; import { sampleDocNoSortId, - sampleRuleAlertParams, - sampleRuleGuid, - sampleRuleSO, expectedRule, sampleDocSeverity, + sampleRuleSO, } from './__mocks__/es_results'; import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; -import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; import { INTERNAL_RULE_ID_KEY, INTERNAL_IMMUTABLE_KEY } from '../../../../common/constants'; -import { getRulesSchemaMock } from '../../../../common/detection_engine/schemas/response/rules_schema.mocks'; -import { RuleTypeParams } from '../types'; +import { getQueryRuleParams, getThreatRuleParams } from '../schemas/rule_schemas.mock'; +import { ThreatRuleParams } from '../schemas/rule_schemas'; -describe('buildRule', () => { - beforeEach(() => { - jest.clearAllMocks(); +describe('buildRuleWithoutOverrides', () => { + test('builds a rule using rule alert', () => { + const ruleSO = sampleRuleSO(getQueryRuleParams()); + const rule = buildRuleWithoutOverrides(ruleSO); + expect(rule).toEqual(expectedRule()); + }); + + test('builds a rule and removes internal tags', () => { + const ruleSO = sampleRuleSO(getQueryRuleParams()); + ruleSO.attributes.tags = [ + 'some fake tag 1', + 'some fake tag 2', + `${INTERNAL_RULE_ID_KEY}:rule-1`, + `${INTERNAL_IMMUTABLE_KEY}:true`, + ]; + const rule = buildRuleWithoutOverrides(ruleSO); + expect(rule.tags).toEqual(['some fake tag 1', 'some fake tag 2']); }); test('it builds a rule as expected with filters present', () => { - const ruleParams = sampleRuleAlertParams(); - ruleParams.filters = [ + const ruleSO = sampleRuleSO(getQueryRuleParams()); + const ruleFilters = [ { query: 'host.name: Rebecca', }, @@ -43,253 +49,14 @@ describe('buildRule', () => { query: 'host.name: Braden', }, ]; - const rule = buildRule({ - actions: [], - doc: sampleDocNoSortId(), - ruleParams, - name: 'some-name', - id: sampleRuleGuid, - enabled: false, - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: 'some interval', - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); - const expected: Partial = { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - created_by: 'elastic', - description: 'Detecting root and admin users', - enabled: false, - false_positives: [], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: 'some interval', - language: 'kuery', - license: 'Elastic License', - max_signals: 10000, - name: 'some-name', - output_index: '.siem-signals', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - risk_score: 50, - risk_score_mapping: [], - rule_id: 'rule-1', - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - to: 'now', - type: 'query', - note: '', - updated_by: 'elastic', - updated_at: rule.updated_at, - created_at: rule.created_at, - throttle: 'no_actions', - filters: [ - { - query: 'host.name: Rebecca', - }, - { - query: 'host.name: Evan', - }, - { - query: 'host.name: Braden', - }, - ], - exceptions_list: getListArrayMock(), - version: 1, - }; - expect(rule).toEqual(expected); - }); - - test('it omits a null value such as if "enabled" is null if is present', () => { - const ruleParams = sampleRuleAlertParams(); - ruleParams.filters = undefined; - const rule = buildRule({ - actions: [], - doc: sampleDocNoSortId(), - ruleParams, - name: 'some-name', - id: sampleRuleGuid, - enabled: true, - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: 'some interval', - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); - const expected: Partial = { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - created_by: 'elastic', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: 'some interval', - language: 'kuery', - license: 'Elastic License', - max_signals: 10000, - name: 'some-name', - output_index: '.siem-signals', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - risk_score: 50, - risk_score_mapping: [], - rule_id: 'rule-1', - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - to: 'now', - type: 'query', - note: '', - updated_by: 'elastic', - version: 1, - updated_at: rule.updated_at, - created_at: rule.created_at, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }; - expect(rule).toEqual(expected); - }); - - test('it omits a null value such as if "filters" is undefined if is present', () => { - const ruleParams = sampleRuleAlertParams(); - ruleParams.filters = undefined; - const rule = buildRule({ - actions: [], - doc: sampleDocNoSortId(), - ruleParams, - name: 'some-name', - id: sampleRuleGuid, - enabled: true, - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: 'some interval', - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); - const expected: Partial = { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - created_by: 'elastic', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: 'some interval', - language: 'kuery', - license: 'Elastic License', - max_signals: 10000, - name: 'some-name', - note: '', - output_index: '.siem-signals', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - risk_score: 50, - risk_score_mapping: [], - rule_id: 'rule-1', - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - to: 'now', - type: 'query', - updated_by: 'elastic', - version: 1, - updated_at: rule.updated_at, - created_at: rule.created_at, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }; - expect(rule).toEqual(expected); - }); - - test('it builds a rule and removes internal tags', () => { - const ruleParams = sampleRuleAlertParams(); - const rule = buildRule({ - actions: [], - doc: sampleDocNoSortId(), - ruleParams, - name: 'some-name', - id: sampleRuleGuid, - enabled: false, - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: 'some interval', - tags: [ - 'some fake tag 1', - 'some fake tag 2', - `${INTERNAL_RULE_ID_KEY}:rule-1`, - `${INTERNAL_IMMUTABLE_KEY}:true`, - ], - throttle: 'no_actions', - }); - const expected: Partial = { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - created_by: 'elastic', - description: 'Detecting root and admin users', - enabled: false, - false_positives: [], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: 'some interval', - language: 'kuery', - license: 'Elastic License', - max_signals: 10000, - name: 'some-name', - output_index: '.siem-signals', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - risk_score: 50, - risk_score_mapping: [], - rule_id: 'rule-1', - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - to: 'now', - type: 'query', - note: '', - updated_by: 'elastic', - updated_at: rule.updated_at, - created_at: rule.created_at, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - version: 1, - }; - expect(rule).toEqual(expected); + ruleSO.attributes.params.filters = ruleFilters; + const rule = buildRuleWithoutOverrides(ruleSO); + expect(rule.filters).toEqual(ruleFilters); }); test('it creates a indicator/threat_mapping/threat_matching rule', () => { - const ruleParams: RuleTypeParams = { - ...sampleRuleAlertParams(), + const ruleParams: ThreatRuleParams = { + ...getThreatRuleParams(), threatMapping: [ { entries: [ @@ -323,21 +90,8 @@ describe('buildRule', () => { threatIndex: ['threat_index'], threatLanguage: 'kuery', }; - const threatMatchRule = buildRule({ - actions: [], - doc: sampleDocNoSortId(), - ruleParams, - name: 'some-name', - id: sampleRuleGuid, - enabled: false, - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: 'some interval', - tags: [], - throttle: 'no_actions', - }); + const ruleSO = sampleRuleSO(ruleParams); + const threatMatchRule = buildRuleWithoutOverrides(ruleSO); const expected: Partial = { threat_mapping: ruleParams.threatMapping, threat_filters: ruleParams.threatFilters, @@ -350,106 +104,18 @@ describe('buildRule', () => { }); }); -describe('removeInternalTagsFromRule', () => { - test('it removes internal tags from a typical rule', () => { - const rule = getRulesSchemaMock(); - rule.tags = [ - 'some fake tag 1', - 'some fake tag 2', - `${INTERNAL_RULE_ID_KEY}:rule-1`, - `${INTERNAL_IMMUTABLE_KEY}:true`, - ]; - const noInternals = removeInternalTagsFromRule(rule); - expect(noInternals).toEqual(getRulesSchemaMock()); - }); - - test('it works with an empty array', () => { - const rule = getRulesSchemaMock(); - rule.tags = []; - const noInternals = removeInternalTagsFromRule(rule); - const expected = getRulesSchemaMock(); - expected.tags = []; - expect(noInternals).toEqual(expected); - }); - - test('it works if tags contains normal values and no internal values', () => { - const rule = getRulesSchemaMock(); - const noInternals = removeInternalTagsFromRule(rule); - expect(noInternals).toEqual(rule); - }); -}); - -describe('buildRuleWithoutOverrides', () => { - test('builds a rule using rule SO', () => { - const ruleSO = sampleRuleSO(); - const rule = buildRuleWithoutOverrides(ruleSO); - expect(rule).toEqual(expectedRule()); - }); - - test('builds a rule using rule SO and removes internal tags', () => { - const ruleSO = sampleRuleSO(); - ruleSO.attributes.tags = [ - 'some fake tag 1', - 'some fake tag 2', - `${INTERNAL_RULE_ID_KEY}:rule-1`, - `${INTERNAL_IMMUTABLE_KEY}:true`, - ]; - const rule = buildRuleWithoutOverrides(ruleSO); - expect(rule).toEqual(expectedRule()); - }); -}); - describe('buildRuleWithOverrides', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - test('it builds a rule as expected with filters present', () => { - const ruleSO = sampleRuleSO(); - ruleSO.attributes.params.filters = [ - { - query: 'host.name: Rebecca', - }, - { - query: 'host.name: Evan', - }, - { - query: 'host.name: Braden', - }, - ]; - // @ts-expect-error @elastic/elasticsearch _source is optional - const rule = buildRuleWithOverrides(ruleSO, sampleDocNoSortId()._source); - const expected: RulesSchema = { - ...expectedRule(), - filters: ruleSO.attributes.params.filters, - }; - expect(rule).toEqual(expected); - }); - - test('it builds a rule and removes internal tags', () => { - const ruleSO = sampleRuleSO(); - ruleSO.attributes.tags = [ - 'some fake tag 1', - 'some fake tag 2', - `${INTERNAL_RULE_ID_KEY}:rule-1`, - `${INTERNAL_IMMUTABLE_KEY}:true`, - ]; - // @ts-expect-error @elastic/elasticsearch _source is optional - const rule = buildRuleWithOverrides(ruleSO, sampleDocNoSortId()._source); - expect(rule).toEqual(expectedRule()); - }); - test('it applies rule name override in buildRule', () => { - const ruleSO = sampleRuleSO(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); ruleSO.attributes.params.ruleNameOverride = 'someKey'; - // @ts-expect-error @elastic/elasticsearch _source is optional - const rule = buildRuleWithOverrides(ruleSO, sampleDocNoSortId()._source); + const rule = buildRuleWithOverrides(ruleSO, sampleDocNoSortId()._source!); const expected = { ...expectedRule(), name: 'someValue', rule_name_override: 'someKey', meta: { ruleNameOverridden: true, + someMeta: 'someField', }, }; expect(rule).toEqual(expected); @@ -457,7 +123,7 @@ describe('buildRuleWithOverrides', () => { test('it applies risk score override in buildRule', () => { const newRiskScore = 79; - const ruleSO = sampleRuleSO(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); ruleSO.attributes.params.riskScoreMapping = [ { field: 'new_risk_score', @@ -470,14 +136,14 @@ describe('buildRuleWithOverrides', () => { const doc = sampleDocNoSortId(); // @ts-expect-error @elastic/elasticsearch _source is optional doc._source.new_risk_score = newRiskScore; - // @ts-expect-error @elastic/elasticsearch _source is optional - const rule = buildRuleWithOverrides(ruleSO, doc._source); + const rule = buildRuleWithOverrides(ruleSO, doc._source!); const expected = { ...expectedRule(), risk_score: newRiskScore, risk_score_mapping: ruleSO.attributes.params.riskScoreMapping, meta: { riskScoreOverridden: true, + someMeta: 'someField', }, }; expect(rule).toEqual(expected); @@ -485,7 +151,7 @@ describe('buildRuleWithOverrides', () => { test('it applies severity override in buildRule', () => { const eventSeverity = '42'; - const ruleSO = sampleRuleSO(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); ruleSO.attributes.params.severityMapping = [ { field: 'event.severity', @@ -495,14 +161,14 @@ describe('buildRuleWithOverrides', () => { }, ]; const doc = sampleDocSeverity(Number(eventSeverity)); - // @ts-expect-error @elastic/elasticsearch _source is optional - const rule = buildRuleWithOverrides(ruleSO, doc._source); + const rule = buildRuleWithOverrides(ruleSO, doc._source!); const expected = { ...expectedRule(), severity: 'critical', severity_mapping: ruleSO.attributes.params.severityMapping, meta: { severityOverrideField: 'event.severity', + someMeta: 'someField', }, }; expect(rule).toEqual(expected); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts index 7755f2af70d84..55f22188a7ec8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts @@ -7,202 +7,35 @@ import { SavedObject } from 'src/core/types'; import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; -import { RuleAlertAction } from '../../../../common/detection_engine/types'; -import { RuleTypeParams } from '../types'; import { buildRiskScoreFromMapping } from './mappings/build_risk_score_from_mapping'; -import { SignalSourceHit, RuleAlertAttributes, SignalSource } from './types'; +import { AlertAttributes, SignalSource } from './types'; import { buildSeverityFromMapping } from './mappings/build_severity_from_mapping'; import { buildRuleNameFromMapping } from './mappings/build_rule_name_from_mapping'; -import { INTERNAL_IDENTIFIER } from '../../../../common/constants'; +import { RuleParams } from '../schemas/rule_schemas'; +import { commonParamsCamelToSnake, typeSpecificCamelToSnake } from '../schemas/rule_converters'; +import { transformTags } from '../routes/rules/utils'; -interface BuildRuleParams { - ruleParams: RuleTypeParams; - name: string; - id: string; - actions: RuleAlertAction[]; - enabled: boolean; - createdAt: string; - createdBy: string; - doc: SignalSourceHit; - updatedAt: string; - updatedBy: string; - interval: string; - tags: string[]; - throttle: string; -} - -export const buildRule = ({ - ruleParams, - name, - id, - actions, - enabled, - createdAt, - createdBy, - doc, - updatedAt, - updatedBy, - interval, - tags, - throttle, -}: BuildRuleParams): RulesSchema => { - const { riskScore, riskScoreMeta } = buildRiskScoreFromMapping({ - // @ts-expect-error @elastic/elasticsearch _source is optional - eventSource: doc._source, - riskScore: ruleParams.riskScore, - riskScoreMapping: ruleParams.riskScoreMapping, - }); - - const { severity, severityMeta } = buildSeverityFromMapping({ - // @ts-expect-error @elastic/elasticsearch _source is optional - eventSource: doc._source, - severity: ruleParams.severity, - severityMapping: ruleParams.severityMapping, - }); - - const { ruleName, ruleNameMeta } = buildRuleNameFromMapping({ - // @ts-expect-error @elastic/elasticsearch _source is optional - eventSource: doc._source, - ruleName: name, - ruleNameMapping: ruleParams.ruleNameOverride, - }); - - const meta: RulesSchema['meta'] = { - ...ruleParams.meta, - ...riskScoreMeta, - ...severityMeta, - ...ruleNameMeta, - }; - - const rule: RulesSchema = { - id, - rule_id: ruleParams.ruleId ?? '(unknown rule_id)', - actions, - author: ruleParams.author ?? [], - building_block_type: ruleParams.buildingBlockType, - false_positives: ruleParams.falsePositives, - saved_id: ruleParams.savedId, - timeline_id: ruleParams.timelineId, - timeline_title: ruleParams.timelineTitle, - meta: Object.keys(meta).length > 0 ? meta : undefined, - max_signals: ruleParams.maxSignals, - risk_score: riskScore, - risk_score_mapping: ruleParams.riskScoreMapping ?? [], - output_index: ruleParams.outputIndex, - description: ruleParams.description, - note: ruleParams.note, - from: ruleParams.from, - immutable: ruleParams.immutable, - index: ruleParams.index, - interval, - language: ruleParams.language, - license: ruleParams.license, - name: ruleName, - query: ruleParams.query, - references: ruleParams.references, - rule_name_override: ruleParams.ruleNameOverride, - severity, - severity_mapping: ruleParams.severityMapping ?? [], - tags, - type: ruleParams.type, - to: ruleParams.to, - enabled, - filters: ruleParams.filters, - created_by: createdBy, - updated_by: updatedBy, - threat: ruleParams.threat ?? [], - threat_mapping: ruleParams.threatMapping, - threat_filters: ruleParams.threatFilters, - threat_indicator_path: ruleParams.threatIndicatorPath, - threat_query: ruleParams.threatQuery, - threat_index: ruleParams.threatIndex, - threat_language: ruleParams.threatLanguage, - timestamp_override: ruleParams.timestampOverride, - throttle, - version: ruleParams.version, - created_at: createdAt, - updated_at: updatedAt, - exceptions_list: ruleParams.exceptionsList ?? [], - machine_learning_job_id: ruleParams.machineLearningJobId, - anomaly_threshold: ruleParams.anomalyThreshold, - threshold: ruleParams.threshold, - }; - return removeInternalTagsFromRule(rule); -}; - -export const buildRuleWithoutOverrides = ( - ruleSO: SavedObject -): RulesSchema => { +export const buildRuleWithoutOverrides = (ruleSO: SavedObject): RulesSchema => { const ruleParams = ruleSO.attributes.params; - const rule: RulesSchema = { + return { id: ruleSO.id, - rule_id: ruleParams.ruleId, actions: ruleSO.attributes.actions, - author: ruleParams.author ?? [], - building_block_type: ruleParams.buildingBlockType, - false_positives: ruleParams.falsePositives, - saved_id: ruleParams.savedId, - timeline_id: ruleParams.timelineId, - timeline_title: ruleParams.timelineTitle, - meta: ruleParams.meta, - max_signals: ruleParams.maxSignals, - risk_score: ruleParams.riskScore, - risk_score_mapping: [], - output_index: ruleParams.outputIndex, - description: ruleParams.description, - note: ruleParams.note, - from: ruleParams.from, - immutable: ruleParams.immutable, - index: ruleParams.index, interval: ruleSO.attributes.schedule.interval, - language: ruleParams.language, - license: ruleParams.license, name: ruleSO.attributes.name, - query: ruleParams.query, - references: ruleParams.references, - severity: ruleParams.severity, - severity_mapping: [], - tags: ruleSO.attributes.tags, - type: ruleParams.type, - to: ruleParams.to, + tags: transformTags(ruleSO.attributes.tags), enabled: ruleSO.attributes.enabled, - filters: ruleParams.filters, created_by: ruleSO.attributes.createdBy, updated_by: ruleSO.attributes.updatedBy, - threat: ruleParams.threat ?? [], - timestamp_override: ruleParams.timestampOverride, throttle: ruleSO.attributes.throttle, - version: ruleParams.version, created_at: ruleSO.attributes.createdAt, updated_at: ruleSO.updated_at ?? '', - exceptions_list: ruleParams.exceptionsList ?? [], - machine_learning_job_id: ruleParams.machineLearningJobId, - anomaly_threshold: ruleParams.anomalyThreshold, - threshold: ruleParams.threshold, - threat_filters: ruleParams.threatFilters, - threat_index: ruleParams.threatIndex, - threat_query: ruleParams.threatQuery, - threat_mapping: ruleParams.threatMapping, - threat_language: ruleParams.threatLanguage, - threat_indicator_path: ruleParams.threatIndicatorPath, + ...commonParamsCamelToSnake(ruleParams), + ...typeSpecificCamelToSnake(ruleParams), }; - return removeInternalTagsFromRule(rule); -}; - -export const removeInternalTagsFromRule = (rule: RulesSchema): RulesSchema => { - if (rule.tags == null) { - return rule; - } else { - const ruleWithoutInternalTags: RulesSchema = { - ...rule, - tags: rule.tags.filter((tag) => !tag.startsWith(INTERNAL_IDENTIFIER)), - }; - return ruleWithoutInternalTags; - } }; export const buildRuleWithOverrides = ( - ruleSO: SavedObject, + ruleSO: SavedObject, eventSource: SignalSource ): RulesSchema => { const ruleWithoutOverrides = buildRuleWithoutOverrides(ruleSO); @@ -212,7 +45,7 @@ export const buildRuleWithOverrides = ( export const applyRuleOverrides = ( rule: RulesSchema, eventSource: SignalSource, - ruleParams: RuleTypeParams + ruleParams: RuleParams ): RulesSchema => { const { riskScore, riskScoreMeta } = buildRiskScoreFromMapping({ eventSource, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_ml_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_ml_signals.ts index a5e05d07ee1e1..00ac40fa7e27c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_ml_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_ml_signals.ts @@ -8,36 +8,27 @@ import type { estypes } from '@elastic/elasticsearch'; import { flow, omit } from 'lodash/fp'; import set from 'set-value'; -import { Logger } from '../../../../../../../src/core/server'; +import { Logger, SavedObject } from '../../../../../../../src/core/server'; import { AlertInstanceContext, AlertInstanceState, AlertServices, } from '../../../../../alerting/server'; -import { RuleAlertAction } from '../../../../common/detection_engine/types'; -import { RuleTypeParams, RefreshTypes } from '../types'; +import { RefreshTypes } from '../types'; import { singleBulkCreate, SingleBulkCreateResponse } from './single_bulk_create'; import { AnomalyResults, Anomaly } from '../../machine_learning'; import { BuildRuleMessage } from './rule_messages'; +import { AlertAttributes } from './types'; +import { MachineLearningRuleParams } from '../schemas/rule_schemas'; interface BulkCreateMlSignalsParams { - actions: RuleAlertAction[]; someResult: AnomalyResults; - ruleParams: RuleTypeParams; + ruleSO: SavedObject>; services: AlertServices; logger: Logger; id: string; signalsIndex: string; - name: string; - createdAt: string; - createdBy: string; - updatedAt: string; - updatedBy: string; - interval: string; - enabled: boolean; refresh: RefreshTypes; - tags: string[]; - throttle: string; buildRuleMessage: BuildRuleMessage; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts index a4763f67004f6..aa51d133260b8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts @@ -20,13 +20,14 @@ import { ExceptionListItemSchema } from '../../../../../common/shared_imports'; import { isOutdated } from '../../migrations/helpers'; import { getIndexVersion } from '../../routes/index/get_index_version'; import { MIN_EQL_RULE_INDEX_VERSION } from '../../routes/index/get_signals_template'; +import { EqlRuleParams } from '../../schemas/rule_schemas'; import { RefreshTypes } from '../../types'; import { buildSignalFromEvent, buildSignalGroupFromSequence } from '../build_bulk_body'; import { getInputIndex } from '../get_input_output_index'; import { RuleStatusService } from '../rule_status_service'; import { bulkInsertSignals, filterDuplicateSignals } from '../single_bulk_create'; import { - EqlRuleAttributes, + AlertAttributes, EqlSignalSearchResponse, SearchAfterAndBulkCreateReturnType, WrappedSignalHit, @@ -43,7 +44,7 @@ export const eqlExecutor = async ({ logger, refresh, }: { - rule: SavedObject; + rule: SavedObject>; exceptionItems: ExceptionListItemSchema[]; ruleStatusService: RuleStatusService; services: AlertServices; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/ml.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/ml.ts index 12ebca1aa3e7c..338ad2dbe9d40 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/ml.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/ml.ts @@ -16,13 +16,14 @@ import { ListClient } from '../../../../../../lists/server'; import { isJobStarted } from '../../../../../common/machine_learning/helpers'; import { ExceptionListItemSchema } from '../../../../../common/shared_imports'; import { SetupPlugins } from '../../../../plugin'; +import { MachineLearningRuleParams } from '../../schemas/rule_schemas'; import { RefreshTypes } from '../../types'; import { bulkCreateMlSignals } from '../bulk_create_ml_signals'; import { filterEventsAgainstList } from '../filters/filter_events_against_list'; import { findMlSignals } from '../find_ml_signals'; import { BuildRuleMessage } from '../rule_messages'; import { RuleStatusService } from '../rule_status_service'; -import { MachineLearningRuleAttributes } from '../types'; +import { AlertAttributes } from '../types'; import { createErrorsFromShard, createSearchAfterReturnType, mergeReturns } from '../utils'; export const mlExecutor = async ({ @@ -36,7 +37,7 @@ export const mlExecutor = async ({ refresh, buildRuleMessage, }: { - rule: SavedObject; + rule: SavedObject>; ml: SetupPlugins['ml']; listClient: ListClient; exceptionItems: ExceptionListItemSchema[]; @@ -105,23 +106,13 @@ export const mlExecutor = async ({ createdItemsCount, createdItems, } = await bulkCreateMlSignals({ - actions: rule.attributes.actions, - throttle: rule.attributes.throttle, someResult: filteredAnomalyResults, - ruleParams, + ruleSO: rule, services, logger, id: rule.id, signalsIndex: ruleParams.outputIndex, - name: rule.attributes.name, - createdBy: rule.attributes.createdBy, - createdAt: rule.attributes.createdAt, - updatedBy: rule.attributes.updatedBy, - updatedAt: rule.updated_at ?? '', - interval: rule.attributes.schedule.interval, - enabled: rule.attributes.enabled, refresh, - tags: rule.attributes.tags, buildRuleMessage, }); // The legacy ES client does not define failures when it can be present on the structure, hence why I have the & { failures: [] } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts index 9914eb04c6ca6..751a1fa081752 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts @@ -18,9 +18,10 @@ import { RefreshTypes } from '../../types'; import { getFilter } from '../get_filter'; import { getInputIndex } from '../get_input_output_index'; import { searchAfterAndBulkCreate } from '../search_after_bulk_create'; -import { QueryRuleAttributes, RuleRangeTuple } from '../types'; +import { AlertAttributes, RuleRangeTuple } from '../types'; import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; +import { QueryRuleParams } from '../../schemas/rule_schemas'; export const queryExecutor = async ({ rule, @@ -35,7 +36,7 @@ export const queryExecutor = async ({ eventsTelemetry, buildRuleMessage, }: { - rule: SavedObject; + rule: SavedObject>; tuples: RuleRangeTuple[]; listClient: ListClient; exceptionItems: ExceptionListItemSchema[]; @@ -64,7 +65,7 @@ export const queryExecutor = async ({ tuples, listClient, exceptionsList: exceptionItems, - ruleParams, + ruleSO: rule, services, logger, eventsTelemetry, @@ -72,18 +73,8 @@ export const queryExecutor = async ({ inputIndexPattern: inputIndex, signalsIndex: ruleParams.outputIndex, filter: esFilter, - actions: rule.attributes.actions, - name: rule.attributes.name, - createdBy: rule.attributes.createdBy, - createdAt: rule.attributes.createdAt, - updatedBy: rule.attributes.updatedBy, - updatedAt: rule.updated_at ?? '', - interval: rule.attributes.schedule.interval, - enabled: rule.attributes.enabled, pageSize: searchAfterSize, refresh, - tags: rule.attributes.tags, - throttle: rule.attributes.throttle, buildRuleMessage, }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threat_match.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threat_match.ts index 5a8e945c3b06e..62619cf948d40 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threat_match.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threat_match.ts @@ -16,10 +16,11 @@ import { ListClient } from '../../../../../../lists/server'; import { ExceptionListItemSchema } from '../../../../../common/shared_imports'; import { RefreshTypes } from '../../types'; import { getInputIndex } from '../get_input_output_index'; -import { RuleRangeTuple, ThreatRuleAttributes } from '../types'; +import { RuleRangeTuple, AlertAttributes } from '../types'; import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; import { createThreatSignals } from '../threat_mapping/create_threat_signals'; +import { ThreatRuleParams } from '../../schemas/rule_schemas'; export const threatMatchExecutor = async ({ rule, @@ -34,7 +35,7 @@ export const threatMatchExecutor = async ({ eventsTelemetry, buildRuleMessage, }: { - rule: SavedObject; + rule: SavedObject>; tuples: RuleRangeTuple[]; listClient: ListClient; exceptionItems: ExceptionListItemSchema[]; @@ -56,7 +57,6 @@ export const threatMatchExecutor = async ({ type: ruleParams.type, filters: ruleParams.filters ?? [], language: ruleParams.language, - name: rule.attributes.name, savedId: ruleParams.savedId, services, exceptionItems, @@ -65,18 +65,9 @@ export const threatMatchExecutor = async ({ eventsTelemetry, alertId: rule.id, outputIndex: ruleParams.outputIndex, - params: ruleParams, + ruleSO: rule, searchAfterSize, - actions: rule.attributes.actions, - createdBy: rule.attributes.createdBy, - createdAt: rule.attributes.createdAt, - updatedBy: rule.attributes.updatedBy, - interval: rule.attributes.schedule.interval, - updatedAt: rule.updated_at ?? '', - enabled: rule.attributes.enabled, refresh, - tags: rule.attributes.tags, - throttle: rule.attributes.throttle, threatFilters: ruleParams.threatFilters ?? [], threatQuery: ruleParams.threatQuery, threatLanguage: ruleParams.threatLanguage, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts index c8f70449251f6..204481f5d910c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts @@ -12,11 +12,9 @@ import { AlertInstanceState, AlertServices, } from '../../../../../../alerting/server'; -import { - hasLargeValueItem, - normalizeThresholdField, -} from '../../../../../common/detection_engine/utils'; +import { hasLargeValueItem } from '../../../../../common/detection_engine/utils'; import { ExceptionListItemSchema } from '../../../../../common/shared_imports'; +import { ThresholdRuleParams } from '../../schemas/rule_schemas'; import { RefreshTypes } from '../../types'; import { getFilter } from '../get_filter'; import { getInputIndex } from '../get_input_output_index'; @@ -28,11 +26,7 @@ import { getThresholdBucketFilters, getThresholdSignalHistory, } from '../threshold'; -import { - RuleRangeTuple, - SearchAfterAndBulkCreateReturnType, - ThresholdRuleAttributes, -} from '../types'; +import { AlertAttributes, RuleRangeTuple, SearchAfterAndBulkCreateReturnType } from '../types'; import { createSearchAfterReturnType, createSearchAfterReturnTypeFromResponse, @@ -51,7 +45,7 @@ export const thresholdExecutor = async ({ buildRuleMessage, startedAt, }: { - rule: SavedObject; + rule: SavedObject>; tuples: RuleRangeTuple[]; exceptionItems: ExceptionListItemSchema[]; ruleStatusService: RuleStatusService; @@ -83,7 +77,7 @@ export const thresholdExecutor = async ({ services, logger, ruleId: ruleParams.ruleId, - bucketByFields: normalizeThresholdField(ruleParams.threshold.field), + bucketByFields: ruleParams.threshold.field, timestampOverride: ruleParams.timestampOverride, buildRuleMessage, }); @@ -127,28 +121,17 @@ export const thresholdExecutor = async ({ createdItems, errors, } = await bulkCreateThresholdSignals({ - actions: rule.attributes.actions, - throttle: rule.attributes.throttle, someResult: thresholdResults, - ruleParams, + ruleSO: rule, filter: esFilter, services, logger, id: rule.id, inputIndexPattern: inputIndex, signalsIndex: ruleParams.outputIndex, - timestampOverride: ruleParams.timestampOverride, startedAt, from: tuple.from.toDate(), - name: rule.attributes.name, - createdBy: rule.attributes.createdBy, - createdAt: rule.attributes.createdAt, - updatedBy: rule.attributes.updatedBy, - updatedAt: rule.updated_at ?? '', - interval: rule.attributes.schedule.interval, - enabled: rule.attributes.enabled, refresh, - tags: rule.attributes.tags, thresholdSignalHistory, buildRuleMessage, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 6deb45095ec36..9d9eefe844532 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -6,9 +6,9 @@ */ import { - sampleRuleAlertParams, sampleEmptyDocSearchResults, sampleRuleGuid, + sampleRuleSO, mockLogger, repeatedSearchResultsWithSortId, repeatedSearchResultsWithNoSortId, @@ -28,6 +28,7 @@ import { getSearchListItemResponseMock } from '../../../../../lists/common/schem import { getRuleRangeTuples } from './utils'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; const buildRuleMessage = buildRuleMessageFactory({ id: 'fake id', @@ -41,7 +42,9 @@ describe('searchAfterAndBulkCreate', () => { let inputIndexPattern: string[] = []; let listClient = listMock.getListClient(); const someGuids = Array.from({ length: 13 }).map(() => uuid.v4()); - const sampleParams = sampleRuleAlertParams(30); + const sampleParams = getQueryRuleParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); + sampleParams.maxSignals = 30; let tuples: RuleRangeTuple[]; beforeEach(() => { jest.clearAllMocks(); @@ -164,8 +167,8 @@ describe('searchAfterAndBulkCreate', () => { }, ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - ruleParams: sampleParams, tuples, + ruleSO, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -174,19 +177,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -277,7 +270,7 @@ describe('searchAfterAndBulkCreate', () => { }, ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - ruleParams: sampleParams, + ruleSO, tuples, listClient, exceptionsList: [exceptionItem], @@ -287,19 +280,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -364,7 +347,7 @@ describe('searchAfterAndBulkCreate', () => { }, ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - ruleParams: sampleParams, + ruleSO, tuples, listClient, exceptionsList: [exceptionItem], @@ -374,19 +357,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -432,7 +405,7 @@ describe('searchAfterAndBulkCreate', () => { }, ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - ruleParams: sampleParams, + ruleSO, tuples, listClient, exceptionsList: [exceptionItem], @@ -442,19 +415,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -496,7 +459,7 @@ describe('searchAfterAndBulkCreate', () => { }, ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - ruleParams: sampleParams, + ruleSO, tuples, listClient, exceptionsList: [exceptionItem], @@ -506,19 +469,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -582,7 +535,7 @@ describe('searchAfterAndBulkCreate', () => { }, ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - ruleParams: sampleParams, + ruleSO, tuples, listClient, exceptionsList: [exceptionItem], @@ -592,19 +545,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -670,7 +613,7 @@ describe('searchAfterAndBulkCreate', () => { ) ); const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - ruleParams: sampleParams, + ruleSO, tuples, listClient, exceptionsList: [], @@ -680,19 +623,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -726,26 +659,16 @@ describe('searchAfterAndBulkCreate', () => { listClient, exceptionsList: [exceptionItem], tuples, - ruleParams: sampleParams, + ruleSO, services: mockService, logger: mockLogger, eventsTelemetry: undefined, id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(mockLogger.error).toHaveBeenCalled(); @@ -782,26 +705,16 @@ describe('searchAfterAndBulkCreate', () => { listClient, exceptionsList: [exceptionItem], tuples, - ruleParams: sampleParams, + ruleSO, services: mockService, logger: mockLogger, eventsTelemetry: undefined, id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -852,26 +765,16 @@ describe('searchAfterAndBulkCreate', () => { listClient, exceptionsList: [exceptionItem], tuples, - ruleParams: sampleParams, + ruleSO, services: mockService, logger: mockLogger, eventsTelemetry: undefined, id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(false); @@ -984,7 +887,7 @@ describe('searchAfterAndBulkCreate', () => { lastLookBackDate, errors, } = await searchAfterAndBulkCreate({ - ruleParams: sampleParams, + ruleSO, tuples, listClient, exceptionsList: [], @@ -994,19 +897,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(false); @@ -1089,7 +982,7 @@ describe('searchAfterAndBulkCreate', () => { const mockEnrichment = jest.fn((a) => a); const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ enrichment: mockEnrichment, - ruleParams: sampleParams, + ruleSO, tuples, listClient, exceptionsList: [], @@ -1099,19 +992,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index cfe30a6602381..0bc0039b54dba 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -25,7 +25,7 @@ import { SearchAfterAndBulkCreateParams, SearchAfterAndBulkCreateReturnType } fr // search_after through documents and re-index using bulk endpoint. export const searchAfterAndBulkCreate = async ({ tuples: totalToFromTuples, - ruleParams, + ruleSO, exceptionsList, services, listClient, @@ -35,21 +35,12 @@ export const searchAfterAndBulkCreate = async ({ inputIndexPattern, signalsIndex, filter, - actions, - name, - createdAt, - createdBy, - updatedBy, - updatedAt, - interval, - enabled, pageSize, refresh, - tags, - throttle, buildRuleMessage, enrichment = identity, }: SearchAfterAndBulkCreateParams): Promise => { + const ruleParams = ruleSO.attributes.params; let toReturn = createSearchAfterReturnType(); // sortId tells us where to start our next consecutive search_after query @@ -218,22 +209,12 @@ export const searchAfterAndBulkCreate = async ({ } = await singleBulkCreate({ buildRuleMessage, filteredEvents: enrichedEvents, - ruleParams, + ruleSO, services, logger, id, signalsIndex, - actions, - name, - createdAt, - createdBy, - updatedAt, - updatedBy, - interval, - enabled, refresh, - tags, - throttle, }); toReturn = mergeReturns([ toReturn, @@ -252,13 +233,7 @@ export const searchAfterAndBulkCreate = async ({ buildRuleMessage(`filteredEvents.hits.hits: ${filteredEvents.hits.hits.length}`) ); - sendAlertTelemetryEvents( - logger, - eventsTelemetry, - filteredEvents, - ruleParams, - buildRuleMessage - ); + sendAlertTelemetryEvents(logger, eventsTelemetry, filteredEvents, buildRuleMessage); } if (!hasSortId && !hasBackupSortId) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.ts index f7d21adc4bea9..d87427576cd8f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.ts @@ -6,7 +6,6 @@ */ import { TelemetryEventsSender, TelemetryEvent } from '../../telemetry/sender'; -import { RuleTypeParams } from '../types'; import { BuildRuleMessage } from './rule_messages'; import { SignalSearchResponse, SignalSource } from './types'; import { Logger } from '../../../../../../../src/core/server'; @@ -31,7 +30,6 @@ export function sendAlertTelemetryEvents( logger: Logger, eventsTelemetry: TelemetryEventsSender | undefined, filteredEvents: SignalSearchResponse, - ruleParams: RuleTypeParams, buildRuleMessage: BuildRuleMessage ) { if (eventsTelemetry === undefined) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.mock.ts deleted file mode 100644 index d1cab7397bbfd..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.mock.ts +++ /dev/null @@ -1,55 +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 { SignalParamsSchema } from './signal_params_schema'; - -export const getSignalParamsSchemaMock = (): Partial => ({ - description: 'Detecting root and admin users', - query: 'user.name: root or user.name: admin', - severity: 'high', - type: 'query', - riskScore: 55, - language: 'kuery', - ruleId: 'rule-1', - from: 'now-6m', - to: 'now', -}); - -export const getSignalParamsSchemaDecodedMock = (): SignalParamsSchema => ({ - author: [], - buildingBlockType: null, - description: 'Detecting root and admin users', - eventCategoryOverride: undefined, - falsePositives: [], - filters: null, - from: 'now-6m', - immutable: false, - index: null, - language: 'kuery', - license: null, - maxSignals: 100, - meta: null, - note: null, - outputIndex: null, - query: 'user.name: root or user.name: admin', - references: [], - riskScore: 55, - riskScoreMapping: null, - ruleNameOverride: null, - ruleId: 'rule-1', - savedId: null, - severity: 'high', - severityMapping: null, - threatFilters: null, - threat: null, - timelineId: null, - timelineTitle: null, - timestampOverride: null, - to: 'now', - type: 'query', - version: 1, -}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.test.ts deleted file mode 100644 index 21db1e55b9810..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.test.ts +++ /dev/null @@ -1,158 +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 { signalParamsSchema, SignalParamsSchema } from './signal_params_schema'; -import { - getSignalParamsSchemaDecodedMock, - getSignalParamsSchemaMock, -} from './signal_params_schema.mock'; -import { DEFAULT_MAX_SIGNALS } from '../../../../common/constants'; - -describe('signal_params_schema', () => { - test('it works with expected basic mock data set', () => { - const schema = signalParamsSchema(); - expect(schema.validate(getSignalParamsSchemaMock())).toEqual( - getSignalParamsSchemaDecodedMock() - ); - }); - - test('it works on older lists data structures if they exist as an empty array', () => { - const schema = signalParamsSchema(); - const mock: Partial = { lists: [], ...getSignalParamsSchemaMock() }; - const expected: Partial = { - lists: [], - ...getSignalParamsSchemaDecodedMock(), - }; - expect(schema.validate(mock)).toEqual(expected); - }); - - test('it works on older exceptions_list data structures if they exist as an empty array', () => { - const schema = signalParamsSchema(); - const mock: Partial = { - exceptions_list: [], - ...getSignalParamsSchemaMock(), - }; - const expected: Partial = { - exceptions_list: [], - ...getSignalParamsSchemaDecodedMock(), - }; - expect(schema.validate(mock)).toEqual(expected); - }); - - test('it throws if given an invalid value', () => { - const schema = signalParamsSchema(); - const mock: Partial & { madeUpValue: string } = { - madeUpValue: 'something', - ...getSignalParamsSchemaMock(), - }; - expect(() => schema.validate(mock)).toThrow( - '[madeUpValue]: definition for this key is missing' - ); - }); - - test('if risk score is a string then it will be converted into a number before being inserted as data', () => { - const schema = signalParamsSchema(); - const mock: Omit, 'riskScore'> & { riskScore: string } = { - ...getSignalParamsSchemaMock(), - riskScore: '5', - }; - expect(schema.validate(mock).riskScore).toEqual(5); - expect(typeof schema.validate(mock).riskScore).toEqual('number'); - }); - - test('if risk score is a number then it will work as a number', () => { - const schema = signalParamsSchema(); - const mock: Partial = { - ...getSignalParamsSchemaMock(), - riskScore: 5, - }; - expect(schema.validate(mock).riskScore).toEqual(5); - expect(typeof schema.validate(mock).riskScore).toEqual('number'); - }); - - test('maxSignals will default to "DEFAULT_MAX_SIGNALS" if not set', () => { - const schema = signalParamsSchema(); - const { maxSignals, ...withoutMockData } = getSignalParamsSchemaMock(); - expect(schema.validate(withoutMockData).maxSignals).toEqual(DEFAULT_MAX_SIGNALS); - }); - - test('version will default to "1" if not set', () => { - const schema = signalParamsSchema(); - const { version, ...withoutVersion } = getSignalParamsSchemaMock(); - expect(schema.validate(withoutVersion).version).toEqual(1); - }); - - test('references will default to an empty array if not set', () => { - const schema = signalParamsSchema(); - const { references, ...withoutReferences } = getSignalParamsSchemaMock(); - expect(schema.validate(withoutReferences).references).toEqual([]); - }); - - test('immutable will default to false if not set', () => { - const schema = signalParamsSchema(); - const { immutable, ...withoutImmutable } = getSignalParamsSchemaMock(); - expect(schema.validate(withoutImmutable).immutable).toEqual(false); - }); - - test('falsePositives will default to an empty array if not set', () => { - const schema = signalParamsSchema(); - const { falsePositives, ...withoutFalsePositives } = getSignalParamsSchemaMock(); - expect(schema.validate(withoutFalsePositives).falsePositives).toEqual([]); - }); - - test('threshold validates with `value` only', () => { - const schema = signalParamsSchema(); - const threshold = { - value: 200, - }; - const mock = { - ...getSignalParamsSchemaMock(), - threshold, - }; - expect(schema.validate(mock).threshold?.value).toEqual(200); - }); - - test('threshold does not validate without `value`', () => { - const schema = signalParamsSchema(); - const threshold = { - field: 'agent.id', - cardinality: [ - { - field: ['host.name'], - value: 5, - }, - ], - }; - const mock = { - ...getSignalParamsSchemaMock(), - threshold, - }; - expect(() => schema.validate(mock)).toThrow(); - }); - - test('threshold `cardinality` cannot currently be greater than length 1', () => { - const schema = signalParamsSchema(); - const threshold = { - value: 100, - cardinality: [ - { - field: 'host.name', - value: 5, - }, - { - field: 'user.name', - value: 5, - }, - ], - }; - const mock = { - ...getSignalParamsSchemaMock(), - threshold, - }; - expect(() => schema.validate(mock)).toThrow(); - }); -}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts deleted file mode 100644 index fe4781d384358..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts +++ /dev/null @@ -1,86 +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 { schema, TypeOf } from '@kbn/config-schema'; - -import { DEFAULT_MAX_SIGNALS } from '../../../../common/constants'; - -export const signalSchema = schema.object({ - anomalyThreshold: schema.maybe(schema.number()), - author: schema.arrayOf(schema.string(), { defaultValue: [] }), - buildingBlockType: schema.nullable(schema.string()), - description: schema.string(), - note: schema.nullable(schema.string()), - eventCategoryOverride: schema.maybe(schema.string()), - falsePositives: schema.arrayOf(schema.string(), { defaultValue: [] }), - from: schema.string(), - ruleId: schema.string(), - immutable: schema.boolean({ defaultValue: false }), - index: schema.nullable(schema.arrayOf(schema.string())), - language: schema.nullable(schema.string()), - license: schema.nullable(schema.string()), - outputIndex: schema.nullable(schema.string()), - savedId: schema.nullable(schema.string()), - timelineId: schema.nullable(schema.string()), - timelineTitle: schema.nullable(schema.string()), - meta: schema.nullable(schema.object({}, { unknowns: 'allow' })), - machineLearningJobId: schema.maybe(schema.string()), - query: schema.nullable(schema.string()), - filters: schema.nullable(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), - maxSignals: schema.number({ defaultValue: DEFAULT_MAX_SIGNALS }), - riskScore: schema.number(), - // TODO: Specify types explicitly since they're known? - riskScoreMapping: schema.nullable(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), - ruleNameOverride: schema.nullable(schema.string()), - severity: schema.string(), - severityMapping: schema.nullable(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), - threat: schema.nullable(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), - threshold: schema.maybe( - schema.object({ - // Can be an empty string (pre-7.12) or empty array (7.12+) - field: schema.nullable( - schema.oneOf([schema.string(), schema.arrayOf(schema.string(), { maxSize: 3 })]) - ), - // Always required - value: schema.number(), - // Can be null (pre-7.12) or empty array (7.12+) - cardinality: schema.nullable( - schema.arrayOf( - schema.object({ - field: schema.string(), - value: schema.number(), - }), - { maxSize: 1 } - ) - ), - }) - ), - timestampOverride: schema.nullable(schema.string()), - to: schema.string(), - type: schema.string(), - references: schema.arrayOf(schema.string(), { defaultValue: [] }), - version: schema.number({ defaultValue: 1 }), - lists: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), // For backwards compatibility with customers that had a data bug in 7.7. Once we use a migration script please remove this. - exceptions_list: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), // For backwards compatibility with customers that had a data bug in 7.8. Once we use a migration script please remove this. - exceptionsList: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), - threatFilters: schema.nullable(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), - threatIndex: schema.maybe(schema.arrayOf(schema.string())), - threatIndicatorPath: schema.maybe(schema.string()), - threatQuery: schema.maybe(schema.string()), - threatMapping: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), - threatLanguage: schema.maybe(schema.string()), - concurrentSearches: schema.maybe(schema.number()), - itemsPerSearch: schema.maybe(schema.number()), -}); - -/** - * This is the schema for the Alert Rule that represents the SIEM alert for signals - * that index into the .siem-signals-${space-id} - */ -export const signalParamsSchema = () => signalSchema; - -export type SignalParamsSchema = TypeOf; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index ba7776af9d36a..ae58909d727de 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -8,7 +8,7 @@ import moment from 'moment'; import type { estypes } from '@elastic/elasticsearch'; import { loggingSystemMock } from 'src/core/server/mocks'; -import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; +import { getAlertMock } from '../routes/__mocks__/request_responses'; import { signalRulesAlertType } from './signal_rule_alert_type'; import { alertsMock, AlertServicesMock } from '../../../../../alerting/server/mocks'; import { ruleStatusServiceFactory } from './rule_status_service'; @@ -31,6 +31,7 @@ import { ApiResponse } from '@elastic/elasticsearch/lib/Transport'; import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; import { queryExecutor } from './executors/query'; import { mlExecutor } from './executors/ml'; +import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; jest.mock('./rule_status_saved_objects_client'); jest.mock('./rule_status_service'); @@ -54,19 +55,13 @@ const getPayload = ( ): RuleExecutorOptions => ({ alertId: ruleAlert.id, services, + name: ruleAlert.name, + tags: ruleAlert.tags, params: { ...ruleAlert.params, - actions: [], - enabled: ruleAlert.enabled, - interval: ruleAlert.schedule.interval, - name: ruleAlert.name, - tags: ruleAlert.tags, - throttle: ruleAlert.throttle, }, state: {}, spaceId: '', - name: 'name', - tags: [], startedAt: new Date('2019-12-13T16:50:33.400Z'), previousStartedAt: new Date('2019-12-13T16:40:33.400Z'), createdBy: 'elastic', @@ -154,7 +149,7 @@ describe('signal_rule_alert_type', () => { alertServices.scopedClusterClient.asCurrentUser.fieldCaps.mockResolvedValue( value as ApiResponse ); - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', @@ -208,7 +203,10 @@ describe('signal_rule_alert_type', () => { }, application: {}, }); - payload.params.index = ['some*', 'myfa*', 'anotherindex*']; + const newRuleAlert = getAlertMock(getQueryRuleParams()); + newRuleAlert.params.index = ['some*', 'myfa*', 'anotherindex*']; + payload = getPayload(newRuleAlert, alertServices) as jest.Mocked; + await alert.executor(payload); expect(ruleStatusService.partialFailure).toHaveBeenCalled(); expect(ruleStatusService.partialFailure.mock.calls[0][0]).toContain( @@ -231,7 +229,10 @@ describe('signal_rule_alert_type', () => { }, application: {}, }); - payload.params.index = ['some*', 'myfa*']; + const newRuleAlert = getAlertMock(getQueryRuleParams()); + newRuleAlert.params.index = ['some*', 'myfa*', 'anotherindex*']; + payload = getPayload(newRuleAlert, alertServices) as jest.Mocked; + await alert.executor(payload); expect(ruleStatusService.partialFailure).toHaveBeenCalled(); expect(ruleStatusService.partialFailure.mock.calls[0][0]).toContain( @@ -247,7 +248,7 @@ describe('signal_rule_alert_type', () => { }); it("should set refresh to 'wait_for' when actions are present", async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); ruleAlert.actions = [ { actionTypeId: '.slack', @@ -276,7 +277,7 @@ describe('signal_rule_alert_type', () => { }); it('should call scheduleActions if signalsCount was greater than 0 and rule has actions defined', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); ruleAlert.actions = [ { actionTypeId: '.slack', @@ -306,7 +307,7 @@ describe('signal_rule_alert_type', () => { }); it('should resolve results_link when meta is an empty object to use "/app/security"', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); ruleAlert.params.meta = {}; ruleAlert.actions = [ { @@ -343,7 +344,7 @@ describe('signal_rule_alert_type', () => { }); it('should resolve results_link when meta is undefined use "/app/security"', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); delete ruleAlert.params.meta; ruleAlert.actions = [ { @@ -380,7 +381,7 @@ describe('signal_rule_alert_type', () => { }); it('should resolve results_link with a custom link', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); ruleAlert.params.meta = { kibana_siem_app_url: 'http://localhost' }; ruleAlert.actions = [ { @@ -418,7 +419,7 @@ describe('signal_rule_alert_type', () => { describe('ML rule', () => { it('should not call checkPrivileges if ML rule', async () => { - const ruleAlert = getMlResult(); + const ruleAlert = getAlertMock(getMlRuleParams()); alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 52ceafbdb69b3..419141d98d15a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -12,7 +12,6 @@ import { chain, tryCatch } from 'fp-ts/lib/TaskEither'; import { flow } from 'fp-ts/lib/function'; import * as t from 'io-ts'; -import { pickBy } from 'lodash/fp'; import { validateNonExact } from '../../../../common/validate'; import { toError, toPromise } from '../../../../common/fp_utils'; @@ -31,7 +30,7 @@ import { import { parseScheduleDates } from '../../../../common/detection_engine/parse_schedule_dates'; import { SetupPlugins } from '../../../plugin'; import { getInputIndex } from './get_input_output_index'; -import { SignalRuleAlertTypeDefinition, RuleAlertAttributes } from './types'; +import { AlertAttributes, SignalRuleAlertTypeDefinition } from './types'; import { getListsClient, getExceptions, @@ -40,8 +39,8 @@ import { hasTimestampFields, hasReadIndexPrivileges, getRuleRangeTuples, + isMachineLearningParams, } from './utils'; -import { signalParamsSchema } from './signal_params_schema'; import { siemRuleActionGroups } from './siem_rule_action_groups'; import { scheduleNotificationActions, @@ -52,7 +51,6 @@ import { buildRuleMessageFactory } from './rule_messages'; import { ruleStatusSavedObjectsClientFactory } from './rule_status_saved_objects_client'; import { getNotificationResultsLink } from '../notifications/utils'; import { TelemetryEventsSender } from '../../telemetry/sender'; -import { RuleTypeParams } from '../types'; import { eqlExecutor } from './executors/eql'; import { queryExecutor } from './executors/query'; import { threatMatchExecutor } from './executors/threat_match'; @@ -64,6 +62,8 @@ import { queryRuleParams, threatRuleParams, thresholdRuleParams, + ruleParams, + RuleParams, } from '../schemas/rule_schemas'; export const signalRulesAlertType = ({ @@ -85,15 +85,17 @@ export const signalRulesAlertType = ({ actionGroups: siemRuleActionGroups, defaultActionGroupId: 'default', validate: { - /** - * TODO: Fix typing inconsistancy between `RuleTypeParams` and `CreateRulesOptions` - * Once that's done, you should be able to do: - * ``` - * params: signalParamsSchema(), - * ``` - */ - params: (signalParamsSchema() as unknown) as { - validate: (object: unknown) => RuleTypeParams; + params: { + validate: (object: unknown): RuleParams => { + const [validated, errors] = validateNonExact(object, ruleParams); + if (errors != null) { + throw new Error(errors); + } + if (validated == null) { + throw new Error('Validation of rule params failed'); + } + return validated; + }, }, }, producer: SERVER_APP_ID, @@ -107,7 +109,7 @@ export const signalRulesAlertType = ({ spaceId, updatedBy: updatedByUser, }) { - const { ruleId, index, maxSignals, meta, outputIndex, timestampOverride, type } = params; + const { ruleId, maxSignals, meta, outputIndex, timestampOverride, type } = params; const searchAfterSize = Math.min(maxSignals, DEFAULT_SEARCH_AFTER_PAGE_SIZE); let hasError: boolean = false; @@ -117,10 +119,8 @@ export const signalRulesAlertType = ({ alertId, ruleStatusClient, }); - const savedObject = await services.savedObjectsClient.get( - 'alert', - alertId - ); + + const savedObject = await services.savedObjectsClient.get('alert', alertId); const { actions, name, @@ -143,7 +143,8 @@ export const signalRulesAlertType = ({ // move this collection of lines into a function in utils // so that we can use it in create rules route, bulk, etc. try { - if (!isEmpty(index)) { + if (!isMachineLearningParams(params)) { + const index = params.index; const hasTimestampOverride = timestampOverride != null && !isEmpty(timestampOverride); const inputIndices = await getInputIndex(services, version, index); const [privileges, timestampFieldCaps] = await Promise.all([ @@ -392,11 +393,10 @@ export const signalRulesAlertType = ({ * @param schema io-ts schema for the specific rule type the SavedObject claims to be */ export const asTypeSpecificSO = ( - ruleSO: SavedObject, + ruleSO: SavedObject, schema: T ) => { - const nonNullParams = pickBy((value: unknown) => value !== null, ruleSO.attributes.params); - const [validated, errors] = validateNonExact(nonNullParams, schema); + const [validated, errors] = validateNonExact(ruleSO.attributes.params, schema); if (validated == null || errors != null) { throw new Error(`Rule attempted to execute with invalid params: ${errors}`); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts index b9a771ac0299e..3fbb8c1a607e9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts @@ -7,7 +7,6 @@ import { generateId } from './utils'; import { - sampleRuleAlertParams, sampleDocSearchResultsNoSortId, mockLogger, sampleRuleGuid, @@ -16,6 +15,7 @@ import { sampleBulkCreateDuplicateResult, sampleBulkCreateErrorResult, sampleDocWithAncestors, + sampleRuleSO, } from './__mocks__/es_results'; import { DEFAULT_SIGNALS_INDEX } from '../../../../common/constants'; import { singleBulkCreate, filterDuplicateRules } from './single_bulk_create'; @@ -23,6 +23,7 @@ import { alertsMock, AlertServicesMock } from '../../../../../alerting/server/mo import { buildRuleMessageFactory } from './rule_messages'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; const buildRuleMessage = buildRuleMessageFactory({ id: 'fake id', @@ -140,7 +141,7 @@ describe('singleBulkCreate', () => { }); test('create successful bulk create', async () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( // @ts-expect-error not compatible response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ @@ -155,22 +156,12 @@ describe('singleBulkCreate', () => { ); const { success, createdItemsCount } = await singleBulkCreate({ filteredEvents: sampleDocSearchResultsNoSortId(), - ruleParams: sampleParams, + ruleSO, services: mockService, logger: mockLogger, id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, - actions: [], - name: 'rule-name', - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -178,7 +169,7 @@ describe('singleBulkCreate', () => { }); test('create successful bulk create with docs with no versioning', async () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( // @ts-expect-error not compatible response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ @@ -193,22 +184,12 @@ describe('singleBulkCreate', () => { ); const { success, createdItemsCount } = await singleBulkCreate({ filteredEvents: sampleDocSearchResultsNoSortIdNoVersion(), - ruleParams: sampleParams, + ruleSO, services: mockService, logger: mockLogger, id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -216,29 +197,19 @@ describe('singleBulkCreate', () => { }); test('create unsuccessful bulk create due to empty search results', async () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValue( // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise(false) ); const { success, createdItemsCount } = await singleBulkCreate({ filteredEvents: sampleEmptyDocSearchResults(), - ruleParams: sampleParams, + ruleSO, services: mockService, logger: mockLogger, id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -246,29 +217,18 @@ describe('singleBulkCreate', () => { }); test('create successful bulk create when bulk create has duplicate errors', async () => { - const sampleParams = sampleRuleAlertParams(); - const sampleSearchResult = sampleDocSearchResultsNoSortId; + const ruleSO = sampleRuleSO(getQueryRuleParams()); mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise(sampleBulkCreateDuplicateResult) ); const { success, createdItemsCount } = await singleBulkCreate({ - filteredEvents: sampleSearchResult(), - ruleParams: sampleParams, + filteredEvents: sampleDocSearchResultsNoSortId(), + ruleSO, services: mockService, logger: mockLogger, id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); @@ -278,29 +238,18 @@ describe('singleBulkCreate', () => { }); test('create failed bulk create when bulk create has multiple error statuses', async () => { - const sampleParams = sampleRuleAlertParams(); - const sampleSearchResult = sampleDocSearchResultsNoSortId; + const ruleSO = sampleRuleSO(getQueryRuleParams()); mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise(sampleBulkCreateErrorResult) ); const { success, createdItemsCount, errors } = await singleBulkCreate({ - filteredEvents: sampleSearchResult(), - ruleParams: sampleParams, + filteredEvents: sampleDocSearchResultsNoSortId(), + ruleSO, services: mockService, logger: mockLogger, id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(mockLogger.error).toHaveBeenCalled(); @@ -349,28 +298,18 @@ describe('singleBulkCreate', () => { }); test('create successful and returns proper createdItemsCount', async () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(sampleBulkCreateDuplicateResult) ); const { success, createdItemsCount } = await singleBulkCreate({ filteredEvents: sampleDocSearchResultsNoSortId(), - ruleParams: sampleParams, + ruleSO, services: mockService, logger: mockLogger, id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, - actions: [], - name: 'rule-name', - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts index 8a0788f6d42e6..92d01fef6e50c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts @@ -12,32 +12,21 @@ import { AlertInstanceState, AlertServices, } from '../../../../../alerting/server'; -import { SignalHit, SignalSearchResponse, WrappedSignalHit } from './types'; -import { RuleAlertAction } from '../../../../common/detection_engine/types'; -import { RuleTypeParams, RefreshTypes } from '../types'; +import { AlertAttributes, SignalHit, SignalSearchResponse, WrappedSignalHit } from './types'; +import { RefreshTypes } from '../types'; import { generateId, makeFloatString, errorAggregator } from './utils'; import { buildBulkBody } from './build_bulk_body'; import { BuildRuleMessage } from './rule_messages'; -import { Logger } from '../../../../../../../src/core/server'; +import { Logger, SavedObject } from '../../../../../../../src/core/server'; import { isEventTypeSignal } from './build_event_type_signal'; interface SingleBulkCreateParams { filteredEvents: SignalSearchResponse; - ruleParams: RuleTypeParams; + ruleSO: SavedObject; services: AlertServices; logger: Logger; id: string; signalsIndex: string; - actions: RuleAlertAction[]; - name: string; - createdAt: string; - createdBy: string; - updatedAt: string; - updatedBy: string; - interval: string; - enabled: boolean; - tags: string[]; - throttle: string; refresh: RefreshTypes; buildRuleMessage: BuildRuleMessage; } @@ -97,23 +86,14 @@ export interface BulkInsertSignalsResponse { export const singleBulkCreate = async ({ buildRuleMessage, filteredEvents, - ruleParams, + ruleSO, services, logger, id, signalsIndex, - actions, - name, - createdAt, - createdBy, - updatedAt, - updatedBy, - interval, - enabled, refresh, - tags, - throttle, }: SingleBulkCreateParams): Promise => { + const ruleParams = ruleSO.attributes.params; filteredEvents.hits.hits = filterDuplicateRules(id, filteredEvents); logger.debug(buildRuleMessage(`about to bulk create ${filteredEvents.hits.hits.length} events`)); if (filteredEvents.hits.hits.length === 0) { @@ -141,21 +121,7 @@ export const singleBulkCreate = async ({ ), }, }, - buildBulkBody({ - doc, - ruleParams, - id, - actions, - name, - createdAt, - createdBy, - updatedAt, - updatedBy, - interval, - enabled, - tags, - throttle, - }), + buildBulkBody(ruleSO, doc), ]); const start = performance.now(); const { body: response } = await services.scopedClusterClient.asCurrentUser.bulk({ @@ -170,26 +136,11 @@ export const singleBulkCreate = async ({ ) ); logger.debug(buildRuleMessage(`took property says bulk took: ${response.took} milliseconds`)); - const createdItems = filteredEvents.hits.hits .map((doc, index) => ({ _id: response.items[index].create?._id ?? '', _index: response.items[index].create?._index ?? '', - ...buildBulkBody({ - doc, - ruleParams, - id, - actions, - name, - createdAt, - createdBy, - updatedAt, - updatedBy, - interval, - enabled, - tags, - throttle, - }), + ...buildBulkBody(ruleSO, doc), })) .filter((_, index) => get(response.items[index], 'create.status') === 201); const createdItemsCount = createdItems.length; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts index d9c72f7f95679..37b0b88d88eda 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts @@ -29,20 +29,10 @@ export const createThreatSignal = async ({ eventsTelemetry, alertId, outputIndex, - params, + ruleSO, searchAfterSize, - actions, - createdBy, - createdAt, - updatedBy, - interval, - updatedAt, - enabled, refresh, - tags, - throttle, buildRuleMessage, - name, currentThreatList, currentResult, }: CreateThreatSignalOptions): Promise => { @@ -82,7 +72,7 @@ export const createThreatSignal = async ({ tuples, listClient, exceptionsList: exceptionItems, - ruleParams: params, + ruleSO, services, logger, eventsTelemetry, @@ -90,18 +80,8 @@ export const createThreatSignal = async ({ inputIndexPattern: inputIndex, signalsIndex: outputIndex, filter: esFilter, - actions, - name, - createdBy, - createdAt, - updatedBy, - updatedAt, - interval, - enabled, pageSize: searchAfterSize, refresh, - tags, - throttle, buildRuleMessage, enrichment: threatEnrichment, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts index 8e42e60768bf0..ade85db0e4ba6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts @@ -30,28 +30,19 @@ export const createThreatSignals = async ({ eventsTelemetry, alertId, outputIndex, - params, + ruleSO, searchAfterSize, - actions, - createdBy, - createdAt, - updatedBy, - interval, - updatedAt, - enabled, refresh, - tags, - throttle, threatFilters, threatQuery, threatLanguage, buildRuleMessage, threatIndex, threatIndicatorPath, - name, concurrentSearches, itemsPerSearch, }: CreateThreatSignalsOptions): Promise => { + const params = ruleSO.attributes.params; logger.debug(buildRuleMessage('Indicator matching rule starting')); const perPage = concurrentSearches * itemsPerSearch; @@ -127,20 +118,10 @@ export const createThreatSignals = async ({ eventsTelemetry, alertId, outputIndex, - params, + ruleSO, searchAfterSize, - actions, - createdBy, - createdAt, - updatedBy, - updatedAt, - interval, - enabled, - tags, refresh, - throttle, buildRuleMessage, - name, currentThreatList: slicedChunk, currentResult: results, }) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts index aeed8da7ac3d9..360fb118faa84 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts @@ -20,18 +20,22 @@ import { ItemsPerSearch, ThreatIndicatorPathOrUndefined, } from '../../../../../common/detection_engine/schemas/types/threat_mapping'; -import { RuleTypeParams } from '../../types'; import { AlertInstanceContext, AlertInstanceState, AlertServices, } from '../../../../../../alerting/server'; import { ExceptionListItemSchema } from '../../../../../../lists/common/schemas'; -import { ElasticsearchClient, Logger } from '../../../../../../../../src/core/server'; -import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ElasticsearchClient, Logger, SavedObject } from '../../../../../../../../src/core/server'; import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; -import { RuleRangeTuple, SearchAfterAndBulkCreateReturnType, SignalsEnrichment } from '../types'; +import { + AlertAttributes, + RuleRangeTuple, + SearchAfterAndBulkCreateReturnType, + SignalsEnrichment, +} from '../types'; +import { ThreatRuleParams } from '../../schemas/rule_schemas'; export type SortOrderOrUndefined = 'asc' | 'desc' | undefined; @@ -51,25 +55,15 @@ export interface CreateThreatSignalsOptions { eventsTelemetry: TelemetryEventsSender | undefined; alertId: string; outputIndex: string; - params: RuleTypeParams; + ruleSO: SavedObject>; searchAfterSize: number; - actions: RuleAlertAction[]; - createdBy: string; - createdAt: string; - updatedBy: string; - updatedAt: string; - interval: string; - enabled: boolean; - tags: string[]; refresh: false | 'wait_for'; - throttle: string; threatFilters: unknown[]; threatQuery: ThreatQuery; buildRuleMessage: BuildRuleMessage; threatIndex: ThreatIndex; threatIndicatorPath: ThreatIndicatorPathOrUndefined; threatLanguage: ThreatLanguageOrUndefined; - name: string; concurrentSearches: ConcurrentSearches; itemsPerSearch: ItemsPerSearch; } @@ -91,20 +85,10 @@ export interface CreateThreatSignalOptions { eventsTelemetry: TelemetryEventsSender | undefined; alertId: string; outputIndex: string; - params: RuleTypeParams; + ruleSO: SavedObject>; searchAfterSize: number; - actions: RuleAlertAction[]; - createdBy: string; - createdAt: string; - updatedBy: string; - updatedAt: string; - interval: string; - enabled: boolean; - tags: string[]; refresh: false | 'wait_for'; - throttle: string; buildRuleMessage: BuildRuleMessage; - name: string; currentThreatList: ThreatListItem[]; currentResult: SearchAfterAndBulkCreateReturnType; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.test.ts index c0fdc4eb0189d..79c2d86f35e7b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.test.ts @@ -6,175 +6,13 @@ */ import { loggingSystemMock } from '../../../../../../../../src/core/server/mocks'; -import { normalizeThresholdField } from '../../../../../common/detection_engine/utils'; -import { - Threshold, - ThresholdNormalized, -} from '../../../../../common/detection_engine/schemas/common/schemas'; +import { ThresholdNormalized } from '../../../../../common/detection_engine/schemas/common/schemas'; import { sampleDocNoSortId, sampleDocSearchResultsNoSortId } from '../__mocks__/es_results'; import { sampleThresholdSignalHistory } from '../__mocks__/threshold_signal_history.mock'; import { calculateThresholdSignalUuid } from '../utils'; import { transformThresholdResultsToEcs } from './bulk_create_threshold_signals'; describe('transformThresholdNormalizedResultsToEcs', () => { - it('should return transformed threshold results for pre-7.12 rules', () => { - const threshold: Threshold = { - field: 'source.ip', - value: 1, - }; - const from = new Date('2020-12-17T16:27:00Z'); - const startedAt = new Date('2020-12-17T16:27:00Z'); - const transformedResults = transformThresholdResultsToEcs( - { - ...sampleDocSearchResultsNoSortId('abcd'), - aggregations: { - 'threshold_0:source.ip': { - buckets: [ - { - key: '127.0.0.1', - doc_count: 15, - top_threshold_hits: { - hits: { - hits: [sampleDocNoSortId('abcd')], - }, - }, - }, - ], - }, - }, - }, - 'test', - startedAt, - from, - undefined, - loggingSystemMock.createLogger(), - { - ...threshold, - field: normalizeThresholdField(threshold.field), - }, - '1234', - undefined, - sampleThresholdSignalHistory() - ); - const _id = calculateThresholdSignalUuid('1234', startedAt, ['source.ip'], '127.0.0.1'); - expect(transformedResults).toEqual({ - took: 10, - timed_out: false, - _shards: { - total: 10, - successful: 10, - failed: 0, - skipped: 0, - }, - results: { - hits: { - total: 1, - }, - }, - hits: { - total: 100, - max_score: 100, - hits: [ - { - _id, - _index: 'test', - _source: { - 'source.ip': '127.0.0.1', - '@timestamp': '2020-04-20T21:27:45+0000', - threshold_result: { - from: new Date('2020-12-17T16:27:00.000Z'), - terms: [ - { - field: 'source.ip', - value: '127.0.0.1', - }, - ], - cardinality: undefined, - count: 15, - }, - }, - }, - ], - }, - }); - }); - - it('should return transformed threshold results for pre-7.12 rules without threshold field', () => { - const threshold: Threshold = { - field: '', - value: 1, - }; - const from = new Date('2020-12-17T16:27:00Z'); - const startedAt = new Date('2020-12-17T16:27:00Z'); - const transformedResults = transformThresholdResultsToEcs( - { - ...sampleDocSearchResultsNoSortId('abcd'), - aggregations: { - threshold_0: { - buckets: [ - { - key: '', - doc_count: 15, - top_threshold_hits: { - hits: { - hits: [sampleDocNoSortId('abcd')], - }, - }, - }, - ], - }, - }, - }, - 'test', - startedAt, - from, - undefined, - loggingSystemMock.createLogger(), - { - ...threshold, - field: normalizeThresholdField(threshold.field), - }, - '1234', - undefined, - sampleThresholdSignalHistory() - ); - const _id = calculateThresholdSignalUuid('1234', startedAt, [], ''); - expect(transformedResults).toEqual({ - took: 10, - timed_out: false, - _shards: { - total: 10, - successful: 10, - failed: 0, - skipped: 0, - }, - results: { - hits: { - total: 1, - }, - }, - hits: { - total: 100, - max_score: 100, - hits: [ - { - _id, - _index: 'test', - _source: { - '@timestamp': '2020-04-20T21:27:45+0000', - threshold_result: { - from: new Date('2020-12-17T16:27:00.000Z'), - terms: [], - cardinality: undefined, - count: 15, - }, - }, - }, - ], - }, - }); - }); - it('should return transformed threshold results', () => { const threshold: ThresholdNormalized = { field: ['source.ip', 'host.name'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts index 8e5e31cc87b4f..197065f205fc5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts @@ -7,20 +7,19 @@ import { get } from 'lodash/fp'; import set from 'set-value'; -import { normalizeThresholdField } from '../../../../../common/detection_engine/utils'; import { ThresholdNormalized, TimestampOverrideOrUndefined, } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { Logger } from '../../../../../../../../src/core/server'; +import { Logger, SavedObject } from '../../../../../../../../src/core/server'; import { AlertInstanceContext, AlertInstanceState, AlertServices, } from '../../../../../../alerting/server'; -import { BaseHit, RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { BaseHit } from '../../../../../common/detection_engine/types'; import { TermAggregationBucket } from '../../../types'; -import { RuleTypeParams, RefreshTypes } from '../../types'; +import { RefreshTypes } from '../../types'; import { singleBulkCreate, SingleBulkCreateResponse } from '../single_bulk_create'; import { calculateThresholdSignalUuid, @@ -33,29 +32,20 @@ import type { SignalSource, SignalSearchResponse, ThresholdSignalHistory, + AlertAttributes, } from '../types'; +import { ThresholdRuleParams } from '../../schemas/rule_schemas'; interface BulkCreateThresholdSignalsParams { - actions: RuleAlertAction[]; someResult: SignalSearchResponse; - ruleParams: RuleTypeParams; + ruleSO: SavedObject>; services: AlertServices; inputIndexPattern: string[]; logger: Logger; id: string; filter: unknown; signalsIndex: string; - timestampOverride: TimestampOverrideOrUndefined; - name: string; - createdAt: string; - createdBy: string; - updatedAt: string; - updatedBy: string; - interval: string; - enabled: boolean; refresh: RefreshTypes; - tags: string[]; - throttle: string; startedAt: Date; from: Date; thresholdSignalHistory: ThresholdSignalHistory; @@ -249,8 +239,8 @@ export const transformThresholdResultsToEcs = ( export const bulkCreateThresholdSignals = async ( params: BulkCreateThresholdSignalsParams ): Promise => { + const ruleParams = params.ruleSO.attributes.params; const thresholdResults = params.someResult; - const threshold = params.ruleParams.threshold!; const ecsResults = transformThresholdResultsToEcs( thresholdResults, params.inputIndexPattern.join(','), @@ -258,12 +248,9 @@ export const bulkCreateThresholdSignals = async ( params.from, params.filter, params.logger, - { - ...threshold, - field: normalizeThresholdField(threshold.field), - }, - params.ruleParams.ruleId, - params.timestampOverride, + ruleParams.threshold, + ruleParams.ruleId, + ruleParams.timestampOverride, params.thresholdSignalHistory ); const buildRuleMessage = params.buildRuleMessage; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.test.ts index 622e77309765f..e84b4f31fb15f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.test.ts @@ -31,108 +31,6 @@ describe('findThresholdSignals', () => { mockService = alertsMock.createAlertServices(); }); - it('should generate a threshold signal for pre-7.12 rules', async () => { - await findThresholdSignals({ - from: 'now-6m', - to: 'now', - inputIndexPattern: ['*'], - services: mockService, - logger: mockLogger, - filter: queryFilter, - threshold: { - field: 'host.name', - value: 100, - }, - buildRuleMessage, - timestampOverride: undefined, - }); - expect(mockSingleSearchAfter).toHaveBeenCalledWith( - expect.objectContaining({ - aggregations: { - 'threshold_0:host.name': { - terms: { - field: 'host.name', - min_doc_count: 100, - size: 10000, - }, - aggs: { - top_threshold_hits: { - top_hits: { - sort: [ - { - '@timestamp': { - order: 'desc', - }, - }, - ], - fields: [ - { - field: '*', - include_unmapped: true, - }, - ], - size: 1, - }, - }, - }, - }, - }, - }) - ); - }); - - it('should generate a signal for pre-7.12 rules with no threshold field', async () => { - await findThresholdSignals({ - from: 'now-6m', - to: 'now', - inputIndexPattern: ['*'], - services: mockService, - logger: mockLogger, - filter: queryFilter, - threshold: { - field: '', - value: 100, - }, - buildRuleMessage, - timestampOverride: undefined, - }); - expect(mockSingleSearchAfter).toHaveBeenCalledWith( - expect.objectContaining({ - aggregations: { - threshold_0: { - terms: { - script: { - source: '""', - lang: 'painless', - }, - min_doc_count: 100, - }, - aggs: { - top_threshold_hits: { - top_hits: { - sort: [ - { - '@timestamp': { - order: 'desc', - }, - }, - ], - fields: [ - { - field: '*', - include_unmapped: true, - }, - ], - size: 1, - }, - }, - }, - }, - }, - }) - ); - }); - it('should generate a threshold signal query when only a value is provided', async () => { await findThresholdSignals({ from: 'now-6m', @@ -246,6 +144,7 @@ describe('findThresholdSignals', () => { threshold: { field: ['host.name', 'user.name'], value: 100, + cardinality: [], }, buildRuleMessage, timestampOverride: undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.ts index efcdb85e9b2c7..33ffa5b71a65c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.ts @@ -8,10 +8,9 @@ import { set } from '@elastic/safer-lodash-set'; import { - Threshold, + ThresholdNormalized, TimestampOverrideOrUndefined, } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { normalizeThresholdField } from '../../../../../common/detection_engine/utils'; import { AlertInstanceContext, AlertInstanceState, @@ -29,7 +28,7 @@ interface FindThresholdSignalsParams { services: AlertServices; logger: Logger; filter: unknown; - threshold: Threshold; + threshold: ThresholdNormalized; buildRuleMessage: BuildRuleMessage; timestampOverride: TimestampOverrideOrUndefined; } @@ -88,7 +87,7 @@ export const findThresholdSignals = async ({ : {}), }; - const thresholdFields = normalizeThresholdField(threshold.field); + const thresholdFields = threshold.field; // Generate a nested terms aggregation for each threshold grouping field provided, appending leaf // aggregations to 1) filter out buckets that don't meet the cardinality threshold, if provided, and diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index 615b91d60bb1b..80d08a77ba5d2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -25,19 +25,13 @@ import { RuleAlertAction, SearchTypes, } from '../../../../common/detection_engine/types'; -import { RuleTypeParams, RefreshTypes } from '../types'; +import { RefreshTypes } from '../types'; import { ListClient } from '../../../../../lists/server'; -import { Logger } from '../../../../../../../src/core/server'; +import { Logger, SavedObject } from '../../../../../../../src/core/server'; import { ExceptionListItemSchema } from '../../../../../lists/common/schemas'; import { BuildRuleMessage } from './rule_messages'; import { TelemetryEventsSender } from '../../telemetry/sender'; -import { - EqlRuleParams, - MachineLearningRuleParams, - QueryRuleParams, - ThreatRuleParams, - ThresholdRuleParams, -} from '../schemas/rule_schemas'; +import { RuleParams } from '../schemas/rule_schemas'; // used for gap detection code // eslint-disable-next-line @typescript-eslint/naming-convention @@ -166,7 +160,7 @@ export type BaseSignalHit = estypes.Hit; export type EqlSignalSearchResponse = EqlSearchResponse; export type RuleExecutorOptions = AlertExecutorOptions< - RuleTypeParams, + RuleParams, AlertTypeState, AlertInstanceState, AlertInstanceContext @@ -177,7 +171,7 @@ export type RuleExecutorOptions = AlertExecutorOptions< export const isAlertExecutor = ( obj: SignalRuleAlertTypeDefinition ): obj is AlertType< - RuleTypeParams, + RuleParams, AlertTypeState, AlertInstanceState, AlertInstanceContext, @@ -187,7 +181,7 @@ export const isAlertExecutor = ( }; export type SignalRuleAlertTypeDefinition = AlertType< - RuleTypeParams, + RuleParams, AlertTypeState, AlertInstanceState, AlertInstanceContext, @@ -230,7 +224,7 @@ export interface SignalHit { [key: string]: SearchTypes; } -export interface AlertAttributes { +export interface AlertAttributes { actions: RuleAlertAction[]; enabled: boolean; name: string; @@ -242,30 +236,7 @@ export interface AlertAttributes { interval: string; }; throttle: string; -} - -export interface RuleAlertAttributes extends AlertAttributes { - params: RuleTypeParams; -} - -export interface MachineLearningRuleAttributes extends AlertAttributes { - params: MachineLearningRuleParams; -} - -export interface ThresholdRuleAttributes extends AlertAttributes { - params: ThresholdRuleParams; -} - -export interface ThreatRuleAttributes extends AlertAttributes { - params: ThreatRuleParams; -} - -export interface QueryRuleAttributes extends AlertAttributes { - params: QueryRuleParams; -} - -export interface EqlRuleAttributes extends AlertAttributes { - params: EqlRuleParams; + params: T; } export type BulkResponseErrorAggregation = Record; @@ -290,7 +261,7 @@ export interface SearchAfterAndBulkCreateParams { from: moment.Moment; maxSignals: number; }>; - ruleParams: RuleTypeParams; + ruleSO: SavedObject; services: AlertServices; listClient: ListClient; exceptionsList: ExceptionListItemSchema[]; @@ -299,19 +270,9 @@ export interface SearchAfterAndBulkCreateParams { id: string; inputIndexPattern: string[]; signalsIndex: string; - name: string; - actions: RuleAlertAction[]; - createdAt: string; - createdBy: string; - updatedBy: string; - updatedAt: string; - interval: string; - enabled: boolean; pageSize: number; filter: unknown; refresh: RefreshTypes; - tags: string[]; - throttle: string; buildRuleMessage: BuildRuleMessage; enrichment?: SignalsEnrichment; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index fb0166fd4dbee..54ed44956c8b3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -42,6 +42,15 @@ import { hasLargeValueList } from '../../../../common/detection_engine/utils'; import { MAX_EXCEPTION_LIST_SIZE } from '../../../../../lists/common/constants'; import { ShardError } from '../../types'; import { RuleStatusService } from './rule_status_service'; +import { + EqlRuleParams, + MachineLearningRuleParams, + QueryRuleParams, + RuleParams, + SavedQueryRuleParams, + ThreatRuleParams, + ThresholdRuleParams, +} from '../schemas/rule_schemas'; interface SortExceptionsReturn { exceptionsWithValueLists: ExceptionListItemSchema[]; @@ -825,3 +834,15 @@ export const getThresholdTermsHash = ( ) .digest('hex'); }; + +export const isEqlParams = (params: RuleParams): params is EqlRuleParams => params.type === 'eql'; +export const isThresholdParams = (params: RuleParams): params is ThresholdRuleParams => + params.type === 'threshold'; +export const isQueryParams = (params: RuleParams): params is QueryRuleParams => + params.type === 'query'; +export const isSavedQueryParams = (params: RuleParams): params is SavedQueryRuleParams => + params.type === 'saved_query'; +export const isThreatParams = (params: RuleParams): params is ThreatRuleParams => + params.type === 'threat_match'; +export const isMachineLearningParams = (params: RuleParams): params is MachineLearningRuleParams => + params.type === 'machine_learning'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/tags/read_tags.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/tags/read_tags.test.ts index 918857b976bea..b2a589dacd371 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/tags/read_tags.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/tags/read_tags.test.ts @@ -6,9 +6,10 @@ */ import { alertsClientMock } from '../../../../../alerting/server/mocks'; -import { getResult, getFindResultWithMultiHits } from '../routes/__mocks__/request_responses'; +import { getAlertMock, getFindResultWithMultiHits } from '../routes/__mocks__/request_responses'; import { INTERNAL_RULE_ID_KEY, INTERNAL_IDENTIFIER } from '../../../../common/constants'; import { readRawTags, readTags, convertTagsToSet, convertToTags, isTags } from './read_tags'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; describe('read_tags', () => { afterEach(() => { @@ -17,12 +18,12 @@ describe('read_tags', () => { describe('readRawTags', () => { test('it should return the intersection of tags to where none are repeating', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 2', 'tag 3']; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = ['tag 1', 'tag 2', 'tag 3', 'tag 4']; @@ -35,12 +36,12 @@ describe('read_tags', () => { }); test('it should return the intersection of tags to where some are repeating values', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3']; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3', 'tag 4']; @@ -53,12 +54,12 @@ describe('read_tags', () => { }); test('it should work with no tags defined between two results', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = []; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = []; @@ -71,7 +72,7 @@ describe('read_tags', () => { }); test('it should work with a single tag which has repeating values in it', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 1', 'tag 1', 'tag 2']; @@ -84,7 +85,7 @@ describe('read_tags', () => { }); test('it should work with a single tag which has empty tags', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = []; @@ -99,12 +100,12 @@ describe('read_tags', () => { describe('readTags', () => { test('it should return the intersection of tags to where none are repeating', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 2', 'tag 3']; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = ['tag 1', 'tag 2', 'tag 3', 'tag 4']; @@ -117,12 +118,12 @@ describe('read_tags', () => { }); test('it should return the intersection of tags to where some are repeating values', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3']; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3', 'tag 4']; @@ -135,12 +136,12 @@ describe('read_tags', () => { }); test('it should work with no tags defined between two results', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = []; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = []; @@ -153,7 +154,7 @@ describe('read_tags', () => { }); test('it should work with a single tag which has repeating values in it', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 1', 'tag 1', 'tag 2']; @@ -166,7 +167,7 @@ describe('read_tags', () => { }); test('it should work with a single tag which has empty tags', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = []; @@ -179,7 +180,7 @@ describe('read_tags', () => { }); test('it should filter out any __internal tags for things such as alert_id', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = [ @@ -196,7 +197,7 @@ describe('read_tags', () => { }); test('it should filter out any __internal tags with two different results', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = [ @@ -209,7 +210,7 @@ describe('read_tags', () => { 'tag 5', ]; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = [ @@ -231,12 +232,12 @@ describe('read_tags', () => { describe('convertTagsToSet', () => { test('it should convert the intersection of two tag systems without duplicates', () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3']; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3', 'tag 4']; @@ -254,12 +255,12 @@ describe('read_tags', () => { describe('convertToTags', () => { test('it should convert the two tag systems together with duplicates', () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3']; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3', 'tag 4']; @@ -280,18 +281,18 @@ describe('read_tags', () => { }); test('it should filter out anything that is not a tag', () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3']; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '99979e67-19a7-455f-b452-8eded6135716'; result2.params.ruleId = 'rule-2'; // @ts-expect-error delete result2.tags; - const result3 = getResult(); + const result3 = getAlertMock(getQueryRuleParams()); result3.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result3.params.ruleId = 'rule-2'; result3.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3', 'tag 4']; From e35ecaa3785dd9521a1c144ee50b56084cc4c4f5 Mon Sep 17 00:00:00 2001 From: Ross Wolf <31489089+rw-access@users.noreply.github.com> Date: Wed, 14 Apr 2021 10:57:50 -0600 Subject: [PATCH 28/43] [Security] Adds pre-packaged rule updates through the "Prebuilt Security Detection Rules" Fleet integration (#96698) * Make the prepackaged rules functions async * Fix type for getPrepackagedRules mock * Install updates from saved objects & FS * Mock getLatestPrepackagedRules instead of getPrepackagedRules * Cleanup ruleAssetSavedObjectsClientFactory.all * Fix comment for "most recent version" * Switch to ruleMap.get() for less typescript errors * Remove unneeded constants * Fix SO.attributes sig and use custom validation --- .../rules/add_prepackaged_rules_route.test.ts | 2 +- .../rules/add_prepackaged_rules_route.ts | 11 +-- ...get_prepackaged_rules_status_route.test.ts | 2 +- .../get_prepackaged_rules_status_route.ts | 11 +-- .../rules/get_prepackaged_rules.test.ts | 6 +- .../rules/get_prepackaged_rules.ts | 68 ++++++++++++++++++- .../rules/rule_asset_saved_objects_client.ts | 47 +++++++++++++ .../lib/detection_engine/rules/types.ts | 13 ++++ 8 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/rule_asset_saved_objects_client.ts diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts index 1195f9e5e1e96..026820a8f2ff7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts @@ -25,7 +25,7 @@ import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mo jest.mock('../../rules/get_prepackaged_rules', () => { return { - getPrepackagedRules: (): AddPrepackagedRulesSchemaDecoded[] => { + getLatestPrepackagedRules: async (): Promise => { return [ { author: ['Elastic'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts index 8a8d6925b0e80..4f9bd7d0cfd6c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts @@ -25,12 +25,13 @@ import { SetupPlugins } from '../../../../plugin'; import { buildFrameworkRequest } from '../../../timeline/utils/common'; import { getIndexExists } from '../../index/get_index_exists'; -import { getPrepackagedRules } from '../../rules/get_prepackaged_rules'; +import { getLatestPrepackagedRules } from '../../rules/get_prepackaged_rules'; import { installPrepackagedRules } from '../../rules/install_prepacked_rules'; import { updatePrepackagedRules } from '../../rules/update_prepacked_rules'; import { getRulesToInstall } from '../../rules/get_rules_to_install'; import { getRulesToUpdate } from '../../rules/get_rules_to_update'; import { getExistingPrepackagedRules } from '../../rules/get_existing_prepackaged_rules'; +import { ruleAssetSavedObjectsClientFactory } from '../../rules/rule_asset_saved_objects_client'; import { transformError, buildSiemResponse } from '../utils'; import { AlertsClient } from '../../../../../../alerting/server'; @@ -110,7 +111,7 @@ export const createPrepackagedRules = async ( const savedObjectsClient = context.core.savedObjects.client; const exceptionsListClient = context.lists != null ? context.lists.getExceptionListClient() : exceptionsClient; - + const ruleAssetsClient = ruleAssetSavedObjectsClientFactory(savedObjectsClient); if (!siemClient || !alertsClient) { throw new PrepackagedRulesError('', 404); } @@ -120,10 +121,10 @@ export const createPrepackagedRules = async ( await exceptionsListClient.createEndpointList(); } - const rulesFromFileSystem = getPrepackagedRules(); + const latestPrepackagedRules = await getLatestPrepackagedRules(ruleAssetsClient); const prepackagedRules = await getExistingPrepackagedRules({ alertsClient }); - const rulesToInstall = getRulesToInstall(rulesFromFileSystem, prepackagedRules); - const rulesToUpdate = getRulesToUpdate(rulesFromFileSystem, prepackagedRules); + const rulesToInstall = getRulesToInstall(latestPrepackagedRules, prepackagedRules); + const rulesToUpdate = getRulesToUpdate(latestPrepackagedRules, prepackagedRules); const signalsIndex = siemClient.getSignalsIndex(); if (rulesToInstall.length !== 0 || rulesToUpdate.length !== 0) { const signalsIndexExists = await getIndexExists(esClient.asCurrentUser, signalsIndex); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts index 9e843d463ab3e..3c8321ee8eb9a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts @@ -23,7 +23,7 @@ import { jest.mock('../../rules/get_prepackaged_rules', () => { return { - getPrepackagedRules: () => { + getLatestPrepackagedRules: async () => { return [ { rule_id: 'rule-1', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts index c67f2cb6e9545..33f9746fe9245 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts @@ -13,11 +13,12 @@ import { import type { SecuritySolutionPluginRouter } from '../../../../types'; import { DETECTION_ENGINE_PREPACKAGED_URL } from '../../../../../common/constants'; import { transformError, buildSiemResponse } from '../utils'; -import { getPrepackagedRules } from '../../rules/get_prepackaged_rules'; import { getRulesToInstall } from '../../rules/get_rules_to_install'; import { getRulesToUpdate } from '../../rules/get_rules_to_update'; import { findRules } from '../../rules/find_rules'; +import { getLatestPrepackagedRules } from '../../rules/get_prepackaged_rules'; import { getExistingPrepackagedRules } from '../../rules/get_existing_prepackaged_rules'; +import { ruleAssetSavedObjectsClientFactory } from '../../rules/rule_asset_saved_objects_client'; import { buildFrameworkRequest } from '../../../timeline/utils/common'; import { ConfigType } from '../../../../config'; import { SetupPlugins } from '../../../../plugin'; @@ -40,15 +41,17 @@ export const getPrepackagedRulesStatusRoute = ( }, }, async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; const siemResponse = buildSiemResponse(response); const alertsClient = context.alerting?.getAlertsClient(); + const ruleAssetsClient = ruleAssetSavedObjectsClientFactory(savedObjectsClient); if (!alertsClient) { return siemResponse.error({ statusCode: 404 }); } try { - const rulesFromFileSystem = getPrepackagedRules(); + const latestPrepackagedRules = await getLatestPrepackagedRules(ruleAssetsClient); const customRules = await findRules({ alertsClient, perPage: 1, @@ -61,8 +64,8 @@ export const getPrepackagedRulesStatusRoute = ( const frameworkRequest = await buildFrameworkRequest(context, security, request); const prepackagedRules = await getExistingPrepackagedRules({ alertsClient }); - const rulesToInstall = getRulesToInstall(rulesFromFileSystem, prepackagedRules); - const rulesToUpdate = getRulesToUpdate(rulesFromFileSystem, prepackagedRules); + const rulesToInstall = getRulesToInstall(latestPrepackagedRules, prepackagedRules); + const rulesToUpdate = getRulesToUpdate(latestPrepackagedRules, prepackagedRules); const prepackagedTimelineStatus = await checkTimelinesStatus(frameworkRequest); const [validatedprepackagedTimelineStatus] = validate( prepackagedTimelineStatus, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.test.ts index 039bc8c1e2e49..2d92731dbbdfd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.test.ts @@ -41,8 +41,10 @@ describe('get_existing_prepackaged_rules', () => { }); test('should throw an exception with a message having rule_id and name in it', () => { - // @ts-expect-error intentionally invalid argument - expect(() => getPrepackagedRules([{ name: 'rule name', rule_id: 'id-123' }])).toThrow( + expect(() => + // @ts-expect-error intentionally invalid argument + getPrepackagedRules([{ name: 'rule name', rule_id: 'id-123' }]) + ).toThrow( 'name: "rule name", rule_id: "id-123" within the folder rules/prepackaged_rules is not a valid detection engine rule. Expect the system to not work with pre-packaged rules until this rule is fixed or the file is removed. Error is: Invalid value "undefined" supplied to "description",Invalid value "undefined" supplied to "risk_score",Invalid value "undefined" supplied to "severity",Invalid value "undefined" supplied to "type",Invalid value "undefined" supplied to "version", Full rule contents are:\n{\n "name": "rule name",\n "rule_id": "id-123"\n}' ); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.ts index 508238afcb6df..b91557c6d7b1b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.ts @@ -19,6 +19,9 @@ import { BadRequestError } from '../errors/bad_request_error'; // TODO: convert rules files to TS and add explicit type definitions import { rawRules } from './prepackaged_rules'; +import { RuleAssetSavedObjectsClient } from './rule_asset_saved_objects_client'; +import { IRuleAssetSOAttributes } from './types'; +import { SavedObjectAttributes } from '../../../../../../../src/core/types'; /** * Validate the rules from the file system and throw any errors indicating to the developer @@ -52,7 +55,70 @@ export const validateAllPrepackagedRules = ( }); }; +/** + * Validate the rules from Saved Objects created by Fleet. + */ +export const validateAllRuleSavedObjects = ( + rules: Array +): AddPrepackagedRulesSchemaDecoded[] => { + return rules.map((rule) => { + const decoded = addPrepackagedRulesSchema.decode(rule); + const checked = exactCheck(rule, decoded); + + const onLeft = (errors: t.Errors): AddPrepackagedRulesSchemaDecoded => { + const ruleName = rule.name ? rule.name : '(rule name unknown)'; + const ruleId = rule.rule_id ? rule.rule_id : '(rule rule_id unknown)'; + throw new BadRequestError( + `name: "${ruleName}", rule_id: "${ruleId}" within the security-rule saved object ` + + `is not a valid detection engine rule. Expect the system ` + + `to not work with pre-packaged rules until this rule is fixed ` + + `or the file is removed. Error is: ${formatErrors( + errors + ).join()}, Full rule contents are:\n${JSON.stringify(rule, null, 2)}` + ); + }; + + const onRight = (schema: AddPrepackagedRulesSchema): AddPrepackagedRulesSchemaDecoded => { + return schema as AddPrepackagedRulesSchemaDecoded; + }; + return pipe(checked, fold(onLeft, onRight)); + }); +}; + +/** + * Retrieve and validate rules that were installed from Fleet as saved objects. + */ +export const getFleetInstalledRules = async ( + client: RuleAssetSavedObjectsClient +): Promise => { + const fleetResponse = await client.all(); + const fleetRules = fleetResponse.map((so) => so.attributes); + return validateAllRuleSavedObjects(fleetRules); +}; + export const getPrepackagedRules = ( // @ts-expect-error mock data is too loosely typed rules: AddPrepackagedRulesSchema[] = rawRules -): AddPrepackagedRulesSchemaDecoded[] => validateAllPrepackagedRules(rules); +): AddPrepackagedRulesSchemaDecoded[] => { + return validateAllPrepackagedRules(rules); +}; + +export const getLatestPrepackagedRules = async ( + client: RuleAssetSavedObjectsClient +): Promise => { + // build a map of the most recent version of each rule + const prepackaged = getPrepackagedRules(); + const ruleMap = new Map(prepackaged.map((r) => [r.rule_id, r])); + + // check the rules installed via fleet and create/update if the version is newer + const fleetRules = await getFleetInstalledRules(client); + const fleetUpdates = fleetRules.filter((r) => { + const rule = ruleMap.get(r.rule_id); + return rule == null || rule.version < r.version; + }); + + // add the new or updated rules to the map + fleetUpdates.forEach((r) => ruleMap.set(r.rule_id, r)); + + return Array.from(ruleMap.values()); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/rule_asset_saved_objects_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/rule_asset_saved_objects_client.ts new file mode 100644 index 0000000000000..ac0969dfc975d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/rule_asset_saved_objects_client.ts @@ -0,0 +1,47 @@ +/* + * 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 { + SavedObjectsClientContract, + SavedObjectsFindOptions, + SavedObjectsFindResponse, +} from '../../../../../../../src/core/server'; +import { ruleAssetSavedObjectType } from '../rules/saved_object_mappings'; +import { IRuleAssetSavedObject } from '../rules/types'; + +const DEFAULT_PAGE_SIZE = 100; + +export interface RuleAssetSavedObjectsClient { + find: ( + options?: Omit + ) => Promise>; + all: () => Promise; +} + +export const ruleAssetSavedObjectsClientFactory = ( + savedObjectsClient: SavedObjectsClientContract +): RuleAssetSavedObjectsClient => { + return { + find: (options) => + savedObjectsClient.find({ + ...options, + type: ruleAssetSavedObjectType, + }), + all: async () => { + const finder = savedObjectsClient.createPointInTimeFinder({ + perPage: DEFAULT_PAGE_SIZE, + type: ruleAssetSavedObjectType, + }); + const responses: IRuleAssetSavedObject[] = []; + for await (const response of finder.find()) { + responses.push(...response.saved_objects.map((so) => so as IRuleAssetSavedObject)); + } + await finder.close(); + return responses; + }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index 2a87b00829321..2990a0f728027 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -164,6 +164,19 @@ export interface IRuleStatusFindType { saved_objects: IRuleStatusSavedObject[]; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface IRuleAssetSOAttributes extends Record { + rule_id: string | null | undefined; + version: string | null | undefined; + name: string | null | undefined; +} + +export interface IRuleAssetSavedObject { + type: string; + id: string; + attributes: IRuleAssetSOAttributes & SavedObjectAttributes; +} + export interface HapiReadableStream extends Readable { hapi: { filename: string; From 096536647f69875d835d5cc055ec3b27cbec09bf Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Wed, 14 Apr 2021 19:11:44 +0200 Subject: [PATCH 29/43] [ML] fix vertical overflow (#97127) --- .../ml/public/application/explorer/swimlane_container.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index 4adb79f065cd4..c108257094b6a 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -361,7 +361,7 @@ export const SwimlaneContainer: FC = ({ From 3bc2952216f905620afe019af4b3785e385f000d Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Wed, 14 Apr 2021 12:28:00 -0500 Subject: [PATCH 30/43] [Workplace Search] Bypass UnsavedChangesPrompt for tab changes in Display Settings (#97062) * Move redirect logic into logic file * Add logic to prevent prompt from triggering when changing tabs The idea here is to set a boolean flag that sends false for unsavedChanges when switching between tabs and then sets it back after a successful tab change * Keep sidebar nav item active for both tabs * Add tests --- .../display_settings.test.tsx | 8 ++-- .../display_settings/display_settings.tsx | 28 ++++--------- .../display_settings_logic.test.ts | 40 ++++++++++++++++++- .../display_settings_logic.ts | 39 ++++++++++++++++++ .../components/source_sub_nav.tsx | 5 ++- 5 files changed, 94 insertions(+), 26 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx index c1f526e24b8e2..54be43596a431 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx @@ -7,7 +7,6 @@ import '../../../../../__mocks__/shallow_useeffect.mock'; -import { mockKibanaValues } from '../../../../../__mocks__'; import { setMockValues, setMockActions } from '../../../../../__mocks__'; import { exampleResult } from '../../../../__mocks__/content_sources.mock'; @@ -25,11 +24,11 @@ import { DisplaySettings } from './display_settings'; import { FieldEditorModal } from './field_editor_modal'; describe('DisplaySettings', () => { - const { navigateToUrl } = mockKibanaValues; const { exampleDocuments, searchResultConfig } = exampleResult; const initializeDisplaySettings = jest.fn(); const setServerData = jest.fn(); const setColorField = jest.fn(); + const handleSelectedTabChanged = jest.fn(); const values = { isOrganization: true, @@ -46,6 +45,7 @@ describe('DisplaySettings', () => { initializeDisplaySettings, setServerData, setColorField, + handleSelectedTabChanged, }); setMockValues({ ...values }); }); @@ -83,7 +83,7 @@ describe('DisplaySettings', () => { const tabsEl = wrapper.find(EuiTabbedContent); tabsEl.prop('onTabClick')!(tabs[0]); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/123/display_settings/'); + expect(handleSelectedTabChanged).toHaveBeenCalledWith('search_results'); }); it('handles second tab click', () => { @@ -91,7 +91,7 @@ describe('DisplaySettings', () => { const tabsEl = wrapper.find(EuiTabbedContent); tabsEl.prop('onTabClick')!(tabs[1]); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/123/display_settings/result_detail'); + expect(handleSelectedTabChanged).toHaveBeenCalledWith('result_detail'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx index e39a8d17e406c..3441e5fcbaf82 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx @@ -20,19 +20,11 @@ import { } from '@elastic/eui'; import { clearFlashMessages } from '../../../../../shared/flash_messages'; -import { KibanaLogic } from '../../../../../shared/kibana'; import { Loading } from '../../../../../shared/loading'; import { UnsavedChangesPrompt } from '../../../../../shared/unsaved_changes_prompt'; -import { AppLogic } from '../../../../app_logic'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; import { SAVE_BUTTON } from '../../../../constants'; -import { - DISPLAY_SETTINGS_RESULT_DETAIL_PATH, - DISPLAY_SETTINGS_SEARCH_RESULT_PATH, - getContentSourcePath, -} from '../../../../routes'; - import { UNSAVED_MESSAGE, DISPLAY_SETTINGS_TITLE, @@ -42,7 +34,7 @@ import { SEARCH_RESULTS_LABEL, RESULT_DETAIL_LABEL, } from './constants'; -import { DisplaySettingsLogic } from './display_settings_logic'; +import { DisplaySettingsLogic, TabId } from './display_settings_logic'; import { FieldEditorModal } from './field_editor_modal'; import { ResultDetail } from './result_detail'; import { SearchResults } from './search_results'; @@ -52,19 +44,20 @@ interface DisplaySettingsProps { } export const DisplaySettings: React.FC = ({ tabId }) => { - const { initializeDisplaySettings, setServerData } = useActions(DisplaySettingsLogic); + const { initializeDisplaySettings, setServerData, handleSelectedTabChanged } = useActions( + DisplaySettingsLogic + ); const { dataLoading, - sourceId, addFieldModalVisible, unsavedChanges, exampleDocuments, + navigatingBetweenTabs, } = useValues(DisplaySettingsLogic); - const { isOrganization } = useValues(AppLogic); - const hasDocuments = exampleDocuments.length > 0; + const hasUnsavedChanges = hasDocuments && unsavedChanges; useEffect(() => { initializeDisplaySettings(); @@ -87,12 +80,7 @@ export const DisplaySettings: React.FC = ({ tabId }) => { ] as EuiTabbedContentTab[]; const onSelectedTabChanged = (tab: EuiTabbedContentTab) => { - const path = - tab.id === tabs[1].id - ? getContentSourcePath(DISPLAY_SETTINGS_RESULT_DETAIL_PATH, sourceId, isOrganization) - : getContentSourcePath(DISPLAY_SETTINGS_SEARCH_RESULT_PATH, sourceId, isOrganization); - - KibanaLogic.values.navigateToUrl(path); + handleSelectedTabChanged(tab.id as TabId); }; const handleFormSubmit = (e: FormEvent) => { @@ -103,7 +91,7 @@ export const DisplaySettings: React.FC = ({ tabId }) => { return ( <>
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts index 73df0298ecd19..5a6ef5ba5990f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../../../__mocks__'; +import { + LogicMounter, + mockFlashMessageHelpers, + mockHttpValues, + mockKibanaValues, +} from '../../../../../__mocks__'; import { exampleResult } from '../../../../__mocks__/content_sources.mock'; import { nextTick } from '@kbn/test/jest'; @@ -25,6 +30,7 @@ import { DisplaySettingsLogic, defaultSearchResultConfig } from './display_setti describe('DisplaySettingsLogic', () => { const { http } = mockHttpValues; + const { navigateToUrl } = mockKibanaValues; const { clearFlashMessages, flashAPIErrors, setSuccessMessage } = mockFlashMessageHelpers; const { mount } = new LogicMounter(DisplaySettingsLogic); @@ -40,6 +46,7 @@ describe('DisplaySettingsLogic', () => { serverRoute: '', editFieldIndex: null, dataLoading: true, + navigatingBetweenTabs: false, addFieldModalVisible: false, titleFieldHover: false, urlFieldHover: false, @@ -203,6 +210,12 @@ describe('DisplaySettingsLogic', () => { }); }); + it('setNavigatingBetweenTabs', () => { + DisplaySettingsLogic.actions.setNavigatingBetweenTabs(true); + + expect(DisplaySettingsLogic.values.navigatingBetweenTabs).toEqual(true); + }); + it('addDetailField', () => { const newField = { label: 'Monkey', fieldName: 'primate' }; DisplaySettingsLogic.actions.setServerResponseData(serverProps); @@ -351,6 +364,31 @@ describe('DisplaySettingsLogic', () => { expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); }); + + describe('handleSelectedTabChanged', () => { + beforeEach(() => { + DisplaySettingsLogic.actions.onInitializeDisplaySettings(serverProps); + }); + + it('calls sets navigatingBetweenTabs', async () => { + const setNavigatingBetweenTabsSpy = jest.spyOn( + DisplaySettingsLogic.actions, + 'setNavigatingBetweenTabs' + ); + DisplaySettingsLogic.actions.handleSelectedTabChanged('search_results'); + await nextTick(); + + expect(setNavigatingBetweenTabsSpy).toHaveBeenCalledWith(true); + expect(navigateToUrl).toHaveBeenCalledWith('/p/sources/123/display_settings/'); + }); + + it('calls calls correct route for "result_detail"', async () => { + DisplaySettingsLogic.actions.handleSelectedTabChanged('result_detail'); + await nextTick(); + + expect(navigateToUrl).toHaveBeenCalledWith('/p/sources/123/display_settings/result_detail'); + }); + }); }); describe('selectors', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts index 62d959083af59..e8b419a31abb2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts @@ -16,7 +16,13 @@ import { flashAPIErrors, } from '../../../../../shared/flash_messages'; import { HttpLogic } from '../../../../../shared/http'; +import { KibanaLogic } from '../../../../../shared/kibana'; import { AppLogic } from '../../../../app_logic'; +import { + DISPLAY_SETTINGS_RESULT_DETAIL_PATH, + DISPLAY_SETTINGS_SEARCH_RESULT_PATH, + getContentSourcePath, +} from '../../../../routes'; import { DetailField, SearchResultConfig, OptionValue, Result } from '../../../../types'; import { SourceLogic } from '../../source_logic'; @@ -34,6 +40,8 @@ export interface DisplaySettingsInitialData extends DisplaySettingsResponseProps serverRoute: string; } +export type TabId = 'search_results' | 'result_detail'; + interface DisplaySettingsActions { initializeDisplaySettings(): void; setServerData(): void; @@ -51,6 +59,8 @@ interface DisplaySettingsActions { setDetailFields(result: DropResult): { result: DropResult }; openEditDetailField(editFieldIndex: number | null): number | null; removeDetailField(index: number): number; + setNavigatingBetweenTabs(navigatingBetweenTabs: boolean): boolean; + handleSelectedTabChanged(tabId: TabId): TabId; addDetailField(newField: DetailField): DetailField; updateDetailField( updatedField: DetailField, @@ -73,6 +83,7 @@ interface DisplaySettingsValues { serverRoute: string; editFieldIndex: number | null; dataLoading: boolean; + navigatingBetweenTabs: boolean; addFieldModalVisible: boolean; titleFieldHover: boolean; urlFieldHover: boolean; @@ -109,6 +120,8 @@ export const DisplaySettingsLogic = kea< setDetailFields: (result: DropResult) => ({ result }), openEditDetailField: (editFieldIndex: number | null) => editFieldIndex, removeDetailField: (index: number) => index, + setNavigatingBetweenTabs: (navigatingBetweenTabs: boolean) => navigatingBetweenTabs, + handleSelectedTabChanged: (tabId: TabId) => tabId, addDetailField: (newField: DetailField) => newField, updateDetailField: (updatedField: DetailField, index: number) => ({ updatedField, index }), toggleFieldEditorModal: () => true, @@ -224,6 +237,12 @@ export const DisplaySettingsLogic = kea< onInitializeDisplaySettings: () => false, }, ], + navigatingBetweenTabs: [ + false, + { + setNavigatingBetweenTabs: (_, navigatingBetweenTabs) => navigatingBetweenTabs, + }, + ], addFieldModalVisible: [ false, { @@ -330,6 +349,26 @@ export const DisplaySettingsLogic = kea< toggleFieldEditorModal: () => { clearFlashMessages(); }, + + handleSelectedTabChanged: async (tabId, breakpoint) => { + const { isOrganization } = AppLogic.values; + const { sourceId } = values; + const path = + tabId === 'result_detail' + ? getContentSourcePath(DISPLAY_SETTINGS_RESULT_DETAIL_PATH, sourceId, isOrganization) + : getContentSourcePath(DISPLAY_SETTINGS_SEARCH_RESULT_PATH, sourceId, isOrganization); + + // This method is needed because the shared `UnsavedChangesPrompt` component is triggered + // when navigating between tabs. We set a boolean flag that tells the prompt there are no + // unsaved changes when navigating between the tabs and reset it one the transition is complete + // in order to restore the intended functionality when navigating away with unsaved changes. + actions.setNavigatingBetweenTabs(true); + + await breakpoint(); + + KibanaLogic.values.navigateToUrl(path); + actions.setNavigatingBetweenTabs(false); + }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx index bf0c5471f7b57..12e1506ec6efd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx @@ -45,7 +45,10 @@ export const SourceSubNav: React.FC = () => { {NAV.SCHEMA} - + {NAV.DISPLAY_SETTINGS} From 0bfa5aaf013b834ecbd21d5338529b8d193f1a12 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 14 Apr 2021 19:49:19 +0100 Subject: [PATCH 31/43] chore(NA): moving @kbn/tinymath into bazel (#97022) * chore(NA): moving @kbn/tinymath into bazel * chore(NA): fixed jest tests * chore(NA): simplified tsconfig file Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../monorepo-packages.asciidoc | 1 + package.json | 2 +- packages/BUILD.bazel | 3 +- packages/kbn-tinymath/BUILD.bazel | 71 + .../{src => grammar}/grammar.pegjs | 2 +- .../{tinymath.d.ts => index.d.ts} | 0 packages/kbn-tinymath/package.json | 7 +- packages/kbn-tinymath/src/grammar.js | 1555 ----------------- packages/kbn-tinymath/src/index.js | 3 +- packages/kbn-tinymath/test/library.test.js | 2 +- packages/kbn-tinymath/tsconfig.json | 5 +- yarn.lock | 2 +- 12 files changed, 82 insertions(+), 1571 deletions(-) create mode 100644 packages/kbn-tinymath/BUILD.bazel rename packages/kbn-tinymath/{src => grammar}/grammar.pegjs (99%) rename packages/kbn-tinymath/{tinymath.d.ts => index.d.ts} (100%) delete mode 100644 packages/kbn-tinymath/src/grammar.js diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 88a142e5b53c0..fc78729be5a69 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -64,4 +64,5 @@ yarn kbn watch-bazel - @elastic/datemath - @kbn/apm-utils - @kbn/config-schema +- @kbn/tinymath diff --git a/package.json b/package.json index 9b4958c30022c..ff7f76df4aee5 100644 --- a/package.json +++ b/package.json @@ -138,7 +138,7 @@ "@kbn/server-http-tools": "link:packages/kbn-server-http-tools", "@kbn/server-route-repository": "link:packages/kbn-server-route-repository", "@kbn/std": "link:packages/kbn-std", - "@kbn/tinymath": "link:packages/kbn-tinymath", + "@kbn/tinymath": "link:bazel-bin/packages/kbn-tinymath/npm_module", "@kbn/ui-framework": "link:packages/kbn-ui-framework", "@kbn/ui-shared-deps": "link:packages/kbn-ui-shared-deps", "@kbn/utility-types": "link:packages/kbn-utility-types", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index aa66c96764718..182013c356bb0 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -5,6 +5,7 @@ filegroup( srcs = [ "//packages/elastic-datemath:build", "//packages/kbn-apm-utils:build", - "//packages/kbn-config-schema:build" + "//packages/kbn-config-schema:build", + "//packages/kbn-tinymath:build", ], ) diff --git a/packages/kbn-tinymath/BUILD.bazel b/packages/kbn-tinymath/BUILD.bazel new file mode 100644 index 0000000000000..9d521776fb491 --- /dev/null +++ b/packages/kbn-tinymath/BUILD.bazel @@ -0,0 +1,71 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") +load("@npm//pegjs:index.bzl", "pegjs") + +PKG_BASE_NAME = "kbn-tinymath" +PKG_REQUIRE_NAME = "@kbn/tinymath" + +SOURCE_FILES = glob( + [ + "src/**/*", + ] +) + +TYPE_FILES = [ + "index.d.ts", +] + +SRCS = SOURCE_FILES + TYPE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +DEPS = [ + "@npm//lodash", +] + +pegjs( + name = "grammar", + data = [ + ":grammar/grammar.pegjs" + ], + output_dir = True, + args = [ + "-o", + "$(@D)/index.js", + "./%s/grammar/grammar.pegjs" % package_name() + ], +) + +js_library( + name = PKG_BASE_NAME, + srcs = [ + ":srcs", + ":grammar" + ], + deps = DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + srcs = NPM_MODULE_EXTRA_FILES, + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-tinymath/src/grammar.pegjs b/packages/kbn-tinymath/grammar/grammar.pegjs similarity index 99% rename from packages/kbn-tinymath/src/grammar.pegjs rename to packages/kbn-tinymath/grammar/grammar.pegjs index 9cb92fa9374a2..70f275776e45d 100644 --- a/packages/kbn-tinymath/src/grammar.pegjs +++ b/packages/kbn-tinymath/grammar/grammar.pegjs @@ -107,7 +107,7 @@ String / [\'] value:(ValidChar)+ [\'] { return value.join(''); } / value:(ValidChar)+ { return value.join(''); } - + Argument = name:[a-zA-Z_]+ _ '=' _ value:(Number / String) _ { return { diff --git a/packages/kbn-tinymath/tinymath.d.ts b/packages/kbn-tinymath/index.d.ts similarity index 100% rename from packages/kbn-tinymath/tinymath.d.ts rename to packages/kbn-tinymath/index.d.ts diff --git a/packages/kbn-tinymath/package.json b/packages/kbn-tinymath/package.json index cc4fa0a64d9c3..915afda7ba2d2 100644 --- a/packages/kbn-tinymath/package.json +++ b/packages/kbn-tinymath/package.json @@ -4,10 +4,5 @@ "license": "SSPL-1.0 OR Elastic License 2.0", "private": true, "main": "src/index.js", - "types": "tinymath.d.ts", - "scripts": { - "kbn:bootstrap": "yarn build", - "build": "../../node_modules/.bin/pegjs -o src/grammar.js src/grammar.pegjs" - }, - "dependencies": {} + "types": "index.d.ts" } \ No newline at end of file diff --git a/packages/kbn-tinymath/src/grammar.js b/packages/kbn-tinymath/src/grammar.js deleted file mode 100644 index 5454143530c39..0000000000000 --- a/packages/kbn-tinymath/src/grammar.js +++ /dev/null @@ -1,1555 +0,0 @@ -/* - * Generated by PEG.js 0.10.0. - * - * http://pegjs.org/ - */ - -"use strict"; - -function peg$subclass(child, parent) { - function ctor() { this.constructor = child; } - ctor.prototype = parent.prototype; - child.prototype = new ctor(); -} - -function peg$SyntaxError(message, expected, found, location) { - this.message = message; - this.expected = expected; - this.found = found; - this.location = location; - this.name = "SyntaxError"; - - if (typeof Error.captureStackTrace === "function") { - Error.captureStackTrace(this, peg$SyntaxError); - } -} - -peg$subclass(peg$SyntaxError, Error); - -peg$SyntaxError.buildMessage = function(expected, found) { - var DESCRIBE_EXPECTATION_FNS = { - literal: function(expectation) { - return "\"" + literalEscape(expectation.text) + "\""; - }, - - "class": function(expectation) { - var escapedParts = "", - i; - - for (i = 0; i < expectation.parts.length; i++) { - escapedParts += expectation.parts[i] instanceof Array - ? classEscape(expectation.parts[i][0]) + "-" + classEscape(expectation.parts[i][1]) - : classEscape(expectation.parts[i]); - } - - return "[" + (expectation.inverted ? "^" : "") + escapedParts + "]"; - }, - - any: function(expectation) { - return "any character"; - }, - - end: function(expectation) { - return "end of input"; - }, - - other: function(expectation) { - return expectation.description; - } - }; - - function hex(ch) { - return ch.charCodeAt(0).toString(16).toUpperCase(); - } - - function literalEscape(s) { - return s - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\0/g, '\\0') - .replace(/\t/g, '\\t') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/[\x00-\x0F]/g, function(ch) { return '\\x0' + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x' + hex(ch); }); - } - - function classEscape(s) { - return s - .replace(/\\/g, '\\\\') - .replace(/\]/g, '\\]') - .replace(/\^/g, '\\^') - .replace(/-/g, '\\-') - .replace(/\0/g, '\\0') - .replace(/\t/g, '\\t') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/[\x00-\x0F]/g, function(ch) { return '\\x0' + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x' + hex(ch); }); - } - - function describeExpectation(expectation) { - return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); - } - - function describeExpected(expected) { - var descriptions = new Array(expected.length), - i, j; - - for (i = 0; i < expected.length; i++) { - descriptions[i] = describeExpectation(expected[i]); - } - - descriptions.sort(); - - if (descriptions.length > 0) { - for (i = 1, j = 1; i < descriptions.length; i++) { - if (descriptions[i - 1] !== descriptions[i]) { - descriptions[j] = descriptions[i]; - j++; - } - } - descriptions.length = j; - } - - switch (descriptions.length) { - case 1: - return descriptions[0]; - - case 2: - return descriptions[0] + " or " + descriptions[1]; - - default: - return descriptions.slice(0, -1).join(", ") - + ", or " - + descriptions[descriptions.length - 1]; - } - } - - function describeFound(found) { - return found ? "\"" + literalEscape(found) + "\"" : "end of input"; - } - - return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; -}; - -function peg$parse(input, options) { - options = options !== void 0 ? options : {}; - - var peg$FAILED = {}, - - peg$startRuleFunctions = { start: peg$parsestart }, - peg$startRuleFunction = peg$parsestart, - - peg$c0 = peg$otherExpectation("whitespace"), - peg$c1 = /^[ \t\n\r]/, - peg$c2 = peg$classExpectation([" ", "\t", "\n", "\r"], false, false), - peg$c3 = /^[ ]/, - peg$c4 = peg$classExpectation([" "], false, false), - peg$c5 = /^["']/, - peg$c6 = peg$classExpectation(["\"", "'"], false, false), - peg$c7 = /^[A-Za-z_@.[\]\-]/, - peg$c8 = peg$classExpectation([["A", "Z"], ["a", "z"], "_", "@", ".", "[", "]", "-"], false, false), - peg$c9 = /^[0-9A-Za-z._@[\]\-]/, - peg$c10 = peg$classExpectation([["0", "9"], ["A", "Z"], ["a", "z"], ".", "_", "@", "[", "]", "-"], false, false), - peg$c11 = peg$otherExpectation("literal"), - peg$c12 = function(literal) { - return literal; - }, - peg$c13 = function(chars) { - return { - type: 'variable', - value: chars.join(''), - location: simpleLocation(location()), - text: text() - }; - }, - peg$c14 = function(rest) { - return { - type: 'variable', - value: rest.join(''), - location: simpleLocation(location()), - text: text() - }; - }, - peg$c15 = "+", - peg$c16 = peg$literalExpectation("+", false), - peg$c17 = "-", - peg$c18 = peg$literalExpectation("-", false), - peg$c19 = function(left, rest) { - return rest.reduce((acc, curr) => ({ - type: 'function', - name: curr[0] === '+' ? 'add' : 'subtract', - args: [acc, curr[1]], - location: simpleLocation(location()), - text: text() - }), left) - }, - peg$c20 = "*", - peg$c21 = peg$literalExpectation("*", false), - peg$c22 = "/", - peg$c23 = peg$literalExpectation("/", false), - peg$c24 = function(left, rest) { - return rest.reduce((acc, curr) => ({ - type: 'function', - name: curr[0] === '*' ? 'multiply' : 'divide', - args: [acc, curr[1]], - location: simpleLocation(location()), - text: text() - }), left) - }, - peg$c25 = "(", - peg$c26 = peg$literalExpectation("(", false), - peg$c27 = ")", - peg$c28 = peg$literalExpectation(")", false), - peg$c29 = function(expr) { - return expr - }, - peg$c30 = peg$otherExpectation("arguments"), - peg$c31 = ",", - peg$c32 = peg$literalExpectation(",", false), - peg$c33 = function(first, arg) {return arg}, - peg$c34 = function(first, rest) { - return [first].concat(rest); - }, - peg$c35 = /^["]/, - peg$c36 = peg$classExpectation(["\""], false, false), - peg$c37 = function(value) { return value.join(''); }, - peg$c38 = /^[']/, - peg$c39 = peg$classExpectation(["'"], false, false), - peg$c40 = /^[a-zA-Z_]/, - peg$c41 = peg$classExpectation([["a", "z"], ["A", "Z"], "_"], false, false), - peg$c42 = "=", - peg$c43 = peg$literalExpectation("=", false), - peg$c44 = function(name, value) { - return { - type: 'namedArgument', - name: name.join(''), - value: value, - location: simpleLocation(location()), - text: text() - }; - }, - peg$c45 = peg$otherExpectation("function"), - peg$c46 = /^[a-zA-Z_\-]/, - peg$c47 = peg$classExpectation([["a", "z"], ["A", "Z"], "_", "-"], false, false), - peg$c48 = function(name, args) { - return { - type: 'function', - name: name.join(''), - args: args || [], - location: simpleLocation(location()), - text: text() - }; - }, - peg$c49 = peg$otherExpectation("number"), - peg$c50 = function() { - return parseFloat(text()); - }, - peg$c51 = /^[eE]/, - peg$c52 = peg$classExpectation(["e", "E"], false, false), - peg$c53 = peg$otherExpectation("exponent"), - peg$c54 = ".", - peg$c55 = peg$literalExpectation(".", false), - peg$c56 = "0", - peg$c57 = peg$literalExpectation("0", false), - peg$c58 = /^[1-9]/, - peg$c59 = peg$classExpectation([["1", "9"]], false, false), - peg$c60 = /^[0-9]/, - peg$c61 = peg$classExpectation([["0", "9"]], false, false), - - peg$currPos = 0, - peg$savedPos = 0, - peg$posDetailsCache = [{ line: 1, column: 1 }], - peg$maxFailPos = 0, - peg$maxFailExpected = [], - peg$silentFails = 0, - - peg$result; - - if ("startRule" in options) { - if (!(options.startRule in peg$startRuleFunctions)) { - throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); - } - - peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; - } - - function text() { - return input.substring(peg$savedPos, peg$currPos); - } - - function location() { - return peg$computeLocation(peg$savedPos, peg$currPos); - } - - function expected(description, location) { - location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos) - - throw peg$buildStructuredError( - [peg$otherExpectation(description)], - input.substring(peg$savedPos, peg$currPos), - location - ); - } - - function error(message, location) { - location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos) - - throw peg$buildSimpleError(message, location); - } - - function peg$literalExpectation(text, ignoreCase) { - return { type: "literal", text: text, ignoreCase: ignoreCase }; - } - - function peg$classExpectation(parts, inverted, ignoreCase) { - return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; - } - - function peg$anyExpectation() { - return { type: "any" }; - } - - function peg$endExpectation() { - return { type: "end" }; - } - - function peg$otherExpectation(description) { - return { type: "other", description: description }; - } - - function peg$computePosDetails(pos) { - var details = peg$posDetailsCache[pos], p; - - if (details) { - return details; - } else { - p = pos - 1; - while (!peg$posDetailsCache[p]) { - p--; - } - - details = peg$posDetailsCache[p]; - details = { - line: details.line, - column: details.column - }; - - while (p < pos) { - if (input.charCodeAt(p) === 10) { - details.line++; - details.column = 1; - } else { - details.column++; - } - - p++; - } - - peg$posDetailsCache[pos] = details; - return details; - } - } - - function peg$computeLocation(startPos, endPos) { - var startPosDetails = peg$computePosDetails(startPos), - endPosDetails = peg$computePosDetails(endPos); - - return { - start: { - offset: startPos, - line: startPosDetails.line, - column: startPosDetails.column - }, - end: { - offset: endPos, - line: endPosDetails.line, - column: endPosDetails.column - } - }; - } - - function peg$fail(expected) { - if (peg$currPos < peg$maxFailPos) { return; } - - if (peg$currPos > peg$maxFailPos) { - peg$maxFailPos = peg$currPos; - peg$maxFailExpected = []; - } - - peg$maxFailExpected.push(expected); - } - - function peg$buildSimpleError(message, location) { - return new peg$SyntaxError(message, null, null, location); - } - - function peg$buildStructuredError(expected, found, location) { - return new peg$SyntaxError( - peg$SyntaxError.buildMessage(expected, found), - expected, - found, - location - ); - } - - function peg$parsestart() { - var s0; - - s0 = peg$parseAddSubtract(); - - return s0; - } - - function peg$parse_() { - var s0, s1; - - peg$silentFails++; - s0 = []; - if (peg$c1.test(input.charAt(peg$currPos))) { - s1 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c2); } - } - while (s1 !== peg$FAILED) { - s0.push(s1); - if (peg$c1.test(input.charAt(peg$currPos))) { - s1 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c2); } - } - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c0); } - } - - return s0; - } - - function peg$parseSpace() { - var s0; - - if (peg$c3.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c4); } - } - - return s0; - } - - function peg$parseQuote() { - var s0; - - if (peg$c5.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c6); } - } - - return s0; - } - - function peg$parseStartChar() { - var s0; - - if (peg$c7.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c8); } - } - - return s0; - } - - function peg$parseValidChar() { - var s0; - - if (peg$c9.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c10); } - } - - return s0; - } - - function peg$parseLiteral() { - var s0, s1, s2, s3; - - peg$silentFails++; - s0 = peg$currPos; - s1 = peg$parse_(); - if (s1 !== peg$FAILED) { - s2 = peg$parseNumber(); - if (s2 === peg$FAILED) { - s2 = peg$parseVariable(); - } - if (s2 !== peg$FAILED) { - s3 = peg$parse_(); - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c12(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c11); } - } - - return s0; - } - - function peg$parseVariable() { - var s0, s1, s2, s3, s4, s5; - - s0 = peg$currPos; - s1 = peg$parse_(); - if (s1 !== peg$FAILED) { - s2 = peg$parseQuote(); - if (s2 !== peg$FAILED) { - s3 = []; - s4 = peg$parseValidChar(); - if (s4 === peg$FAILED) { - s4 = peg$parseSpace(); - } - while (s4 !== peg$FAILED) { - s3.push(s4); - s4 = peg$parseValidChar(); - if (s4 === peg$FAILED) { - s4 = peg$parseSpace(); - } - } - if (s3 !== peg$FAILED) { - s4 = peg$parseQuote(); - if (s4 !== peg$FAILED) { - s5 = peg$parse_(); - if (s5 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c13(s3); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = peg$parse_(); - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$parseValidChar(); - if (s3 !== peg$FAILED) { - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parseValidChar(); - } - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - s3 = peg$parse_(); - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c14(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } - - return s0; - } - - function peg$parseAddSubtract() { - var s0, s1, s2, s3, s4, s5, s6; - - s0 = peg$currPos; - s1 = peg$parse_(); - if (s1 !== peg$FAILED) { - s2 = peg$parseMultiplyDivide(); - if (s2 !== peg$FAILED) { - s3 = []; - s4 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 43) { - s5 = peg$c15; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c16); } - } - if (s5 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 45) { - s5 = peg$c17; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c18); } - } - } - if (s5 !== peg$FAILED) { - s6 = peg$parseMultiplyDivide(); - if (s6 !== peg$FAILED) { - s5 = [s5, s6]; - s4 = s5; - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - while (s4 !== peg$FAILED) { - s3.push(s4); - s4 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 43) { - s5 = peg$c15; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c16); } - } - if (s5 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 45) { - s5 = peg$c17; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c18); } - } - } - if (s5 !== peg$FAILED) { - s6 = peg$parseMultiplyDivide(); - if (s6 !== peg$FAILED) { - s5 = [s5, s6]; - s4 = s5; - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } - if (s3 !== peg$FAILED) { - s4 = peg$parse_(); - if (s4 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c19(s2, s3); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseMultiplyDivide() { - var s0, s1, s2, s3, s4, s5, s6; - - s0 = peg$currPos; - s1 = peg$parse_(); - if (s1 !== peg$FAILED) { - s2 = peg$parseFactor(); - if (s2 !== peg$FAILED) { - s3 = []; - s4 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 42) { - s5 = peg$c20; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c21); } - } - if (s5 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 47) { - s5 = peg$c22; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c23); } - } - } - if (s5 !== peg$FAILED) { - s6 = peg$parseFactor(); - if (s6 !== peg$FAILED) { - s5 = [s5, s6]; - s4 = s5; - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - while (s4 !== peg$FAILED) { - s3.push(s4); - s4 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 42) { - s5 = peg$c20; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c21); } - } - if (s5 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 47) { - s5 = peg$c22; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c23); } - } - } - if (s5 !== peg$FAILED) { - s6 = peg$parseFactor(); - if (s6 !== peg$FAILED) { - s5 = [s5, s6]; - s4 = s5; - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } - if (s3 !== peg$FAILED) { - s4 = peg$parse_(); - if (s4 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c24(s2, s3); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseFactor() { - var s0; - - s0 = peg$parseGroup(); - if (s0 === peg$FAILED) { - s0 = peg$parseFunction(); - if (s0 === peg$FAILED) { - s0 = peg$parseLiteral(); - } - } - - return s0; - } - - function peg$parseGroup() { - var s0, s1, s2, s3, s4, s5, s6, s7; - - s0 = peg$currPos; - s1 = peg$parse_(); - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 40) { - s2 = peg$c25; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c26); } - } - if (s2 !== peg$FAILED) { - s3 = peg$parse_(); - if (s3 !== peg$FAILED) { - s4 = peg$parseAddSubtract(); - if (s4 !== peg$FAILED) { - s5 = peg$parse_(); - if (s5 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 41) { - s6 = peg$c27; - peg$currPos++; - } else { - s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c28); } - } - if (s6 !== peg$FAILED) { - s7 = peg$parse_(); - if (s7 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c29(s4); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseArgument_List() { - var s0, s1, s2, s3, s4, s5, s6, s7; - - peg$silentFails++; - s0 = peg$currPos; - s1 = peg$parseArgument(); - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$currPos; - s4 = peg$parse_(); - if (s4 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 44) { - s5 = peg$c31; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c32); } - } - if (s5 !== peg$FAILED) { - s6 = peg$parse_(); - if (s6 !== peg$FAILED) { - s7 = peg$parseArgument(); - if (s7 !== peg$FAILED) { - peg$savedPos = s3; - s4 = peg$c33(s1, s7); - s3 = s4; - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$currPos; - s4 = peg$parse_(); - if (s4 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 44) { - s5 = peg$c31; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c32); } - } - if (s5 !== peg$FAILED) { - s6 = peg$parse_(); - if (s6 !== peg$FAILED) { - s7 = peg$parseArgument(); - if (s7 !== peg$FAILED) { - peg$savedPos = s3; - s4 = peg$c33(s1, s7); - s3 = s4; - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - } - if (s2 !== peg$FAILED) { - s3 = peg$parse_(); - if (s3 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 44) { - s4 = peg$c31; - peg$currPos++; - } else { - s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c32); } - } - if (s4 === peg$FAILED) { - s4 = null; - } - if (s4 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c34(s1, s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c30); } - } - - return s0; - } - - function peg$parseString() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - if (peg$c35.test(input.charAt(peg$currPos))) { - s1 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c36); } - } - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$parseValidChar(); - if (s3 !== peg$FAILED) { - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parseValidChar(); - } - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - if (peg$c35.test(input.charAt(peg$currPos))) { - s3 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c36); } - } - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c37(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (peg$c38.test(input.charAt(peg$currPos))) { - s1 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c39); } - } - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$parseValidChar(); - if (s3 !== peg$FAILED) { - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parseValidChar(); - } - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - if (peg$c38.test(input.charAt(peg$currPos))) { - s3 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c39); } - } - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c37(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = []; - s2 = peg$parseValidChar(); - if (s2 !== peg$FAILED) { - while (s2 !== peg$FAILED) { - s1.push(s2); - s2 = peg$parseValidChar(); - } - } else { - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c37(s1); - } - s0 = s1; - } - } - - return s0; - } - - function peg$parseArgument() { - var s0, s1, s2, s3, s4, s5, s6; - - s0 = peg$currPos; - s1 = []; - if (peg$c40.test(input.charAt(peg$currPos))) { - s2 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c41); } - } - if (s2 !== peg$FAILED) { - while (s2 !== peg$FAILED) { - s1.push(s2); - if (peg$c40.test(input.charAt(peg$currPos))) { - s2 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c41); } - } - } - } else { - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - s2 = peg$parse_(); - if (s2 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 61) { - s3 = peg$c42; - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c43); } - } - if (s3 !== peg$FAILED) { - s4 = peg$parse_(); - if (s4 !== peg$FAILED) { - s5 = peg$parseNumber(); - if (s5 === peg$FAILED) { - s5 = peg$parseString(); - } - if (s5 !== peg$FAILED) { - s6 = peg$parse_(); - if (s6 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c44(s1, s5); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$parseAddSubtract(); - } - - return s0; - } - - function peg$parseFunction() { - var s0, s1, s2, s3, s4, s5, s6, s7, s8; - - peg$silentFails++; - s0 = peg$currPos; - s1 = peg$parse_(); - if (s1 !== peg$FAILED) { - s2 = []; - if (peg$c46.test(input.charAt(peg$currPos))) { - s3 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c47); } - } - if (s3 !== peg$FAILED) { - while (s3 !== peg$FAILED) { - s2.push(s3); - if (peg$c46.test(input.charAt(peg$currPos))) { - s3 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c47); } - } - } - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 40) { - s3 = peg$c25; - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c26); } - } - if (s3 !== peg$FAILED) { - s4 = peg$parse_(); - if (s4 !== peg$FAILED) { - s5 = peg$parseArgument_List(); - if (s5 === peg$FAILED) { - s5 = null; - } - if (s5 !== peg$FAILED) { - s6 = peg$parse_(); - if (s6 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 41) { - s7 = peg$c27; - peg$currPos++; - } else { - s7 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c28); } - } - if (s7 !== peg$FAILED) { - s8 = peg$parse_(); - if (s8 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c48(s2, s5); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c45); } - } - - return s0; - } - - function peg$parseNumber() { - var s0, s1, s2, s3, s4; - - peg$silentFails++; - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 45) { - s1 = peg$c17; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c18); } - } - if (s1 === peg$FAILED) { - s1 = null; - } - if (s1 !== peg$FAILED) { - s2 = peg$parseInteger(); - if (s2 !== peg$FAILED) { - s3 = peg$parseFraction(); - if (s3 === peg$FAILED) { - s3 = null; - } - if (s3 !== peg$FAILED) { - s4 = peg$parseExp(); - if (s4 === peg$FAILED) { - s4 = null; - } - if (s4 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c50(); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c49); } - } - - return s0; - } - - function peg$parseE() { - var s0; - - if (peg$c51.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c52); } - } - - return s0; - } - - function peg$parseExp() { - var s0, s1, s2, s3, s4; - - peg$silentFails++; - s0 = peg$currPos; - s1 = peg$parseE(); - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 45) { - s2 = peg$c17; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c18); } - } - if (s2 === peg$FAILED) { - s2 = null; - } - if (s2 !== peg$FAILED) { - s3 = []; - s4 = peg$parseDigit(); - if (s4 !== peg$FAILED) { - while (s4 !== peg$FAILED) { - s3.push(s4); - s4 = peg$parseDigit(); - } - } else { - s3 = peg$FAILED; - } - if (s3 !== peg$FAILED) { - s1 = [s1, s2, s3]; - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c53); } - } - - return s0; - } - - function peg$parseFraction() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 46) { - s1 = peg$c54; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c55); } - } - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$parseDigit(); - if (s3 !== peg$FAILED) { - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parseDigit(); - } - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - s1 = [s1, s2]; - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseInteger() { - var s0, s1, s2, s3; - - if (input.charCodeAt(peg$currPos) === 48) { - s0 = peg$c56; - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c57); } - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (peg$c58.test(input.charAt(peg$currPos))) { - s1 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c59); } - } - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$parseDigit(); - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parseDigit(); - } - if (s2 !== peg$FAILED) { - s1 = [s1, s2]; - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } - - return s0; - } - - function peg$parseDigit() { - var s0; - - if (peg$c60.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c61); } - } - - return s0; - } - - - function simpleLocation (location) { - // Returns an object representing the position of the function within the expression, - // demarcated by the position of its first character and last character. We calculate these values - // using the offset because the expression could span multiple lines, and we don't want to deal - // with column and line values. - return { - min: location.start.offset, - max: location.end.offset - } - } - - - peg$result = peg$startRuleFunction(); - - if (peg$result !== peg$FAILED && peg$currPos === input.length) { - return peg$result; - } else { - if (peg$result !== peg$FAILED && peg$currPos < input.length) { - peg$fail(peg$endExpectation()); - } - - throw peg$buildStructuredError( - peg$maxFailExpected, - peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, - peg$maxFailPos < input.length - ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) - : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) - ); - } -} - -module.exports = { - SyntaxError: peg$SyntaxError, - parse: peg$parse -}; diff --git a/packages/kbn-tinymath/src/index.js b/packages/kbn-tinymath/src/index.js index 4db7df9c57315..9f1bb7b851463 100644 --- a/packages/kbn-tinymath/src/index.js +++ b/packages/kbn-tinymath/src/index.js @@ -7,7 +7,8 @@ */ const { get } = require('lodash'); -const { parse: parseFn } = require('./grammar'); +// eslint-disable-next-line import/no-unresolved +const { parse: parseFn } = require('../grammar'); const { functions: includedFunctions } = require('./functions'); module.exports = { parse, evaluate, interpret }; diff --git a/packages/kbn-tinymath/test/library.test.js b/packages/kbn-tinymath/test/library.test.js index d11822625b98f..5ddf1b049b8d4 100644 --- a/packages/kbn-tinymath/test/library.test.js +++ b/packages/kbn-tinymath/test/library.test.js @@ -11,7 +11,7 @@ Need tests for spacing, etc */ -import { evaluate, parse } from '..'; +import { evaluate, parse } from '@kbn/tinymath'; function variableEqual(value) { return expect.objectContaining({ type: 'variable', value }); diff --git a/packages/kbn-tinymath/tsconfig.json b/packages/kbn-tinymath/tsconfig.json index 62a7376efdfa6..73133b7318a0d 100644 --- a/packages/kbn-tinymath/tsconfig.json +++ b/packages/kbn-tinymath/tsconfig.json @@ -1,7 +1,4 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { - "tsBuildInfoFile": "../../build/tsbuildinfo/packages/kbn-tinymath" - }, - "include": ["tinymath.d.ts"] + "include": ["index.d.ts"] } diff --git a/yarn.lock b/yarn.lock index c0c481e541126..2aaf94250b966 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2740,7 +2740,7 @@ version "0.0.0" uid "" -"@kbn/tinymath@link:packages/kbn-tinymath": +"@kbn/tinymath@link:bazel-bin/packages/kbn-tinymath/npm_module": version "0.0.0" uid "" From 9602896f9e25d04371bd7919d797de370e14de9e Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 14 Apr 2021 21:06:28 +0200 Subject: [PATCH 32/43] Discover: Limit document table rendering (#96765) --- src/plugins/discover/common/index.ts | 1 + .../angular/helpers/row_formatter.test.ts | 17 +++++ .../angular/helpers/row_formatter.ts | 9 ++- .../discover_grid/discover_grid.tsx | 6 +- .../get_render_cell_value.test.tsx | 75 ++++++++++++++++--- .../discover_grid/get_render_cell_value.tsx | 7 +- src/plugins/discover/server/ui_settings.ts | 12 +++ .../server/collectors/management/schema.ts | 4 + .../server/collectors/management/types.ts | 1 + src/plugins/telemetry/schema/oss_plugins.json | 18 +++-- 10 files changed, 125 insertions(+), 25 deletions(-) diff --git a/src/plugins/discover/common/index.ts b/src/plugins/discover/common/index.ts index 45cc95ee40804..dd7f9c41a223d 100644 --- a/src/plugins/discover/common/index.ts +++ b/src/plugins/discover/common/index.ts @@ -18,3 +18,4 @@ export const CONTEXT_TIE_BREAKER_FIELDS_SETTING = 'context:tieBreakerFields'; export const DOC_TABLE_LEGACY = 'doc_table:legacy'; export const MODIFY_COLUMNS_ON_SWITCH = 'discover:modifyColumnsOnSwitch'; export const SEARCH_FIELDS_FROM_SOURCE = 'discover:searchFieldsFromSource'; +export const MAX_DOC_FIELDS_DISPLAYED = 'discover:maxDocFieldsDisplayed'; diff --git a/src/plugins/discover/public/application/angular/helpers/row_formatter.test.ts b/src/plugins/discover/public/application/angular/helpers/row_formatter.test.ts index 4c6b9002ce867..ca5cdbd808606 100644 --- a/src/plugins/discover/public/application/angular/helpers/row_formatter.test.ts +++ b/src/plugins/discover/public/application/angular/helpers/row_formatter.test.ts @@ -10,6 +10,7 @@ import { formatRow, formatTopLevelObject } from './row_formatter'; import { stubbedSavedObjectIndexPattern } from '../../../__mocks__/stubbed_saved_object_index_pattern'; import { IndexPattern } from '../../../../../data/common/index_patterns/index_patterns'; import { fieldFormatsMock } from '../../../../../data/common/field_formats/mocks'; +import { setServices } from '../../../kibana_services'; describe('Row formatter', () => { const hit = { @@ -58,6 +59,11 @@ describe('Row formatter', () => { beforeEach(() => { // @ts-expect-error indexPattern.formatHit = formatHitMock; + setServices({ + uiSettings: { + get: () => 100, + }, + }); }); it('formats document properly', () => { @@ -66,6 +72,17 @@ describe('Row formatter', () => { ); }); + it('limits number of rendered items', () => { + setServices({ + uiSettings: { + get: () => 1, + }, + }); + expect(formatRow(hit, indexPattern).trim()).toMatchInlineSnapshot( + `"
also:
with \\\\"quotes\\\\" or 'single qoutes'
"` + ); + }); + it('formats document with highlighted fields first', () => { expect( formatRow({ ...hit, highlight: { number: '42' } }, indexPattern).trim() diff --git a/src/plugins/discover/public/application/angular/helpers/row_formatter.ts b/src/plugins/discover/public/application/angular/helpers/row_formatter.ts index 02902b0634797..b219dda19e10a 100644 --- a/src/plugins/discover/public/application/angular/helpers/row_formatter.ts +++ b/src/plugins/discover/public/application/angular/helpers/row_formatter.ts @@ -7,7 +7,8 @@ */ import { template } from 'lodash'; -import { IndexPattern } from '../../../kibana_services'; +import { MAX_DOC_FIELDS_DISPLAYED } from '../../../../common'; +import { getServices, IndexPattern } from '../../../kibana_services'; function noWhiteSpace(html: string) { const TAGS_WITH_WS = />\s+, indexPattern: IndexPattern) const pairs = highlights[key] ? highlightPairs : sourcePairs; pairs.push([displayKey ? displayKey : key, val]); }); - return doTemplate({ defPairs: [...highlightPairs, ...sourcePairs] }); + const maxEntries = getServices().uiSettings.get(MAX_DOC_FIELDS_DISPLAYED); + return doTemplate({ defPairs: [...highlightPairs, ...sourcePairs].slice(0, maxEntries) }); }; export const formatTopLevelObject = ( @@ -67,5 +69,6 @@ export const formatTopLevelObject = ( const pairs = highlights[key] ? highlightPairs : sourcePairs; pairs.push([displayKey ? displayKey : key, formatted]); }); - return doTemplate({ defPairs: [...highlightPairs, ...sourcePairs] }); + const maxEntries = getServices().uiSettings.get(MAX_DOC_FIELDS_DISPLAYED); + return doTemplate({ defPairs: [...highlightPairs, ...sourcePairs].slice(0, maxEntries) }); }; diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx index 300c40a28c662..be38f166fa1c0 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx @@ -37,6 +37,7 @@ import { defaultPageSize, gridStyle, pageSizeArr, toolbarVisibility } from './co import { DiscoverServices } from '../../../build_services'; import { getDisplayedColumns } from '../../helpers/columns'; import { KibanaContextProvider } from '../../../../../kibana_react/public'; +import { MAX_DOC_FIELDS_DISPLAYED } from '../../../../common'; import { DiscoverGridDocumentToolbarBtn, getDocId } from './discover_grid_document_selection'; interface SortObj { @@ -223,9 +224,10 @@ export const DiscoverGrid = ({ indexPattern, displayedRows, displayedRows ? displayedRows.map((hit) => indexPattern.flattenHit(hit)) : [], - useNewFieldsApi + useNewFieldsApi, + services.uiSettings.get(MAX_DOC_FIELDS_DISPLAYED) ), - [displayedRows, indexPattern, useNewFieldsApi] + [displayedRows, indexPattern, useNewFieldsApi, services.uiSettings] ); /** diff --git a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx index 74cf083d82653..b7e37a28fe539 100644 --- a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx @@ -74,7 +74,8 @@ describe('Discover grid cell rendering', function () { indexPatternMock, rowsSource, rowsSource.map((row) => indexPatternMock.flattenHit(row)), - false + false, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - false + false, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - false + false, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - true + true, + 100 ); const component = shallow( { + const DiscoverGridCellValue = getRenderCellValueFn( + indexPatternMock, + rowsFields, + rowsFields.map((row) => indexPatternMock.flattenHit(row)), + true, + // this is the number of rendered items + 1 + ); + const component = shallow( + + ); + expect(component).toMatchInlineSnapshot(` + + + extension + + + + `); + }); + it('renders fields-based column correctly when isDetails is set to true', () => { const DiscoverGridCellValue = getRenderCellValueFn( indexPatternMock, rowsFields, rowsFields.map((row) => indexPatternMock.flattenHit(row)), - true + true, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - true + true, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - true + true, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - true + true, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - true + true, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - false + false, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - false + false, + 100 ); const component = shallow( >, - useNewFieldsApi: boolean + useNewFieldsApi: boolean, + maxDocFieldsDisplayed: number ) => ({ rowIndex, columnId, isDetails, setCellProps }: EuiDataGridCellValueElementProps) => { const row = rows ? rows[rowIndex] : undefined; const rowFlattened = rowsFlattened @@ -98,7 +99,7 @@ export const getRenderCellValueFn = ( return ( - {[...highlightPairs, ...sourcePairs].map(([key, value]) => ( + {[...highlightPairs, ...sourcePairs].slice(0, maxDocFieldsDisplayed).map(([key, value]) => ( {key} - {[...highlightPairs, ...sourcePairs].map(([key, value]) => ( + {[...highlightPairs, ...sourcePairs].slice(0, maxDocFieldsDisplayed).map(([key, value]) => ( {key} = { @@ -38,6 +39,17 @@ export const uiSettings: Record = { category: ['discover'], schema: schema.arrayOf(schema.string()), }, + [MAX_DOC_FIELDS_DISPLAYED]: { + name: i18n.translate('discover.advancedSettings.maxDocFieldsDisplayedTitle', { + defaultMessage: 'Maximum document fields displayed', + }), + value: 200, + description: i18n.translate('discover.advancedSettings.maxDocFieldsDisplayedText', { + defaultMessage: 'Maximum number of fields rendered in the document column', + }), + category: ['discover'], + schema: schema.number(), + }, [SAMPLE_SIZE_SETTING]: { name: i18n.translate('discover.advancedSettings.sampleSizeTitle', { defaultMessage: 'Number of rows', 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 fcdd00380755f..142bcef521c15 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -189,6 +189,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'long', _meta: { description: 'Non-default value of setting.' }, }, + 'discover:maxDocFieldsDisplayed': { + type: 'long', + _meta: { description: 'Non-default value of setting.' }, + }, defaultColumns: { type: 'array', items: { 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 613ada418c6e7..b457adecc1a79 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -28,6 +28,7 @@ export interface UsageStats { 'doc_table:legacy': boolean; 'discover:modifyColumnsOnSwitch': boolean; 'discover:searchFieldsFromSource': boolean; + 'discover:maxDocFieldsDisplayed': number; 'securitySolution:rulesTableRefresh': string; 'apm:enableSignificantTerms': boolean; 'apm:enableServiceOverview': boolean; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 56b7d98deaef8..2659fffa0bd9d 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7797,6 +7797,12 @@ "description": "Non-default value of setting." } }, + "discover:maxDocFieldsDisplayed": { + "type": "long", + "_meta": { + "description": "Non-default value of setting." + } + }, "defaultColumns": { "type": "array", "items": { @@ -8136,6 +8142,12 @@ "description": "Non-default value of setting." } }, + "observability:enableInspectEsQueries": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "banners:placement": { "type": "keyword", "_meta": { @@ -8160,12 +8172,6 @@ "description": "Non-default value of setting." } }, - "observability:enableInspectEsQueries": { - "type": "boolean", - "_meta": { - "description": "Non-default value of setting." - } - }, "labs:presentation:unifiedToolbar": { "type": "boolean", "_meta": { From af9129b584a71560a8bd467fa6fa68247aa1eaaf Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Wed, 14 Apr 2021 15:25:59 -0500 Subject: [PATCH 33/43] [Workplace Search] Source row and Group Manager Modal bugfixes (#97166) * Add spacing to group manager modal * Add error state to source row This mimics the design pattern from the overview page --- .../components/shared/source_row/source_row.scss | 16 ++++++++++++++++ .../components/shared/source_row/source_row.tsx | 8 +++++++- .../groups/components/group_manager_modal.tsx | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.scss diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.scss new file mode 100644 index 0000000000000..5c2747e8ef53b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.scss @@ -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. + */ + +.source-row--error, +.source-row--error:hover { + color: $euiColorDanger; + background: rgba($euiColorDanger, .1); + + .euiLink { + color: $euiColorDanger; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.tsx index f9679bd42c07d..b6dcaa271d8d8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.tsx @@ -7,6 +7,8 @@ import React from 'react'; +import classNames from 'classnames'; + import { EuiFlexGroup, EuiFlexItem, @@ -30,6 +32,8 @@ import { import { ContentSourceDetails } from '../../../types'; import { SourceIcon } from '../source_icon'; +import './source_row.scss'; + const CREDENTIALS_INVALID_ERROR_REASON = 'credentials_invalid'; export interface ISourceRow { @@ -65,6 +69,8 @@ export const SourceRow: React.FC = ({ const showFix = isOrganization && hasError && allowsReauth && errorReason === CREDENTIALS_INVALID_ERROR_REASON; + const rowClass = classNames({ 'source-row--error': hasError }); + const fixLink = ( = ({ ); return ( - + = ({ - + {CANCEL_BUTTON} From e9eff7181a3c67fd2f945ba1feeaef28e5da6f23 Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Wed, 14 Apr 2021 16:50:42 -0400 Subject: [PATCH 34/43] Fixed relevance tuning (#97172) --- .../applications/app_search/components/relevance_tuning/types.ts | 1 + .../server/routes/app_search/search_settings.test.ts | 1 + .../server/routes/app_search/search_settings.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts index 58e589d606e4b..bd1bdf11bd9ec 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts @@ -69,4 +69,5 @@ export interface SearchSettings { boosts: Record; search_fields: Record; result_fields?: object; + precision?: number; } diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.test.ts index d8f677e2f0d82..26204916deeca 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.test.ts @@ -53,6 +53,7 @@ describe('search settings routes', () => { boosts, result_fields: resultFields, search_fields: searchFields, + precision: 2, }; beforeEach(() => { diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.ts index d329c9b834b08..7291f7cfe64f7 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.ts @@ -22,6 +22,7 @@ const searchSettingsSchema = schema.object({ boosts, result_fields: resultFields, search_fields: searchFields, + precision: schema.number(), }); export function registerSearchSettingsRoutes({ From d72a7afbf497a626c2c815695afff6d37f910b4f Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 14 Apr 2021 14:21:28 -0700 Subject: [PATCH 35/43] [rfc][skip-ci] Screenshot Mode Service (#93496) * [Reporting] Screenshot Service RFC * rewrite summary * simplify design * Update 0009_screenshot_mode_service.md * mention the 3 screenshot report apps * try not to say this is a high-level service * clarify that print media css is just ok * clarify the intent * drop the `app` * add the possibility to test screenshot mode through a URL parameter * keep it more low-level * keep the discussion high level * move a sectioin of text --- rfcs/text/0009_screenshot_mode_service.md | 151 ++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 rfcs/text/0009_screenshot_mode_service.md diff --git a/rfcs/text/0009_screenshot_mode_service.md b/rfcs/text/0009_screenshot_mode_service.md new file mode 100644 index 0000000000000..11ceae29b5f14 --- /dev/null +++ b/rfcs/text/0009_screenshot_mode_service.md @@ -0,0 +1,151 @@ +- Start Date: 2020-03-02 +- RFC PR: (leave this empty) +- Kibana Issue: (leave this empty) + +# Summary + +Currently, the applications that support screenshot reports are: + - Dashboard + - Visualize Editor + - Canvas + +Kibana UI code should be aware when the page is rendering for the purpose of +capturing a screenshot. There should be a service to interact with low-level +code for providing that awareness. Reporting would interact with this service +to improve the quality of the Kibana Reporting feature for a few reasons: + + - Fewer objects in the headless browser memory since interactive code doesn't run + - Fewer API requests made by the headless browser for features that don't apply in a non-interactive context + +**Screenshot mode service** + +The Reporting-enabled applications should use the recommended practice of +having a customized URL for Reporting. The customized URL renders without UI +features like navigation, auto-complete, and anything else that wouldn't make +sense for non-interactive pages. + +However, applications are one piece of the UI code in a browser, and they have +dependencies on other UI plugins. Apps can't control plugins and other things +that Kibana loads in the browser. + +This RFC proposes a Screenshot Mode Service as a low-level plugin that allows +other plugins (UI code) to make choices when the page is rendering for a screenshot. + +More background on how Reporting currently works, including the lifecycle of +creating a PNG report, is here: https://github.com/elastic/kibana/issues/59396 + +# Motivation + +The Reporting team wants all applications to support a customized URLs, such as +Canvas does with its `#/export/workpad/pdf/{workpadId}` UI route. The +customized URL is where an app can solve any rendering issue in a PDF or PNG, +without needing extra CSS to be injected into the page. + +However, many low-level plugins have been added to the UI over time. These run +on every page and an application can not turn them off. Reporting performance +is negatively affected by this type of code. When the Reporting team analyzes +customer logs to figure out why a job timed out, we sometimes see requests for +the newsfeed API and telemetry API: services that aren't needed during a +reporting job. + +In 7.12.0, using the customized `/export/workpad/pdf` in Canvas, the Sample +Data Flights workpad loads 163 requests. Most of thees requests don't come from +the app itself but from the application container code that Canvas can't turn +off. + +# Detailed design + +The Screenshot Mode Service is an entirely new plugin that has an API method +that returns a Boolean. The return value tells the plugin whether or not it +should render itself to optimize for non-interactivity. + +The plugin is low-level as it has no dependencies of its own, so other +low-level plugins can depend on it. + +## Interface +A plugin would depend on `screenshotMode` in kibana.json. That provides +`screenshotMode` as a plugin object. The plugin's purpose is to know when the +page is rendered for screenshot capture, and to interact with plugins through +an API. It allows plugins to decides what to do with the screenshot mode +information. + +``` +interface IScreenshotModeServiceSetup { + isScreenshotMode: () => boolean; +} +``` + +The plugin knows the screenshot mode from request headers: this interface is +constructed from a class that refers to information sent via a custom +proprietary header: + +``` +interface HeaderData { + 'X-Screenshot-Mode': true +} + +class ScreenshotModeServiceSetup implements IScreenshotModeServiceSetup { + constructor(rawData: HeaderData) {} + public isScreenshotMode (): boolean {} +} +``` + +The Reporting headless browser that opens the page can inject custom headers +into the request. Teams should be able to test how their app renders when +loaded with this header. They could use a web debugging proxy, or perhaps the +new service should support a URL parameter which triggers screenshot mode to be +enabled, for easier testing. + +# Basic example + +When Kibana loads initially, there is a Newsfeed plugin in the UI that +checks internally cached records to see if it must fetch the Elastic News +Service for newer items. When the Screenshot Mode Service is implemented, the +Newsfeed component has a source of information to check on whether or not it +should load in the Kibana UI. If it can avoid loading, it avoids an unnecessary +HTTP round trip, which weigh heavily on performance. + +# Alternatives + +- Print media query CSS + If applications UIs supported printability using `@media print`, and Kibana + Reporting uses `page.print()` to capture the PDF, it would be easy for application + developers to test, and prevent bugs showing up in the report. + + However, this proposal only provides high-level customization over visual rendering, which the + application already has if it uses a customized URL for rendering the layout for screenshots. It + has a performance downside, as well: the headless browser still has to render the entire + page as a "normal" render before we can call `page.print()`. No one sees the + results of that initial render, so it is the same amount of wasted rendering cycles + during report generation that we have today. + +# Adoption strategy + +Using this service doesn't mean that anything needs to be replaced or thrown away. It's an add on +that any plugin or even application can use to add conditionals that previously weren't possible. +The Reporting Services team should create an example in a developer example plugin on how to build +a UI that is aware of Screenshot Mode Service. From there, the team would work on updating +whichever code that would benefit from this the most, which we know from analyzing debugging logs +of a report job. The team would work across teams to get it accepted by the owners. + +# How we teach this + +The Reporting Services team will continue to analyze debug logs of reporting jobs to find if there +is UI code running during a report job that could be optimized by this service. The team would +reach out to the code owners and determine if it makes sense to use this service to improve +screenshot performance of their code. + +# Further examples + +- Applications can also use screenshot context to customize the way they load. + An example is Toast Notifications: by default they auto-dismiss themselves + after 30 seconds or so. That makes sense when there is a human there to + notice the message, read it and remember it. But if the page is loaded for + capturing a screenshot, the toast notifications should never disappear. The + message in the toast needs to be part of the screenshot for its message to + mean anything, so it should not force the screenshot capture tool to race + against the toast timeout window. +- Avoid collection and sending of telemetry from the browser when page is + loaded for screenshot capture. +- Turn off autocomplete features and auto-refresh features that weigh on + performance for screenshot capture. From deaa7794d542efa01c141b789f0c0c5575653af7 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Wed, 14 Apr 2021 17:00:18 -0500 Subject: [PATCH 36/43] [Fleet] Add ability to specify which integration variables should be configurable (#97163) --- .../common/types/models/package_policy.ts | 1 + .../package_policy_input_config.tsx | 3 +- .../package_policy_input_stream.tsx | 3 +- .../package_policy_input_var_field.tsx | 19 ++- .../server/services/package_policy.test.ts | 153 +++++++++++++++++- .../fleet/server/services/package_policy.ts | 39 +++++ .../server/types/models/preconfiguration.ts | 3 +- 7 files changed, 211 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/fleet/common/types/models/package_policy.ts b/x-pack/plugins/fleet/common/types/models/package_policy.ts index cb84c0a2fc09a..f30cc0f87d05b 100644 --- a/x-pack/plugins/fleet/common/types/models/package_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/package_policy.ts @@ -14,6 +14,7 @@ export interface PackagePolicyPackage { export interface PackagePolicyConfigRecordEntry { type?: string; value?: any; + frozen?: boolean; } export type PackagePolicyConfigRecord = Record; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx index 037c716b42a36..33ee95910daa6 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx @@ -105,12 +105,13 @@ export const PackagePolicyInputConfig: React.FunctionComponent<{ {requiredVars.map((varDef) => { const { name: varName, type: varType } = varDef; - const value = packagePolicyInput.vars![varName].value; + const { value, frozen } = packagePolicyInput.vars![varName]; return ( { updatePackagePolicyInput({ vars: { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_stream.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_stream.tsx index 3337af7437112..84f097813d484 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_stream.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_stream.tsx @@ -106,12 +106,13 @@ export const PackagePolicyInputStreamConfig: React.FunctionComponent<{ {requiredVars.map((varDef) => { const { name: varName, type: varType } = varDef; - const value = packagePolicyInputStream.vars![varName].value; + const { value, frozen } = packagePolicyInputStream.vars![varName]; return ( { updatePackagePolicyInputStream({ vars: { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_var_field.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_var_field.tsx index 15712f9042eb9..7841e8bb62452 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_var_field.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_var_field.tsx @@ -15,6 +15,7 @@ import { EuiComboBox, EuiText, EuiCodeEditor, + EuiTextArea, EuiFieldPassword, } from '@elastic/eui'; @@ -29,7 +30,8 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{ onChange: (newValue: any) => void; errors?: string[] | null; forceShowErrors?: boolean; -}> = memo(({ varDef, value, onChange, errors: varErrors, forceShowErrors }) => { + frozen?: boolean; +}> = memo(({ varDef, value, onChange, errors: varErrors, forceShowErrors, frozen }) => { const [isDirty, setIsDirty] = useState(false); const { multi, required, type, title, name, description } = varDef; const isInvalid = (isDirty || forceShowErrors) && !!varErrors; @@ -50,12 +52,20 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{ onChange(newVals.map((val) => val.label)); }} onBlur={() => setIsDirty(true)} + isDisabled={frozen} /> ); } switch (type) { case 'yaml': - return ( + return frozen ? ( + + ) : ( onChange(e.target.checked)} onBlur={() => setIsDirty(true)} + disabled={frozen} /> ); case 'password': @@ -89,6 +100,7 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{ value={value === undefined ? '' : value} onChange={(e) => onChange(e.target.value)} onBlur={() => setIsDirty(true)} + disabled={frozen} /> ); default: @@ -98,10 +110,11 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{ value={value === undefined ? '' : value} onChange={(e) => onChange(e.target.value)} onBlur={() => setIsDirty(true)} + disabled={frozen} /> ); } - }, [isInvalid, multi, onChange, type, value, fieldLabel]); + }, [isInvalid, multi, onChange, type, value, fieldLabel, frozen]); // Boolean cannot be optional by default set to false const isOptional = useMemo(() => type !== 'bool' && !required, [required, type]); diff --git a/x-pack/plugins/fleet/server/services/package_policy.test.ts b/x-pack/plugins/fleet/server/services/package_policy.test.ts index b3e726bdf7c9e..2516073793a8b 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.test.ts @@ -11,10 +11,10 @@ import { httpServerMock, } from 'src/core/server/mocks'; -import type { SavedObjectsUpdateResponse } from 'src/core/server'; +import type { SavedObjectsClient, SavedObjectsUpdateResponse } from 'src/core/server'; import type { KibanaRequest } from 'kibana/server'; -import type { PackageInfo, PackagePolicySOAttributes } from '../types'; +import type { PackageInfo, PackagePolicySOAttributes, AgentPolicySOAttributes } from '../types'; import { createPackagePolicyMock } from '../../common/mocks'; import type { ExternalCallback } from '..'; @@ -68,6 +68,26 @@ jest.mock('./epm/registry', () => { }; }); +jest.mock('./agent_policy', () => { + return { + agentPolicyService: { + get: async (soClient: SavedObjectsClient, id: string) => { + const agentPolicySO = await soClient.get( + 'ingest-agent-policies', + id + ); + if (!agentPolicySO) { + return null; + } + const agentPolicy = { id: agentPolicySO.id, ...agentPolicySO.attributes }; + agentPolicy.package_policies = []; + return agentPolicy; + }, + bumpRevision: () => {}, + }, + }; +}); + describe('Package policy service', () => { describe('compilePackagePolicyInputs', () => { it('should work with config variables from the stream', async () => { @@ -346,8 +366,8 @@ describe('Package policy service', () => { }); savedObjectsClient.update.mockImplementation( async ( - type: string, - id: string + _type: string, + _id: string ): Promise> => { throw savedObjectsClient.errors.createConflictError('abc', '123'); } @@ -362,6 +382,131 @@ describe('Package policy service', () => { ) ).rejects.toThrow('Saved object [abc/123] conflict'); }); + + it('should only update input vars that are not frozen', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const mockPackagePolicy = createPackagePolicyMock(); + const mockInputs = [ + { + config: {}, + enabled: true, + type: 'endpoint', + vars: { + dog: { + type: 'text', + value: 'dalmatian', + }, + cat: { + type: 'text', + value: 'siamese', + frozen: true, + }, + }, + streams: [ + { + data_stream: { + type: 'birds', + dataset: 'migratory.patterns', + }, + enabled: false, + id: `endpoint-migratory.patterns-${mockPackagePolicy.id}`, + vars: { + paths: { + value: ['north', 'south'], + type: 'text', + frozen: true, + }, + period: { + value: '6mo', + type: 'text', + }, + }, + }, + ], + }, + ]; + const inputsUpdate = [ + { + config: {}, + enabled: true, + type: 'endpoint', + vars: { + dog: { + type: 'text', + value: 'labrador', + }, + cat: { + type: 'text', + value: 'tabby', + }, + }, + streams: [ + { + data_stream: { + type: 'birds', + dataset: 'migratory.patterns', + }, + enabled: false, + id: `endpoint-migratory.patterns-${mockPackagePolicy.id}`, + vars: { + paths: { + value: ['east', 'west'], + type: 'text', + }, + period: { + value: '12mo', + type: 'text', + }, + }, + }, + ], + }, + ]; + const attributes = { + ...mockPackagePolicy, + inputs: mockInputs, + }; + + savedObjectsClient.get.mockResolvedValue({ + id: 'test', + type: 'abcd', + references: [], + version: 'test', + attributes, + }); + + savedObjectsClient.update.mockImplementation( + async ( + type: string, + id: string, + attrs: any + ): Promise> => { + savedObjectsClient.get.mockResolvedValue({ + id: 'test', + type: 'abcd', + references: [], + version: 'test', + attributes: attrs, + }); + return attrs; + } + ); + const elasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + const result = await packagePolicyService.update( + savedObjectsClient, + elasticsearchClient, + 'the-package-policy-id', + { ...mockPackagePolicy, inputs: inputsUpdate } + ); + + const [modifiedInput] = result.inputs; + expect(modifiedInput.vars!.dog.value).toEqual('labrador'); + expect(modifiedInput.vars!.cat.value).toEqual('siamese'); + const [modifiedStream] = modifiedInput.streams; + expect(modifiedStream.vars!.paths.value).toEqual(expect.arrayContaining(['north', 'south'])); + expect(modifiedStream.vars!.period.value).toEqual('12mo'); + }); }); describe('runExternalCallbacks', () => { diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 1d2295a553462..0857338469794 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -23,6 +23,7 @@ import type { DeletePackagePoliciesResponse, PackagePolicyInput, NewPackagePolicyInput, + PackagePolicyConfigRecordEntry, PackagePolicyInputStream, PackageInfo, ListWithKuery, @@ -346,6 +347,8 @@ class PackagePolicyService { assignStreamIdToInput(oldPackagePolicy.id, input) ); + inputs = enforceFrozenInputs(oldPackagePolicy.inputs, inputs); + if (packagePolicy.package?.name) { const pkgInfo = await getPackageInfo({ savedObjectsClient: soClient, @@ -602,6 +605,42 @@ async function _compilePackageStream( return { ...stream }; } +function enforceFrozenInputs(oldInputs: PackagePolicyInput[], newInputs: PackagePolicyInput[]) { + const resultInputs = [...newInputs]; + + for (const input of resultInputs) { + const oldInput = oldInputs.find((i) => i.type === input.type); + if (input.vars && oldInput?.vars) { + input.vars = _enforceFrozenVars(oldInput.vars, input.vars); + } + if (input.streams && oldInput?.streams) { + for (const stream of input.streams) { + const oldStream = oldInput.streams.find((s) => s.id === stream.id); + if (stream.vars && oldStream?.vars) { + stream.vars = _enforceFrozenVars(oldStream.vars, stream.vars); + } + } + } + } + + return resultInputs; +} + +function _enforceFrozenVars( + oldVars: Record, + newVars: Record +) { + const resultVars: Record = {}; + for (const [key, val] of Object.entries(oldVars)) { + if (val.frozen) { + resultVars[key] = val; + } else { + resultVars[key] = newVars[key]; + } + } + return resultVars; +} + export type PackagePolicyServiceInterface = PackagePolicyService; export const packagePolicyService = new PackagePolicyService(); diff --git a/x-pack/plugins/fleet/server/types/models/preconfiguration.ts b/x-pack/plugins/fleet/server/types/models/preconfiguration.ts index 0dc0ae8f1db88..f697e436fcf4a 100644 --- a/x-pack/plugins/fleet/server/types/models/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/types/models/preconfiguration.ts @@ -16,7 +16,8 @@ const varsSchema = schema.maybe( schema.object({ name: schema.string(), type: schema.maybe(schema.string()), - value: schema.oneOf([schema.string(), schema.number()]), + value: schema.maybe(schema.oneOf([schema.string(), schema.number()])), + frozen: schema.maybe(schema.boolean()), }) ) ); From 3ba640403ffe33e9bd8c23bae5890f0ee72f35a1 Mon Sep 17 00:00:00 2001 From: Rashmi Kulkarni Date: Wed, 14 Apr 2021 15:05:12 -0700 Subject: [PATCH 37/43] unskip accessibility - dashboard_edit_panel tests (#96710) * unskip * added render complete * added render complete in couple other places * minor corrections Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- test/functional/services/dashboard/panel_actions.ts | 3 ++- x-pack/test/accessibility/apps/dashboard_edit_panel.ts | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/functional/services/dashboard/panel_actions.ts b/test/functional/services/dashboard/panel_actions.ts index 0101d2b2a1916..89790b19f426a 100644 --- a/test/functional/services/dashboard/panel_actions.ts +++ b/test/functional/services/dashboard/panel_actions.ts @@ -24,7 +24,7 @@ const SAVE_TO_LIBRARY_TEST_SUBJ = 'embeddablePanelAction-saveToLibrary'; export function DashboardPanelActionsProvider({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects(['header', 'common']); + const PageObjects = getPageObjects(['header', 'common', 'dashboard']); const inspector = getService('inspector'); return new (class DashboardPanelActions { @@ -147,6 +147,7 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft await this.openContextMenu(); } await testSubjects.click(CLONE_PANEL_DATA_TEST_SUBJ); + await PageObjects.dashboard.waitForRenderComplete(); } async openCopyToModalByTitle(title?: string) { diff --git a/x-pack/test/accessibility/apps/dashboard_edit_panel.ts b/x-pack/test/accessibility/apps/dashboard_edit_panel.ts index 466eab6b6b336..c318c2d1c26a0 100644 --- a/x-pack/test/accessibility/apps/dashboard_edit_panel.ts +++ b/x-pack/test/accessibility/apps/dashboard_edit_panel.ts @@ -20,8 +20,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PANEL_TITLE = 'Visualization PieChart'; - // FLAKY: https://github.com/elastic/kibana/issues/92114 - describe.skip('Dashboard Edit Panel', () => { + describe('Dashboard Edit Panel', () => { before(async () => { await esArchiver.load('dashboard/drilldowns'); await esArchiver.loadIfNeeded('logstash_functional'); From 2a281c99c6dd98f1f89a44f5492ff3b95f15bb1a Mon Sep 17 00:00:00 2001 From: Constance Date: Wed, 14 Apr 2021 15:11:25 -0700 Subject: [PATCH 38/43] [App Search] Refactor out a shared MultiInputRows component (#96881) * Add new reusable MultiInputRows component - basically the CurationQuery component, but with a generic values var & allows passing in custom text for every string * Update CurationQueries with MultiInputRows * Update MultiInputRows to support on change behavior - for upcoming Relevance Tuning usage * Update Relevance Tuning value boost form to use new component - relevance_tuning_form.test.tsx fix: was getting test errors with mount(), so I switched to shallow() * Change submitOnChange to onChange fn - more flexible - allows for either an onSubmit or onChange, or even potentially both * Convert MultiInputRowsLogic to keyed Kea logic - so that we can have multiple instances on the same page - primarily the value boosts use case * Update LogicMounter helper & tests to handle keyed logic w/ props * [Misc] LogicMounter helper - fix typing, perf - Use Kea's types instead of trying to rewrite my own LogicFile - Add an early return for tests that pass `{}` to values as well for performance * PR feedback: Change values prop to initialValues + bonus - add a fallback for initially empty components + add a test to check that the logic was mounted correctly * PR feedback: Remove useRef/on mount onChange catch for now - We don't currently need the extra catch for any live components, and it's confusing --- .../public/applications/__mocks__/kea.mock.ts | 44 +++--- .../curation_queries.test.tsx | 102 -------------- .../curation_queries/curation_queries.tsx | 72 ---------- .../curation_queries_logic.test.ts | 94 ------------- .../curation_queries_logic.ts | 53 ------- .../components/curation_queries/utils.test.ts | 15 -- .../components/curations/components/index.ts | 8 -- .../components/curations/constants.ts | 9 ++ .../queries/manage_queries_modal.test.tsx | 12 +- .../curation/queries/manage_queries_modal.tsx | 10 +- .../views/curation_creation.test.tsx | 8 +- .../curations/views/curation_creation.tsx | 15 +- .../components/multi_input_rows/constants.ts | 23 +++ .../index.ts | 2 +- .../input_row.scss} | 2 +- .../input_row.test.tsx} | 30 ++-- .../input_row.tsx} | 30 ++-- .../multi_input_rows.test.tsx | 133 ++++++++++++++++++ .../multi_input_rows/multi_input_rows.tsx | 93 ++++++++++++ .../multi_input_rows_logic.test.ts | 102 ++++++++++++++ .../multi_input_rows_logic.ts | 59 ++++++++ .../components/multi_input_rows/utils.test.ts | 15 ++ .../utils.ts | 4 +- .../value_boost_form.test.tsx | 43 ++---- .../boost_item_content/value_boost_form.tsx | 61 ++------ .../relevance_tuning_form.test.tsx | 12 +- .../relevance_tuning_logic.test.ts | 127 +---------------- .../relevance_tuning_logic.ts | 64 +-------- 28 files changed, 559 insertions(+), 683 deletions(-) delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.test.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.test.ts delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.ts delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.test.ts delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/constants.ts rename x-pack/plugins/enterprise_search/public/applications/app_search/components/{curations/components/curation_queries => multi_input_rows}/index.ts (82%) rename x-pack/plugins/enterprise_search/public/applications/app_search/components/{curations/components/curation_queries/curation_queries.scss => multi_input_rows/input_row.scss} (60%) rename x-pack/plugins/enterprise_search/public/applications/app_search/components/{curations/components/curation_queries/curation_query.test.tsx => multi_input_rows/input_row.test.tsx} (55%) rename x-pack/plugins/enterprise_search/public/applications/app_search/components/{curations/components/curation_queries/curation_query.tsx => multi_input_rows/input_row.tsx} (59%) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/multi_input_rows.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/multi_input_rows.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/multi_input_rows_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/multi_input_rows_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/utils.test.ts rename x-pack/plugins/enterprise_search/public/applications/app_search/components/{curations/components/curation_queries => multi_input_rows}/utils.ts (70%) diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea.mock.ts index 2579d0b728c15..4ebb9edd20c0e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea.mock.ts @@ -84,13 +84,10 @@ export const setMockActions = (actions: object) => { * unmount(); * }); */ -import { resetContext, Logic, LogicInput } from 'kea'; +import { resetContext, LogicWrapper } from 'kea'; + +type LogicFile = LogicWrapper; -interface LogicFile { - inputs: Array>; - build(props?: object): void; - mount(): Function; -} export class LogicMounter { private logicFile: LogicFile; private unmountFn!: Function; @@ -100,24 +97,39 @@ export class LogicMounter { } // Reset context with optional default value overrides - public resetContext = (values?: object) => { - if (!values) { + public resetContext = (values?: object, props?: object) => { + if (!values || !Object.keys(values).length) { resetContext({}); } else { - const path = this.logicFile.inputs[0].path as string[]; // example: ['x', 'y', 'z'] - const defaults = path.reduceRight((value: object, key: string) => ({ [key]: value }), values); // example: { x: { y: { z: values } } } + let { path, key } = this.logicFile.inputs[0]; + + // For keyed logic files, both key and path should be functions + if (this.logicFile._isKeaWithKey) { + key = key(props); + path = path(key); + } + + // Generate the correct nested defaults obj based on the file path + // example path: ['x', 'y', 'z'] + // example defaults: { x: { y: { z: values } } } + const defaults = path.reduceRight( + (value: object, name: string) => ({ [name]: value }), + values + ); resetContext({ defaults }); } }; // Automatically reset context & mount the logic file public mount = (values?: object, props?: object) => { - this.resetContext(values); - if (props) this.logicFile.build(props); + this.resetContext(values, props); + + const logicWithProps = this.logicFile.build(props); + this.unmountFn = logicWithProps.mount(); - const unmount = this.logicFile.mount(); - this.unmountFn = unmount; - return unmount; // Keep Kea behavior of returning an unmount fn from mount + return logicWithProps; + // NOTE: Unlike kea's mount(), this returns the current + // built logic instance with props, NOT the unmount fn }; // Also add unmount as a class method that can be destructured on init without becoming stale later @@ -146,7 +158,7 @@ export class LogicMounter { const { listeners } = this.logicFile.inputs[0]; return typeof listeners === 'function' - ? (listeners as Function)(listenersArgs) // e.g., listeners({ values, actions, props }) => ({ ... }) + ? listeners(listenersArgs) // e.g., listeners({ values, actions, props }) => ({ ... }) : listeners; // handles simpler logic files that just define listeners: { ... } }; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.test.tsx deleted file mode 100644 index e55b944f7bebc..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.test.tsx +++ /dev/null @@ -1,102 +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 { setMockActions, setMockValues } from '../../../../../__mocks__'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { CurationQuery } from './curation_query'; - -import { CurationQueries } from './'; - -describe('CurationQueries', () => { - const props = { - queries: ['a', 'b', 'c'], - onSubmit: jest.fn(), - }; - const values = { - queries: ['a', 'b', 'c'], - hasEmptyQueries: false, - hasOnlyOneQuery: false, - }; - const actions = { - addQuery: jest.fn(), - editQuery: jest.fn(), - deleteQuery: jest.fn(), - }; - - beforeEach(() => { - jest.clearAllMocks(); - setMockValues(values); - setMockActions(actions); - }); - - it('renders a CurationQuery row for each query', () => { - const wrapper = shallow(); - - expect(wrapper.find(CurationQuery)).toHaveLength(3); - expect(wrapper.find(CurationQuery).at(0).prop('queryValue')).toEqual('a'); - expect(wrapper.find(CurationQuery).at(1).prop('queryValue')).toEqual('b'); - expect(wrapper.find(CurationQuery).at(2).prop('queryValue')).toEqual('c'); - }); - - it('calls editQuery when the CurationQuery value changes', () => { - const wrapper = shallow(); - wrapper.find(CurationQuery).at(0).simulate('change', 'new query value'); - - expect(actions.editQuery).toHaveBeenCalledWith(0, 'new query value'); - }); - - it('calls deleteQuery when the CurationQuery calls onDelete', () => { - const wrapper = shallow(); - wrapper.find(CurationQuery).at(2).simulate('delete'); - - expect(actions.deleteQuery).toHaveBeenCalledWith(2); - }); - - it('calls addQuery when the Add Query button is clicked', () => { - const wrapper = shallow(); - wrapper.find('[data-test-subj="addCurationQueryButton"]').simulate('click'); - - expect(actions.addQuery).toHaveBeenCalled(); - }); - - it('disables the add button if any query fields are empty', () => { - setMockValues({ - ...values, - queries: ['a', '', 'c'], - hasEmptyQueries: true, - }); - const wrapper = shallow(); - const button = wrapper.find('[data-test-subj="addCurationQueryButton"]'); - - expect(button.prop('isDisabled')).toEqual(true); - }); - - it('calls the passed onSubmit callback when the submit button is clicked', () => { - setMockValues({ ...values, queries: ['some query'] }); - const wrapper = shallow(); - wrapper.find('[data-test-subj="submitCurationQueriesButton"]').simulate('click'); - - expect(props.onSubmit).toHaveBeenCalledWith(['some query']); - }); - - it('disables the submit button if no query fields have been filled', () => { - setMockValues({ - ...values, - queries: [''], - hasOnlyOneQuery: true, - hasEmptyQueries: true, - }); - const wrapper = shallow(); - const button = wrapper.find('[data-test-subj="submitCurationQueriesButton"]'); - - expect(button.prop('isDisabled')).toEqual(true); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.tsx deleted file mode 100644 index bd9ba592e7224..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.tsx +++ /dev/null @@ -1,72 +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 React from 'react'; - -import { useValues, useActions } from 'kea'; - -import { EuiButton, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -import { CONTINUE_BUTTON_LABEL } from '../../../../../shared/constants'; - -import { Curation } from '../../types'; - -import { CurationQueriesLogic } from './curation_queries_logic'; -import { CurationQuery } from './curation_query'; -import { filterEmptyQueries } from './utils'; -import './curation_queries.scss'; - -interface Props { - queries: Curation['queries']; - onSubmit(queries: Curation['queries']): void; - submitButtonText?: string; -} - -export const CurationQueries: React.FC = ({ - queries: initialQueries, - onSubmit, - submitButtonText = CONTINUE_BUTTON_LABEL, -}) => { - const logic = CurationQueriesLogic({ queries: initialQueries }); - const { queries, hasEmptyQueries, hasOnlyOneQuery } = useValues(logic); - const { addQuery, editQuery, deleteQuery } = useActions(logic); - - return ( - <> - {queries.map((query: string, index) => ( - editQuery(index, newValue)} - onDelete={() => deleteQuery(index)} - disableDelete={hasOnlyOneQuery} - /> - ))} - - {i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.addQueryButtonLabel', { - defaultMessage: 'Add query', - })} - - - onSubmit(filterEmptyQueries(queries))} - data-test-subj="submitCurationQueriesButton" - > - {submitButtonText} - - - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.test.ts deleted file mode 100644 index 766ab78b283be..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.test.ts +++ /dev/null @@ -1,94 +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 { LogicMounter } from '../../../../../__mocks__'; - -import { CurationQueriesLogic } from './curation_queries_logic'; - -describe('CurationQueriesLogic', () => { - const { mount } = new LogicMounter(CurationQueriesLogic); - - const MOCK_QUERIES = ['a', 'b', 'c']; - - const DEFAULT_PROPS = { queries: MOCK_QUERIES }; - const DEFAULT_VALUES = { - queries: MOCK_QUERIES, - hasEmptyQueries: false, - hasOnlyOneQuery: false, - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('has expected default values passed from props', () => { - mount({}, DEFAULT_PROPS); - expect(CurationQueriesLogic.values).toEqual(DEFAULT_VALUES); - }); - - describe('actions', () => { - afterEach(() => { - // Should not mutate the original array - expect(CurationQueriesLogic.values.queries).not.toBe(MOCK_QUERIES); // Would fail if we did not clone a new array - }); - - describe('addQuery', () => { - it('appends an empty string to the queries array', () => { - mount(DEFAULT_VALUES); - CurationQueriesLogic.actions.addQuery(); - - expect(CurationQueriesLogic.values).toEqual({ - ...DEFAULT_VALUES, - hasEmptyQueries: true, - queries: ['a', 'b', 'c', ''], - }); - }); - }); - - describe('deleteQuery', () => { - it('deletes the query string at the specified array index', () => { - mount(DEFAULT_VALUES); - CurationQueriesLogic.actions.deleteQuery(1); - - expect(CurationQueriesLogic.values).toEqual({ - ...DEFAULT_VALUES, - queries: ['a', 'c'], - }); - }); - }); - - describe('editQuery', () => { - it('edits the query string at the specified array index', () => { - mount(DEFAULT_VALUES); - CurationQueriesLogic.actions.editQuery(2, 'z'); - - expect(CurationQueriesLogic.values).toEqual({ - ...DEFAULT_VALUES, - queries: ['a', 'b', 'z'], - }); - }); - }); - }); - - describe('selectors', () => { - describe('hasEmptyQueries', () => { - it('returns true if queries has any empty strings', () => { - mount({}, { queries: ['', '', ''] }); - - expect(CurationQueriesLogic.values.hasEmptyQueries).toEqual(true); - }); - }); - - describe('hasOnlyOneQuery', () => { - it('returns true if queries only has one item', () => { - mount({}, { queries: ['test'] }); - - expect(CurationQueriesLogic.values.hasOnlyOneQuery).toEqual(true); - }); - }); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.ts deleted file mode 100644 index 98109657d61a3..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.ts +++ /dev/null @@ -1,53 +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 { kea, MakeLogicType } from 'kea'; - -interface CurationQueriesValues { - queries: string[]; - hasEmptyQueries: boolean; - hasOnlyOneQuery: boolean; -} - -interface CurationQueriesActions { - addQuery(): void; - deleteQuery(indexToDelete: number): { indexToDelete: number }; - editQuery(index: number, newQueryValue: string): { index: number; newQueryValue: string }; -} - -export const CurationQueriesLogic = kea< - MakeLogicType ->({ - path: ['enterprise_search', 'app_search', 'curation_queries_logic'], - actions: () => ({ - addQuery: true, - deleteQuery: (indexToDelete) => ({ indexToDelete }), - editQuery: (index, newQueryValue) => ({ index, newQueryValue }), - }), - reducers: ({ props }) => ({ - queries: [ - props.queries, - { - addQuery: (state) => [...state, ''], - deleteQuery: (state, { indexToDelete }) => { - const newState = [...state]; - newState.splice(indexToDelete, 1); - return newState; - }, - editQuery: (state, { index, newQueryValue }) => { - const newState = [...state]; - newState[index] = newQueryValue; - return newState; - }, - }, - ], - }), - selectors: { - hasEmptyQueries: [(selectors) => [selectors.queries], (queries) => queries.indexOf('') >= 0], - hasOnlyOneQuery: [(selectors) => [selectors.queries], (queries) => queries.length <= 1], - }, -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.test.ts deleted file mode 100644 index d84649f090691..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.test.ts +++ /dev/null @@ -1,15 +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 { filterEmptyQueries } from './utils'; - -describe('filterEmptyQueries', () => { - it('filters out all empty strings from a queries array', () => { - const queries = ['', 'a', '', 'b', '', 'c', '']; - expect(filterEmptyQueries(queries)).toEqual(['a', 'b', 'c']); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/index.ts deleted file mode 100644 index 4f9136d15d6c3..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { CurationQueries } from './curation_queries'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts index 99f3d340f8430..37c1e9a7a1a2e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts @@ -25,6 +25,15 @@ export const MANAGE_CURATION_TITLE = i18n.translate( { defaultMessage: 'Manage curation' } ); +export const QUERY_INPUTS_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.addQueryButtonLabel', + { defaultMessage: 'Add query' } +); +export const QUERY_INPUTS_PLACEHOLDER = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.queryPlaceholder', + { defaultMessage: 'Enter a query' } +); + export const DELETE_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.curations.deleteConfirmation', { defaultMessage: 'Are you sure you want to remove this curation?' } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/queries/manage_queries_modal.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/queries/manage_queries_modal.test.tsx index 3555a9333a789..7fe992cdd96e2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/queries/manage_queries_modal.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/queries/manage_queries_modal.test.tsx @@ -13,7 +13,7 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { EuiButton, EuiModal } from '@elastic/eui'; -import { CurationQueries } from '../../components'; +import { MultiInputRows } from '../../../multi_input_rows'; import { ManageQueriesModal } from './'; @@ -66,13 +66,13 @@ describe('ManageQueriesModal', () => { expect(wrapper.find(EuiModal)).toHaveLength(0); }); - it('renders the CurationQueries form component', () => { - expect(wrapper.find(CurationQueries)).toHaveLength(1); - expect(wrapper.find(CurationQueries).prop('queries')).toEqual(['hello', 'world']); + it('renders the MultiInputRows component with curation queries', () => { + expect(wrapper.find(MultiInputRows)).toHaveLength(1); + expect(wrapper.find(MultiInputRows).prop('initialValues')).toEqual(['hello', 'world']); }); - it('calls updateCuration and closes the modal on CurationQueries form submit', () => { - wrapper.find(CurationQueries).simulate('submit', ['new', 'queries']); + it('calls updateCuration and closes the modal on MultiInputRows form submit', () => { + wrapper.find(MultiInputRows).simulate('submit', ['new', 'queries']); expect(actions.updateQueries).toHaveBeenCalledWith(['new', 'queries']); expect(wrapper.find(EuiModal)).toHaveLength(0); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/queries/manage_queries_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/queries/manage_queries_modal.tsx index 471fab8413b38..5ab349115a265 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/queries/manage_queries_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/queries/manage_queries_modal.tsx @@ -21,8 +21,9 @@ import { import { i18n } from '@kbn/i18n'; import { SAVE_BUTTON_LABEL } from '../../../../../shared/constants'; +import { MultiInputRows } from '../../../multi_input_rows'; -import { CurationQueries } from '../../components'; +import { QUERY_INPUTS_BUTTON, QUERY_INPUTS_PLACEHOLDER } from '../../constants'; import { CurationLogic } from '../curation_logic'; export const ManageQueriesModal: React.FC = () => { @@ -61,8 +62,11 @@ export const ManageQueriesModal: React.FC = () => {

- { updateQueries(newQueries); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.test.tsx index e6ddbb9c1b7a9..258d0ec6231fc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { CurationQueries } from '../components'; +import { MultiInputRows } from '../../multi_input_rows'; import { CurationCreation } from './curation_creation'; @@ -28,12 +28,12 @@ describe('CurationCreation', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(CurationQueries)).toHaveLength(1); + expect(wrapper.find(MultiInputRows)).toHaveLength(1); }); - it('calls createCuration on CurationQueries submit', () => { + it('calls createCuration on submit', () => { const wrapper = shallow(); - wrapper.find(CurationQueries).simulate('submit', ['some query']); + wrapper.find(MultiInputRows).simulate('submit', ['some query']); expect(actions.createCuration).toHaveBeenCalledWith(['some query']); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.tsx index 10f1fc093e60f..32d46775a2125 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.tsx @@ -13,9 +13,13 @@ import { EuiPageHeader, EuiPageContent, EuiTitle, EuiText, EuiSpacer } from '@el import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../../shared/flash_messages'; +import { MultiInputRows } from '../../multi_input_rows'; -import { CurationQueries } from '../components'; -import { CREATE_NEW_CURATION_TITLE } from '../constants'; +import { + CREATE_NEW_CURATION_TITLE, + QUERY_INPUTS_BUTTON, + QUERY_INPUTS_PLACEHOLDER, +} from '../constants'; import { CurationsLogic } from '../index'; export const CurationCreation: React.FC = () => { @@ -46,7 +50,12 @@ export const CurationCreation: React.FC = () => {

- createCuration(queries)} /> + createCuration(queries)} + /> ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/constants.ts new file mode 100644 index 0000000000000..f0c077c5bfaf2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/constants.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ADD_VALUE_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.multiInputRows.addValueButtonLabel', + { defaultMessage: 'Add value' } +); + +export const DELETE_VALUE_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.multiInputRows.removeValueButtonLabel', + { defaultMessage: 'Remove value' } +); + +export const INPUT_ROW_PLACEHOLDER = i18n.translate( + 'xpack.enterpriseSearch.appSearch.multiInputRows.inputRowPlaceholder', + { defaultMessage: 'Enter a value' } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/index.ts similarity index 82% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/index.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/index.ts index 4f9136d15d6c3..553bf23f21d30 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { CurationQueries } from './curation_queries'; +export { MultiInputRows } from './multi_input_rows'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/input_row.scss similarity index 60% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.scss rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/input_row.scss index c242cf29fd37d..8c256c66a8dbf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/input_row.scss @@ -1,3 +1,3 @@ -.curationQueryRow { +.inputRow { margin-bottom: $euiSizeXS; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/input_row.test.tsx similarity index 55% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/input_row.test.tsx index 64fbec59382a0..03b0c0e4a0d91 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/input_row.test.tsx @@ -11,14 +11,16 @@ import { shallow } from 'enzyme'; import { EuiFieldText } from '@elastic/eui'; -import { CurationQuery } from './curation_query'; +import { InputRow } from './input_row'; -describe('CurationQuery', () => { +describe('InputRow', () => { const props = { - queryValue: 'some query', + value: 'some value', + placeholder: 'Enter a value', onChange: jest.fn(), onDelete: jest.fn(), disableDelete: false, + deleteLabel: 'Delete value', }; beforeEach(() => { @@ -26,29 +28,33 @@ describe('CurationQuery', () => { }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiFieldText)).toHaveLength(1); - expect(wrapper.find(EuiFieldText).prop('value')).toEqual('some query'); + expect(wrapper.find(EuiFieldText).prop('value')).toEqual('some value'); + expect(wrapper.find(EuiFieldText).prop('placeholder')).toEqual('Enter a value'); + expect(wrapper.find('[data-test-subj="deleteInputRowButton"]').prop('title')).toEqual( + 'Delete value' + ); }); it('calls onChange when the input value changes', () => { - const wrapper = shallow(); - wrapper.find(EuiFieldText).simulate('change', { target: { value: 'new query value' } }); + const wrapper = shallow(); + wrapper.find(EuiFieldText).simulate('change', { target: { value: 'new value' } }); - expect(props.onChange).toHaveBeenCalledWith('new query value'); + expect(props.onChange).toHaveBeenCalledWith('new value'); }); it('calls onDelete when the delete button is clicked', () => { - const wrapper = shallow(); - wrapper.find('[data-test-subj="deleteCurationQueryButton"]').simulate('click'); + const wrapper = shallow(); + wrapper.find('[data-test-subj="deleteInputRowButton"]').simulate('click'); expect(props.onDelete).toHaveBeenCalled(); }); it('disables the delete button if disableDelete is passed', () => { - const wrapper = shallow(); - const button = wrapper.find('[data-test-subj="deleteCurationQueryButton"]'); + const wrapper = shallow(); + const button = wrapper.find('[data-test-subj="deleteInputRowButton"]'); expect(button.prop('isDisabled')).toEqual(true); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/input_row.tsx similarity index 59% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/input_row.tsx index 3ec1f9b8bf3b6..5f2a82ae945ed 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/input_row.tsx @@ -8,33 +8,34 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiFieldText, EuiButtonIcon } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -import { DELETE_BUTTON_LABEL } from '../../../../../shared/constants'; interface Props { - queryValue: string; + value: string; + placeholder: string; onChange(newValue: string): void; onDelete(): void; disableDelete: boolean; + deleteLabel: string; } -export const CurationQuery: React.FC = ({ - queryValue, +import './input_row.scss'; + +export const InputRow: React.FC = ({ + value, + placeholder, onChange, onDelete, disableDelete, + deleteLabel, }) => ( - + onChange(e.target.value)} + autoFocus /> @@ -43,8 +44,9 @@ export const CurationQuery: React.FC = ({ color="danger" onClick={onDelete} isDisabled={disableDelete} - aria-label={DELETE_BUTTON_LABEL} - data-test-subj="deleteCurationQueryButton" + aria-label={deleteLabel} + title={deleteLabel} + data-test-subj="deleteInputRowButton" /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/multi_input_rows.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/multi_input_rows.test.tsx new file mode 100644 index 0000000000000..f832ceb8c8842 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/multi_input_rows.test.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockActions, setMockValues, rerender } from '../../../__mocks__'; +import '../../../__mocks__/shallow_useeffect.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { InputRow } from './input_row'; + +jest.mock('./multi_input_rows_logic', () => ({ + MultiInputRowsLogic: jest.fn(), +})); +import { MultiInputRowsLogic } from './multi_input_rows_logic'; + +import { MultiInputRows } from './'; + +describe('MultiInputRows', () => { + const props = { + id: 'test', + }; + const values = { + values: ['a', 'b', 'c'], + hasEmptyValues: false, + hasOnlyOneValue: false, + }; + const actions = { + addValue: jest.fn(), + editValue: jest.fn(), + deleteValue: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + + it('initializes MultiInputRowsLogic with a keyed ID and initialValues', () => { + shallow(); + expect(MultiInputRowsLogic).toHaveBeenCalledWith({ id: 'lorem', values: ['ipsum'] }); + }); + + it('renders a InputRow row for each value', () => { + const wrapper = shallow(); + + expect(wrapper.find(InputRow)).toHaveLength(3); + expect(wrapper.find(InputRow).at(0).prop('value')).toEqual('a'); + expect(wrapper.find(InputRow).at(1).prop('value')).toEqual('b'); + expect(wrapper.find(InputRow).at(2).prop('value')).toEqual('c'); + }); + + it('calls editValue when the InputRow value changes', () => { + const wrapper = shallow(); + wrapper.find(InputRow).at(0).simulate('change', 'new value'); + + expect(actions.editValue).toHaveBeenCalledWith(0, 'new value'); + }); + + it('calls deleteValue when the InputRow calls onDelete', () => { + const wrapper = shallow(); + wrapper.find(InputRow).at(2).simulate('delete'); + + expect(actions.deleteValue).toHaveBeenCalledWith(2); + }); + + it('calls addValue when the Add Value button is clicked', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="addInputRowButton"]').simulate('click'); + + expect(actions.addValue).toHaveBeenCalled(); + }); + + it('disables the add button if any value fields are empty', () => { + setMockValues({ + ...values, + values: ['a', '', 'c'], + hasEmptyValues: true, + }); + const wrapper = shallow(); + const button = wrapper.find('[data-test-subj="addInputRowButton"]'); + + expect(button.prop('isDisabled')).toEqual(true); + }); + + describe('onSubmit', () => { + const onSubmit = jest.fn(); + + it('does not render the submit button if onSubmit is not passed', () => { + const wrapper = shallow(); + expect(wrapper.find('[data-test-subj="submitInputValuesButton"]').exists()).toBe(false); + }); + + it('calls the passed onSubmit callback when the submit button is clicked', () => { + setMockValues({ ...values, values: ['some value'] }); + const wrapper = shallow(); + wrapper.find('[data-test-subj="submitInputValuesButton"]').simulate('click'); + + expect(onSubmit).toHaveBeenCalledWith(['some value']); + }); + + it('disables the submit button if no value fields have been filled', () => { + setMockValues({ + ...values, + values: [''], + hasOnlyOneValue: true, + hasEmptyValues: true, + }); + const wrapper = shallow(); + const button = wrapper.find('[data-test-subj="submitInputValuesButton"]'); + + expect(button.prop('isDisabled')).toEqual(true); + }); + }); + + describe('onChange', () => { + const onChange = jest.fn(); + + it('returns the current values dynamically on change', () => { + const wrapper = shallow(); + setMockValues({ ...values, values: ['updated'] }); + rerender(wrapper); + + expect(onChange).toHaveBeenCalledWith(['updated']); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/multi_input_rows.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/multi_input_rows.tsx new file mode 100644 index 0000000000000..aa2f0977594c4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/multi_input_rows.tsx @@ -0,0 +1,93 @@ +/* + * 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 { useValues, useActions } from 'kea'; + +import { EuiButton, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; + +import { CONTINUE_BUTTON_LABEL } from '../../../shared/constants'; + +import { + ADD_VALUE_BUTTON_LABEL, + DELETE_VALUE_BUTTON_LABEL, + INPUT_ROW_PLACEHOLDER, +} from './constants'; +import { InputRow } from './input_row'; +import { MultiInputRowsLogic } from './multi_input_rows_logic'; +import { filterEmptyValues } from './utils'; + +interface Props { + id: string; + initialValues?: string[]; + onSubmit?(values: string[]): void; + onChange?(values: string[]): void; + submitButtonText?: string; + addRowText?: string; + deleteRowLabel?: string; + inputPlaceholder?: string; +} + +export const MultiInputRows: React.FC = ({ + id, + initialValues = [''], + onSubmit, + onChange, + submitButtonText = CONTINUE_BUTTON_LABEL, + addRowText = ADD_VALUE_BUTTON_LABEL, + deleteRowLabel = DELETE_VALUE_BUTTON_LABEL, + inputPlaceholder = INPUT_ROW_PLACEHOLDER, +}) => { + const logic = MultiInputRowsLogic({ id, values: initialValues }); + const { values, hasEmptyValues, hasOnlyOneValue } = useValues(logic); + const { addValue, editValue, deleteValue } = useActions(logic); + + useEffect(() => { + if (onChange) { + onChange(filterEmptyValues(values)); + } + }, [values]); + + return ( + <> + {values.map((value: string, index: number) => ( + editValue(index, newValue)} + onDelete={() => deleteValue(index)} + disableDelete={hasOnlyOneValue} + deleteLabel={deleteRowLabel} + /> + ))} + + {addRowText} + + {onSubmit && ( + <> + + onSubmit(filterEmptyValues(values))} + data-test-subj="submitInputValuesButton" + > + {submitButtonText} + + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/multi_input_rows_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/multi_input_rows_logic.test.ts new file mode 100644 index 0000000000000..b84db6775820c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/multi_input_rows_logic.test.ts @@ -0,0 +1,102 @@ +/* + * 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 { LogicMounter } from '../../../__mocks__'; + +import { Logic } from 'kea'; + +import { MultiInputRowsLogic } from './multi_input_rows_logic'; + +describe('MultiInputRowsLogic', () => { + const { mount } = new LogicMounter(MultiInputRowsLogic); + + const MOCK_VALUES = ['a', 'b', 'c']; + + const DEFAULT_PROPS = { + id: 'test', + values: MOCK_VALUES, + }; + const DEFAULT_VALUES = { + values: MOCK_VALUES, + hasEmptyValues: false, + hasOnlyOneValue: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values passed from props', () => { + const logic = mount({}, DEFAULT_PROPS); + expect(logic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + let logic: Logic; + + beforeEach(() => { + logic = mount({}, DEFAULT_PROPS); + }); + + afterEach(() => { + // Should not mutate the original array + expect(logic.values.values).not.toBe(MOCK_VALUES); // Would fail if we did not clone a new array + }); + + describe('addValue', () => { + it('appends an empty string to the values array', () => { + logic.actions.addValue(); + + expect(logic.values).toEqual({ + ...DEFAULT_VALUES, + hasEmptyValues: true, + values: ['a', 'b', 'c', ''], + }); + }); + }); + + describe('deleteValue', () => { + it('deletes the value at the specified array index', () => { + logic.actions.deleteValue(1); + + expect(logic.values).toEqual({ + ...DEFAULT_VALUES, + values: ['a', 'c'], + }); + }); + }); + + describe('editValue', () => { + it('edits the value at the specified array index', () => { + logic.actions.editValue(2, 'z'); + + expect(logic.values).toEqual({ + ...DEFAULT_VALUES, + values: ['a', 'b', 'z'], + }); + }); + }); + }); + + describe('selectors', () => { + describe('hasEmptyValues', () => { + it('returns true if values has any empty strings', () => { + const logic = mount({}, { ...DEFAULT_PROPS, values: ['', '', ''] }); + + expect(logic.values.hasEmptyValues).toEqual(true); + }); + }); + + describe('hasOnlyOneValue', () => { + it('returns true if values only has one item', () => { + const logic = mount({}, { ...DEFAULT_PROPS, values: ['test'] }); + + expect(logic.values.hasOnlyOneValue).toEqual(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/multi_input_rows_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/multi_input_rows_logic.ts new file mode 100644 index 0000000000000..6cc392598a61f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/multi_input_rows_logic.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +interface MultiInputRowsValues { + values: string[]; + hasEmptyValues: boolean; + hasOnlyOneValue: boolean; +} + +interface MultiInputRowsActions { + addValue(): void; + deleteValue(indexToDelete: number): { indexToDelete: number }; + editValue(index: number, newValueValue: string): { index: number; newValueValue: string }; +} + +interface MultiInputRowsProps { + values: string[]; + id: string; +} + +export const MultiInputRowsLogic = kea< + MakeLogicType +>({ + path: (key: string) => ['enterprise_search', 'app_search', 'multi_input_rows_logic', key], + key: (props) => props.id, + actions: () => ({ + addValue: true, + deleteValue: (indexToDelete) => ({ indexToDelete }), + editValue: (index, newValueValue) => ({ index, newValueValue }), + }), + reducers: ({ props }) => ({ + values: [ + props.values, + { + addValue: (state) => [...state, ''], + deleteValue: (state, { indexToDelete }) => { + const newState = [...state]; + newState.splice(indexToDelete, 1); + return newState; + }, + editValue: (state, { index, newValueValue }) => { + const newState = [...state]; + newState[index] = newValueValue; + return newState; + }, + }, + ], + }), + selectors: { + hasEmptyValues: [(selectors) => [selectors.values], (values) => values.indexOf('') >= 0], + hasOnlyOneValue: [(selectors) => [selectors.values], (values) => values.length <= 1], + }, +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/utils.test.ts new file mode 100644 index 0000000000000..0946890c40dfa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/utils.test.ts @@ -0,0 +1,15 @@ +/* + * 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 { filterEmptyValues } from './utils'; + +describe('filterEmptyValues', () => { + it('filters out all empty strings from the array', () => { + const values = ['', 'a', '', 'b', '', 'c', '']; + expect(filterEmptyValues(values)).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/utils.ts similarity index 70% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/utils.ts index 505e9641d778e..5ecefb240e17d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/multi_input_rows/utils.ts @@ -5,6 +5,6 @@ * 2.0. */ -export const filterEmptyQueries = (queries: string[]) => { - return queries.filter((query) => query.length); +export const filterEmptyValues = (values: string[]) => { + return values.filter((value) => value.length); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/value_boost_form.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/value_boost_form.test.tsx index 6fbf90e6a2000..6f9284891e711 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/value_boost_form.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/value_boost_form.test.tsx @@ -9,9 +9,9 @@ import { setMockActions } from '../../../../../__mocks__/kea.mock'; import React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { shallow } from 'enzyme'; -import { EuiButton, EuiButtonIcon, EuiFieldText } from '@elastic/eui'; +import { MultiInputRows } from '../../../multi_input_rows'; import { ValueBoost, BoostType } from '../../types'; @@ -23,13 +23,11 @@ describe('ValueBoostForm', () => { function: undefined, factor: 2, type: 'value' as BoostType, - value: ['bar', '', 'baz'], + value: [], }; const actions = { - removeBoostValue: jest.fn(), updateBoostValue: jest.fn(), - addBoostValue: jest.fn(), }; beforeEach(() => { @@ -37,40 +35,15 @@ describe('ValueBoostForm', () => { setMockActions(actions); }); - const valueInput = (wrapper: ShallowWrapper, index: number) => - wrapper.find(EuiFieldText).at(index); - const removeButton = (wrapper: ShallowWrapper, index: number) => - wrapper.find(EuiButtonIcon).at(index); - const addButton = (wrapper: ShallowWrapper) => wrapper.find(EuiButton); - - it('renders a text input for each value from the boost', () => { - const wrapper = shallow(); - expect(valueInput(wrapper, 0).prop('value')).toEqual('bar'); - expect(valueInput(wrapper, 1).prop('value')).toEqual(''); - expect(valueInput(wrapper, 2).prop('value')).toEqual('baz'); - }); - - it('updates the corresponding value in state whenever a user changes the value in a text input', () => { - const wrapper = shallow(); - - valueInput(wrapper, 2).simulate('change', { target: { value: 'new value' } }); - - expect(actions.updateBoostValue).toHaveBeenCalledWith('foo', 3, 2, 'new value'); - }); - - it('deletes a boost value when the Remove Value button is clicked', () => { + it('renders', () => { const wrapper = shallow(); - - removeButton(wrapper, 2).simulate('click'); - - expect(actions.removeBoostValue).toHaveBeenCalledWith('foo', 3, 2); + expect(wrapper.find(MultiInputRows).exists()).toBe(true); }); - it('adds a new boost value when the Add Value is button clicked', () => { + it('updates the boost value whenever the MultiInputRows form component updates', () => { const wrapper = shallow(); + wrapper.find(MultiInputRows).simulate('change', ['bar', 'baz']); - addButton(wrapper).simulate('click'); - - expect(actions.addBoostValue).toHaveBeenCalledWith('foo', 3); + expect(actions.updateBoostValue).toHaveBeenCalledWith('foo', 3, ['bar', 'baz']); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/value_boost_form.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/value_boost_form.tsx index 48d9749029a7e..4f6c1c4248fe6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/value_boost_form.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/value_boost_form.tsx @@ -9,17 +9,9 @@ import React from 'react'; import { useActions } from 'kea'; -import { - EuiButton, - EuiButtonIcon, - EuiFieldText, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { MultiInputRows } from '../../../multi_input_rows'; -import { RelevanceTuningLogic } from '../..'; +import { RelevanceTuningLogic } from '../../index'; import { ValueBoost } from '../../types'; interface Props { @@ -29,51 +21,14 @@ interface Props { } export const ValueBoostForm: React.FC = ({ boost, index, name }) => { - const { updateBoostValue, removeBoostValue, addBoostValue } = useActions(RelevanceTuningLogic); + const { updateBoostValue } = useActions(RelevanceTuningLogic); const values = boost.value; return ( - <> - {values.map((value, valueIndex) => ( - - - updateBoostValue(name, index, valueIndex, e.target.value)} - aria-label={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.boosts.value.valueNameAriaLabel', - { - defaultMessage: 'Value name', - } - )} - autoFocus - /> - - - removeBoostValue(name, index, valueIndex)} - aria-label={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.boosts.value.removeValueAriaLabel', - { - defaultMessage: 'Remove value', - } - )} - /> - - - ))} - - addBoostValue(name, index)}> - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.boosts.value.addValueButtonLabel', - { - defaultMessage: 'Add value', - } - )} - - + updateBoostValue(name, index, updatedValues)} + id={`${name}BoostValue-${index}`} + /> ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_form.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_form.test.tsx index 68d1b7439be5c..a1a241b8856a5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_form.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_form.test.tsx @@ -9,14 +9,14 @@ import { setMockValues, setMockActions } from '../../../../__mocks__/kea.mock'; import React from 'react'; -import { shallow, mount, ReactWrapper, ShallowWrapper } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; import { EuiFieldSearch } from '@elastic/eui'; import { BoostType } from '../types'; import { RelevanceTuningForm } from './relevance_tuning_form'; -import { RelevanceTuningItem } from './relevance_tuning_item'; +import { RelevanceTuningItemContent } from './relevance_tuning_item_content'; describe('RelevanceTuningForm', () => { const values = { @@ -55,14 +55,14 @@ describe('RelevanceTuningForm', () => { }); describe('fields', () => { - let wrapper: ReactWrapper; + let wrapper: ShallowWrapper; let relevantTuningItems: any; beforeAll(() => { setMockValues(values); - wrapper = mount(); - relevantTuningItems = wrapper.find(RelevanceTuningItem); + wrapper = shallow(); + relevantTuningItems = wrapper.find(RelevanceTuningItemContent); }); it('renders a list of fields that may or may not have been filterd by user input', () => { @@ -112,7 +112,7 @@ describe('RelevanceTuningForm', () => { filteredSchemaFieldsWithConflicts: ['fe', 'fi', 'fo'], }); - const wrapper = mount(); + const wrapper = shallow(); expect(wrapper.find('[data-test-subj="DisabledFieldsSection"]').exists()).toBe(true); expect(wrapper.find('[data-test-subj="DisabledField"]').map((f) => f.text())).toEqual([ 'fe', diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts index 4ec38d314a259..97030e08e2a9f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts @@ -891,7 +891,7 @@ describe('RelevanceTuningLogic', () => { }); describe('updateBoostValue', () => { - it('will update the boost value and update search reuslts', () => { + it('will update the boost value and update search results', () => { mount({ searchSettings: searchSettingsWithBoost({ factor: 1, @@ -901,33 +901,13 @@ describe('RelevanceTuningLogic', () => { }); jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); - RelevanceTuningLogic.actions.updateBoostValue('foo', 1, 1, 'a'); + RelevanceTuningLogic.actions.updateBoostValue('foo', 1, ['x', 'y', 'z']); expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( searchSettingsWithBoost({ factor: 1, type: BoostType.Functional, - value: ['a', 'a', 'c'], - }) - ); - }); - - it('will create a new array if no array exists yet for value', () => { - mount({ - searchSettings: searchSettingsWithBoost({ - factor: 1, - type: BoostType.Functional, - }), - }); - jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); - - RelevanceTuningLogic.actions.updateBoostValue('foo', 1, 0, 'a'); - - expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( - searchSettingsWithBoost({ - factor: 1, - type: BoostType.Functional, - value: ['a'], + value: ['x', 'y', 'z'], }) ); }); @@ -959,107 +939,6 @@ describe('RelevanceTuningLogic', () => { }); }); - describe('addBoostValue', () => { - it('will add an empty boost value', () => { - mount({ - searchSettings: searchSettingsWithBoost({ - factor: 1, - type: BoostType.Functional, - value: ['a'], - }), - }); - jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); - - RelevanceTuningLogic.actions.addBoostValue('foo', 1); - - expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( - searchSettingsWithBoost({ - factor: 1, - type: BoostType.Functional, - value: ['a', ''], - }) - ); - }); - - it('will add two empty boost values if none exist yet', () => { - mount({ - searchSettings: searchSettingsWithBoost({ - factor: 1, - type: BoostType.Functional, - }), - }); - jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); - - RelevanceTuningLogic.actions.addBoostValue('foo', 1); - - expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( - searchSettingsWithBoost({ - factor: 1, - type: BoostType.Functional, - value: ['', ''], - }) - ); - }); - - it('will still work if the boost index is out of range', () => { - mount({ - searchSettings: searchSettingsWithBoost({ - factor: 1, - type: BoostType.Functional, - value: ['a', ''], - }), - }); - jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); - - RelevanceTuningLogic.actions.addBoostValue('foo', 10); - - expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( - searchSettingsWithBoost({ - factor: 1, - type: BoostType.Functional, - value: ['a', ''], - }) - ); - }); - }); - - describe('removeBoostValue', () => { - it('will remove a boost value', () => { - mount({ - searchSettings: searchSettingsWithBoost({ - factor: 1, - type: BoostType.Functional, - value: ['a', 'b', 'c'], - }), - }); - jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); - - RelevanceTuningLogic.actions.removeBoostValue('foo', 1, 1); - - expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( - searchSettingsWithBoost({ - factor: 1, - type: BoostType.Functional, - value: ['a', 'c'], - }) - ); - }); - - it('will do nothing if boost values do not exist', () => { - mount({ - searchSettings: searchSettingsWithBoost({ - factor: 1, - type: BoostType.Functional, - }), - }); - jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); - - RelevanceTuningLogic.actions.removeBoostValue('foo', 1, 1); - - expect(RelevanceTuningLogic.actions.setSearchSettings).not.toHaveBeenCalled(); - }); - }); - describe('updateBoostSelectOption', () => { it('will update the boost', () => { mount({ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.ts index b87fef91c7d21..4787ef89c0119 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.ts @@ -69,20 +69,13 @@ interface RelevanceTuningActions { updateBoostValue( name: string, boostIndex: number, - valueIndex: number, - value: string - ): { name: string; boostIndex: number; valueIndex: number; value: string }; + updatedValues: string[] + ): { name: string; boostIndex: number; updatedValues: string[] }; updateBoostCenter( name: string, boostIndex: number, value: string | number ): { name: string; boostIndex: number; value: string | number }; - addBoostValue(name: string, boostIndex: number): { name: string; boostIndex: number }; - removeBoostValue( - name: string, - boostIndex: number, - valueIndex: number - ): { name: string; boostIndex: number; valueIndex: number }; updateBoostSelectOption( name: string, boostIndex: number, @@ -141,15 +134,8 @@ export const RelevanceTuningLogic = kea< addBoost: (name, type) => ({ name, type }), deleteBoost: (name, index) => ({ name, index }), updateBoostFactor: (name, index, factor) => ({ name, index, factor }), - updateBoostValue: (name, boostIndex, valueIndex, value) => ({ - name, - boostIndex, - valueIndex, - value, - }), + updateBoostValue: (name, boostIndex, updatedValues) => ({ name, boostIndex, updatedValues }), updateBoostCenter: (name, boostIndex, value) => ({ name, boostIndex, value }), - addBoostValue: (name, boostIndex) => ({ name, boostIndex }), - removeBoostValue: (name, boostIndex, valueIndex) => ({ name, boostIndex, valueIndex }), updateBoostSelectOption: (name, boostIndex, optionType, value) => ({ name, boostIndex, @@ -430,16 +416,11 @@ export const RelevanceTuningLogic = kea< }, }); }, - updateBoostValue: ({ name, boostIndex, valueIndex, value }) => { + updateBoostValue: ({ name, boostIndex, updatedValues }) => { const { searchSettings } = values; const { boosts } = searchSettings; const updatedBoosts: Boost[] = cloneDeep(boosts[name]); - const existingValue = updatedBoosts[boostIndex].value; - if (existingValue === undefined) { - updatedBoosts[boostIndex].value = [value]; - } else { - existingValue[valueIndex] = value; - } + updatedBoosts[boostIndex].value = updatedValues; actions.setSearchSettings({ ...searchSettings, @@ -464,41 +445,6 @@ export const RelevanceTuningLogic = kea< }, }); }, - addBoostValue: ({ name, boostIndex }) => { - const { searchSettings } = values; - const { boosts } = searchSettings; - const updatedBoosts = cloneDeep(boosts[name]); - const updatedBoost = updatedBoosts[boostIndex]; - if (updatedBoost) { - updatedBoost.value = Array.isArray(updatedBoost.value) ? updatedBoost.value : ['']; - updatedBoost.value.push(''); - } - - actions.setSearchSettings({ - ...searchSettings, - boosts: { - ...boosts, - [name]: updatedBoosts, - }, - }); - }, - removeBoostValue: ({ name, boostIndex, valueIndex }) => { - const { searchSettings } = values; - const { boosts } = searchSettings; - const updatedBoosts = cloneDeep(boosts[name]); - const boostValue = updatedBoosts[boostIndex].value; - - if (boostValue === undefined) return; - - boostValue.splice(valueIndex, 1); - actions.setSearchSettings({ - ...searchSettings, - boosts: { - ...boosts, - [name]: updatedBoosts, - }, - }); - }, updateBoostSelectOption: ({ name, boostIndex, optionType, value }) => { const { searchSettings } = values; const { boosts } = searchSettings; From 3270c642712fa15f9f4e6d3c5b1a12926fbf261b Mon Sep 17 00:00:00 2001 From: Lee Drengenberg Date: Wed, 14 Apr 2021 18:05:13 -0500 Subject: [PATCH 39/43] add images and content for functional UI test debugging tutorial (#96790) --- .../interpreting-ci-failures.asciidoc | 72 +++++++++++++++++- docs/developer/images/a11y_screenshot.png | Bin 0 -> 149936 bytes docs/developer/images/inspect_element.png | Bin 0 -> 207682 bytes docs/developer/images/test_results.png | Bin 0 -> 272827 bytes 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 docs/developer/images/a11y_screenshot.png create mode 100644 docs/developer/images/inspect_element.png create mode 100644 docs/developer/images/test_results.png diff --git a/docs/developer/contributing/interpreting-ci-failures.asciidoc b/docs/developer/contributing/interpreting-ci-failures.asciidoc index 38609b2be5c8c..1401c96999810 100644 --- a/docs/developer/contributing/interpreting-ci-failures.asciidoc +++ b/docs/developer/contributing/interpreting-ci-failures.asciidoc @@ -17,7 +17,7 @@ Clicking the link next to the check in the conversation tab of a pull request wi To view the results of a job execution in Jenkins, either click the link in the comment left by `@elasticmachine` or search for the `kibana-ci` check in the list at the bottom of the PR. This link will take you to the top-level page for the specific job execution that failed. -image::images/job_view.png[] +image::images/job_view.png[Jenkins job view showing a test failure] 1. *Git Changes:* the list of commits that were in this build which weren't in the previous build. For Pull Requests this list is calculated by comparing against the most recent Pull Request which was tested, it is not limited to build for this specific Pull Request, so it's not very useful. 2. *Test Results:* A link to the test results screen, and shortcuts to the failed tests. Functional tests capture and store the log output from each specific test, and make it visible at these links. For other test runners only the error message is visible and log output must be tracked down in the *Pipeline Steps*. @@ -29,6 +29,72 @@ image::images/job_view.png[] To view the logs for a failed specific ciGroup, jest, type checkers, linters, etc., click on the *Pipeline Steps* link in from the Job page. -image::images/pipeline_steps_view.png[] +image::images/pipeline_steps_view.png[Jenkins pipeline steps screenshot] -Scroll down the page until you find a failed step *(1)*, and then look up a few lines for the `Branch:` step to see which specific job this is. If this is the job you're looking for click the little terminal icon next to the failed step *(1)* to view the logs for that specific step in the Pipeline. \ No newline at end of file +Scroll down the page until you find a failed step *(1)*, and then look up a few lines for the `Branch:` step to see which specific job this is. If this is the job you're looking for click the little terminal icon next to the failed step *(1)* to view the logs for that specific step in the Pipeline. + +[discrete] +=== Debugging Functional UI Test Failures + +The logs in Pipeline Steps contain `Info` level logging. To debug Functional UI tests it's usually helpful to see the debug logging. You can go to the list of all tests including failures (1), or directly to the failures (2). + +image::images/test_results.png[Jenkisn build screenshot] + +Looking at the failure, we first look at the Error and stack trace. In the example below, this test failed to find an element within the timeout; + `Error: retry.try timeout: TimeoutError: Waiting for element to be located By(css selector, [data-test-subj="createSpace"])` + +We know the test file from the stack trace was on line 50 of `test/accessibility/apps/spaces.ts` (this test and the stack trace context is kibana/x-pack/ so the file is https://github.com/elastic/kibana/blob/master/x-pack/test/accessibility/apps/spaces.ts#L50). +The function to click on the element was called from a page object method in `test/functional/page_objects/space_selector_page.ts` https://github.com/elastic/kibana/blob/master/x-pack/test/functional/page_objects/space_selector_page.ts#L58 + + + [00:03:36] │ debg --- retry.try error: Waiting for element to be located By(css selector, [data-test-subj="createSpace"]) + [00:03:36] │ Wait timed out after 10020ms + [00:03:36] │ info Taking screenshot "/dev/shm/workspace/parallel/24/kibana/x-pack/test/functional/screenshots/failure/Kibana spaces page meets a11y validations a11y test for click on create space page.png" + [00:03:37] │ info Current URL is: http://localhost:61241/app/home#/ + [00:03:37] │ info Saving page source to: /dev/shm/workspace/parallel/24/kibana/x-pack/test/functional/failure_debug/html/Kibana spaces page meets a11y validations a11y test for click on create space page.html + [00:03:37] └- ✖ fail: Kibana spaces page meets a11y validations a11y test for click on create space page + [00:03:37] │ Error: retry.try timeout: TimeoutError: Waiting for element to be located By(css selector, [data-test-subj="createSpace"]) + [00:03:37] │ Wait timed out after 10020ms + [00:03:37] │ at /dev/shm/workspace/parallel/24/kibana/node_modules/selenium-webdriver/lib/webdriver.js:842:17 + [00:03:37] │ at runMicrotasks () + [00:03:37] │ at processTicksAndRejections (internal/process/task_queues.js:93:5) + [00:03:37] │ at onFailure (/dev/shm/workspace/parallel/24/kibana/test/common/services/retry/retry_for_success.ts:17:9) + [00:03:37] │ at retryForSuccess (/dev/shm/workspace/parallel/24/kibana/test/common/services/retry/retry_for_success.ts:57:13) + [00:03:37] │ at Retry.try (/dev/shm/workspace/parallel/24/kibana/test/common/services/retry/retry.ts:32:14) + [00:03:37] │ at Proxy.clickByCssSelector (/dev/shm/workspace/parallel/24/kibana/test/functional/services/common/find.ts:420:7) + [00:03:37] │ at TestSubjects.click (/dev/shm/workspace/parallel/24/kibana/test/functional/services/common/test_subjects.ts:109:7) + [00:03:37] │ at SpaceSelectorPage.clickCreateSpace (test/functional/page_objects/space_selector_page.ts:59:7) + [00:03:37] │ at Context. (test/accessibility/apps/spaces.ts:50:7) + [00:03:37] │ at Object.apply (/dev/shm/workspace/parallel/24/kibana/node_modules/@kbn/test/src/functional_test_runner/lib/mocha/wrap_function.js:73:16) + + +But we don't know _why_ the test didn't find the element. It could be that its not on the right page, or that the element has changed. + +Just above the `✖ fail:` line, there is a line `info Taking screenshot ...` which tells us the name of the screenshot to look for in the *Google Cloud Storage (GCS) Upload Report:* + +Clicking the `[Download]` link for that png shows this image: + +image::images/a11y_screenshot.png[Kibana spaces page meets a11y validations a11y test for click on create space page.png] + +If we use a running Kibana instance and inspect elements, we find that the `createSpace` data-test-subj attribute is on this button in the Spaces page in Stack Management: + +image::images/inspect_element.png[Kibana screenshot of Spaces page with developer tools open] + +We know the test was not on the correct page to find the element to click. We see in the debug log the repeated attempts to find the element. If we scroll to the start of those repeated attempts, we see that the first thing the test did was this attempt to click on the `createSpace` element. + + + [00:01:30] └-> a11y test for manage spaces menu from top nav on Kibana home + [00:01:30] └-> a11y test for manage spaces page + [00:01:30] └-> a11y test for click on create space page + [00:01:30] └-> "before each" hook: global before each for "a11y test for click on create space page" + [00:01:30] │ debg TestSubjects.click(createSpace) + + +And we can confirm that looking at the test code. + +So we need to backtrack further to find where the test opens the Spaces page. It turns out that the test before this one would have navigated to the proper page, but the test is skipped (marked `it.skip` in a PR). + + it.skip('a11y test for manage spaces page', async () => { + await PageObjects.spaceSelector.clickManageSpaces(); + +Perhaps someone skipped the previous tests not realizing that the tests were not independent. A best practice would be for every test to be atomic and not depend on the results of any other test(s). But in UI testing, the setup takes time and we generally need to optimize for groups of tests within a describe block. \ No newline at end of file diff --git a/docs/developer/images/a11y_screenshot.png b/docs/developer/images/a11y_screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..cbb835890a07e78066533f7aeba63de6e5cb3e59 GIT binary patch literal 149936 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/developer/images/inspect_element.png b/docs/developer/images/inspect_element.png new file mode 100644 index 0000000000000000000000000000000000000000..0c02597858a866dc60f0d3116442d7f1ac041a8b GIT binary patch literal 207682 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
YLlu^DzvFk$9Jox_y>Z$?h(T5 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 &w~ zNPe8CL1D%yHa7+u;wv!@0Bp8al)9pn^v#hH<$Ms{u$}Jo>P)2m@HKl^Q zUu~+b+?B4;z8t%`K%>mvbq{ldbcsv#IC8) zQc~cw(i`nK95uWCNx7^4TW(4*qt|&;;`DLto)UzxbBi2LvKF<9nVjX|?!?<@iy-Y$ zeLLByi)_YrR;`9q2PX$*Y;K}8VJ?>4{6ksG;OV)#k^{+sz52nNCjEX;WxP{kU2wzY z{NZ7X5mjC;6{bK3mgb-%1R*4HhQ0E2;N`auPN>92da{0uS+1|B!sOGEc_S8$ZnTYF z@%yjI%Chq<0fBQ$7DSnRr~)HP^surv5&7&;>O6GqPlpIequFcrZ&3#pRuw--Z7mJK zgL`;PS64IU@pDlX<#`gD1c&`^PH5P?f-Kt5yaO?ME_TH4lr39i<&o#SeVDS;7LAV-X7YwLu_f)iL8`k(%$sUT#4W0#oMhK2a7u>Xog1{(oMK!X zVR0DFG#TxSDG;*H5&y)Cr$6V=mD|{_xGlr_F*~{dL-k_LR3|I)#U|oA=6nq% z`44zo9w?FJIZMgIZXz-)#U%tLJuh?fDJ3|OVNd+U-56YhOiU$IR39#d@YpgsPSouMd9g02C*sbaY{H5*GXfbBce zUVJ}&&FW#`wNI^-x@itHiH`xEkFxOXKJTyQyYr4eYHDwi=mHbs$R%z1b!}^8msWeq z<^(!I(ZR)GYyk=mHNeNQz7+A zEw86v(N9qiO@3L^f_{ZlN!_H5PSPZJK}qdkbfL6kQ|@A1_*E`nDX;-6eSBB1A>$?V zD*2yx`JlMFayu03L_l>|*gylvjq?JLkNHq!l~2Kn#$8fmfl~%+GqI@0@+Dj45M~P9 zDCS#>{Y5dR(M}rrhpHOMB1MU1-Zr6@q6j&b0)a{Qbz?NWZNT>oFKqGtWEl%>lS!L^F?e+dlP<4g zy^@S*bVqlkY2F*hY)2-UkE|W77bRUaTQKF<47UuBE4d3nQ}ZZvclm|op@pmWF+bjt zm#p)4Oli6!%EIfo_Ss=;eWa}#ZB{v*H6|YA2!DQwTSRKY%;e+jD88zjDeI>{cNhLk z*C~j+ydgh;))!^7bsg}G_!^PrCly%-@l1cR&=-mwIdb38@7@O&czG_T>TEU?1zac> zE(HBNke99AY5hW-H}`W9VNi28TvvG;-3hzGc};}+h5U3}(N9`#vZOlJ9W*nuAR!XoA7yRXr%~D@ zn~W&@*377{4USgtQ9z7pax2J+#ljozcpaHXei=hL<@Ri4w0iD@8pZB}f;dlSwF&h@6GJ(4iJTod`rGFucy&NKIm|LknT3S=C;D8o|PZe^? zO4(VxvUZwH6jQ>C(?9B9X?6+PHJRG{#F}& zq_2*YApvhjU5DA&6wIG_Rxc$Z!kNP(l1LR!iVgURe^csW`NI|!ZQ0pT6!0Ps^H%E} zyv(6Cp+!JQGA3!-yT@>zvAP(mBFo>>$@n(gj>1LK?u9h-ek7EKnv|zGtU*RZNj!*C zPFT9IxG!^h3Y><2>+2RBSSwfY-n_NYqM7VG-R^$Rds!2AWQl55q&tA00{3W{X|nq# z_>)b2E)$DQ((TTRW={9p@|0~`-S_bs#n9v_F%KVg7gdt3Yh_5u3h}eR?0WA$WJEUP z5Sj*h)(`$GbeW>**VhhMHeQH@v~2u5g5LnS$5D}QxkgvkJU%~<^dT(O>Des_4@<2a z^x1+&Uq3Lv@&Pnk>m77JeQw~zB4jd(4x4VIG15ImClK&|5yc-7HrWbu48 z{E`LXy+O?NDh5KfqO1P!jYtC*$r<4ny8z@rec=qw1~@y*HI29i+-$Nz5!q&^-L=WT z%OTKVzit;VLd4sSJn278xsz-wC~{6LxU+NUzJ96@Le2cfLi5)?mYIcJs!}I&MWeUE zI@t|u2tP_7?vDPd``kOLg%*98J8>({W;V9fz|e>yq@_PO;a~vqQTZi3@h3ep zf9Pmah7`<;KlD8YBJ|FB2=Nvbzs%_sI){x!G0Fi=Z% z@J{`8<%fmRK2r2Co3G0;g|gIMHObTbRi%f13ig+pj}+hQmsrv>V~4nUv_r%tVR<&~ z>pow}s32BV=7!YB_J7g1$dyrJ4aqK;sGQC9BMYlIv7b77oAV+a-=+<3QY@p)q2b%Y1XkvpPVrL5zF00Z3 zr98uDHb2&rEJZm4QphCoa^+Jdt}@IxFt~*{=Ig5JU9iMH$sww)8V!hbILW`kjGJ-7 zqPO5Ufw9AJpU>eoCx^M@;&hFrFp;6&NSV~3or^1~F9_%mCnScrIeLO`yt z5CEjL62x9-Cq2Yek57KdQdH#k`VY`JdH$2m2r>y%GczVVr`ARiaD00WzgUu1(vPAb z-3mv+1*>UXW|$VF658%!lW33g<{{1!QTgRm zBqV8cJtz90xNV^5s~CzDVa_9?%;G;sV5YCs75!@s z8AXL9#J?P7H|f|3IBo?5;`$%4=4TtByo{2H%<-c1Vpd^O%#LXxzaOCOnJ(%gV_z)7 z3X~8gXy<=^j6t)IK`R>QFZX5`fqoS&eAP~v;~uP_%M^1Sk^iFBKZlKoJ#t{}A^Y0or_-DYJC)%~W6e3u_JrPR?NP)-1xuX$hX~5n zDY1yHo6ig0-W{rg&a!^3JJD>qg3a7X&@rLU9KyMq`_P;S=&5WMMUO^s_ z&)vz>=WM&`Vgdz_ed;h?w@&F2ESIwg2@PvR$#7vzJ9G2561_fXKr&Y|BrEnrG*bWL z1=oWA&6h|X{jD!YtbwI9Ey?QD(t^Yh3CTiu%D?C`J^Fj*Hf1^EIBIO!WWt|#pF?wE z-+nC);>CF|ObT(Q*)-H1LDWeonAb+3B`l5|=5``=_$fy}KwoL)D>D>BcFR`I%e77N zV?Gk~W3AaQXDWv+yuFgEbenGuMd0Jd>#Qbx>d=IBSrpV{LTvJp1_6-#{54&UUeBnS zyt>{tye<6qF3+445dpQ|kxl5YvtSESX9K0m<%DgNId+>tAx6CBl3U>+S+9N2NIHyt zjdzGMf|H-B@VSl*M%WzH=H&Zxjf;i{)7pFOr()g^^NnA7iS^lHgb2zuFWd|w$w%NB}pic~+WYqT1GeKZT z77*m0ohy4;!J511Yxzeq(jv_?F@$A~*w>~m-lGeV!@euF1yNHifoMbf-LzV0g;DbL z^}YsQ9qb9xoOGVYK>R!{UobjM2#?RrT-_^{wDCeXWSq*t+&*c}BpX!A%#KY=j^evO zDL2+nA1-P^0M&o)+N*@c4&&e}!{Hdq8Msn{3{5#PR9y}epfCKSy*&<;mG#KY@nj+> z*^s`T_6~|4!fz>csyjiGax*1e+gh@z&R|IDy0OQ~pj0a^;~5Oa)k)s7Glj{UlzpT3 zzjBYW-3ic?4swrlN=`?^*AC?Y&{?Cfh$^5PL?OuF!xW|@H$x=H3!r~H6DcOCL_Vai zNaVf|0V;ktdT!5ip5B=a)^Gp3NAC0!L*k4PVCE@KIJF>58nB!`^>yo<%TV$l!lgPjWq#UA2=Uu;tx zB2gy)AA4{87FXBoZw7Y=?hxGFB@o;R?(XjHPH^|&?(Xgm!QI{6ZJIpqIp_J#oSDC1 zxcUeBYT3Ql{_I+{s_v@A=lDTCE4sp2xJ=;{cQzM^h0%(uHfQ2P;gRV1b!q=sUt&&E z)kMY2p&2)+I1Z25#!oxDtlzY-*fMZJ{&`1ykp=jg>~r(_cOEw0{RJfvhPduOY?c=~ z>YEzkVXy~Rj*}0YiuFLEkV{Psj>A0FJE*j!n3HbD7N?Pzj#uQM?0ZOC_y?>2{@#hO zKXi~9V^957jnmHcd&UT^qh7mstAzku4d=8*GeRC0(X}|<5>M@)BB8nG?S^MOtmeYp zx+WzI?{>qW`F+~GxQSK)t%#0$;}=W=gde&hT(fbiF4`e0$v8C zrKNrn2Z?d4^r+{dGD7KvbxSF3J@d$2jimByckvsmAzH02WpYc?URZBv=*(h8z9=J; z9TU}WrDm*8g%q30W#qm*)RWKZpv*Z?^_-Knlj)&8>xFce0~{$L@H^IFn>bboU_BJ43_q@p21LU?ZxO zCkfvHNPrefr|XgY_ze7x0+oae)%;+)oRr4MOy>#?FE@#mfhGlI?UrBw1%q0eh0=mY zwNt?QNwTVvrB6-~-1#k$TO!=7bbvvB!)yfWx-i?Z$cVZ7+z?!q4=d%|4L5usj`OX* zoAA?AV@A}YV?M)`2FsfAe=FqH9oM3uw#^sC7^fGgD*9ePUQ7oq~ z$afKXWvgiXcfsk~DYnPZO2~8(b>%@r+&@6GqbcqkYYET>>VocC5jf}BS@s~*RodG} zc@V{Cdk_^UPFDIU!Iu7{_HW+4uN8Pv1k0ouKFQbmx=FT+2ZYmDzD!mnlioZm0CF1q zVvGWc4u@6Ct)y;`I5NgnEY;MC(_i2{wz5=$6aa5_f0(6u{ZpDcbTFqXpx88{*c((B zXf1ZU9m5(JhY@%AoPuC{$~m-+#B7#RG5V;8oN%uZvoXE0(_+2d(LEkontHENPy{T} zUnjewm#0Udjf5gm=;nx8+mU`3no;BR@Jm2YJGl_!)9oGuEa0aCc>yW5Pal0Ulh+$i zSp)b3_yU%I-j;fL+l$ruB2jn%SN{9hr$PZ65{&|q6rY(k6F}nPGX*yR5S4k#XCPCV zx`>o;6u4Z54#e<*@H@&sS`z zN$CA*3a-@UNn*8JSC0TMDQVq1A6drw-#L0QkCOhX$x+EoT7J?E+6@Jty69=;Ax;ncL%NGa*8g~U= z>zJHrT6&soS39gi9L%PYx3V-#x_o>za-xTzMn8YwH_9J4wbbef60MA2){ddEK%!RTIoB>VM15Og=21yj`AKyDeISSm|UMQVYO-8 z10#xf+?;g~_0VV?sgP280*%GCfPmKsrAY=qpTttJ2XanzV=iEb+fIgK?m`R2GNqDe zW9=e=`U|KO8tX(?O8UYcxlrWTnZ=S^hr%GBl%H#vi|mvOQcn8Z+8oB3XE+(cv47!>PDObq2rFNY1r#?~y4YUIsVaXcm~ z-&YzoQCKACWSj(Mb9@{NxYQ&V$X9Jxb9U?h8 z5_s*|mFAnU+%%kV{IjvOm8>d*}y$+?MxZU$+wh>BATop8j{WfTjNmTs(z?AlH zz_&x2@~Mty1vQhdN=cE5zNZW#xhFc3OcXto*RdbpZaw4H>(nJYIc{MM!iDNvb*rrE zU-cg0bu<^)YYT*uDaV{hod32YaFQrf!v=&q$jNjbZT~JqO18vhk)gZ+i7YMex4STO zI=?b@E-rV#K0UPnW{Sgp{}T!^g@0n>;FpepBwh}LK_4R7<+tOIfCPk^_$ufDp6b^w zsS_1$6AA@>Fh;V9aHTy;D0mM7W!Nfl@^$mmF1P;mIk1+QqQM9}M1yQT?{xIb;e;p} zWO=(?hJrcEx?;~EzwL!9M1XNE4WJVJ>j6jaL6?L;0%xT*;WYEl3aaQxYdN6t(Xu(1 z&pJ-IO8wMudPG;(QCMkVA@!q%Kdij*leSJfK(VCKV>MPhg5(Ow15)S*eB)OZz%BF{ z`B(--KKQ=;Nwq(P>DpmOFw2m9{5Tg};UpzXEd<@8n6Wq<#l1mqXM zuLc-`Sf|fn`Qkt}K?vhEf7T--r9qFTEES$~kmyWCMnj|#2SUODl>2ob+z-+&pB`@% zZnKD*9y@A6Q6oPohsU3q^sq7sxn)wjiB)4}W{KA%!1F9^sZ3B`Z+rfIhMKVE&sgu@ z0oz0P_W=!_YKF`RmGf%-mf~3yM8?Ko4MbXH2|feQ(fJ69$a8?CtZR5bKj8US1KZCL z0sJ=&|M?Zv+Gwu%Gbskn0>pG&$wDlGC6xO@SS@ibX+oc$-s^rUp6VFEjWCBPS)49N zqf|m?wyL<0kjyle{romiRfo?r2E4P*-;5A20{?nQpeP`JUJBwfKv3fUcx4C)>~G)$ zRE+DN%k)1Vs}cM^e%`-7MlJjQ9||xY8axyD_?WA_wux_n-%RzMO)hNgj)7myP5sir z_-6kz)TR)h15B$KD#8Rc#r`srKCD6;@*6wNz?%r3)<2|5(WClt|2ieNlGe0;Bma;c zCbAMdSwukF=+$jv_lKkIM5>SMTG=&;%0_9Jp}lq&_UiQ3>AR*P6{}+O*K_XS$o$N8 zMj{cU9D&mz)WMMM^nSEP!$|38O zNq9tf!L7}P$eq)ngfSdW2`nm?wsLrAdlJW-{JE?le!vRlQ$l&TfP6{*r~xzizSC+3Me4VwFxB`iExE=Vt9W=M3Q1sh zVCmq&d8Sej2BwTlT7q7U zNWkXd)x)PcXm33Ww>WsGGqOG%S z+dph8Q|7seJpI_FW_&9V&p09{##^H$evQ)mGy{MqV!b}yq$297PZ{lS3zB*#X@P5# zzYn7}IO zNgq@}iJN(GrXpGp4wkMZ#{!I{M(++)d@_u``qR=KV2$<+@i2R_1|pn%2z5{^KOfC0 zAIZNc5s#gkb>ZxQAIWLWa@BK0MD#s@3rqPTqjuMWTFFAR>cknJeK7LtbP4Hn@SvYp z@OEcyR_bAoq`U4nrmEM>Q)Q@sO)jdbeYq)s=%)(*^~UfyBr3)M`;8P+0SuAs$vme2 zFN(q3%>%tI4A%Poz*%_&lPQ;u!S)}0)RdHjAL(gw2j+tAR=M^!bh}8~jIcViUWpyQ z_r>-95vt0P<`Bityb~q|?F5NZlIc){Ngzzx zx;hrbXu;aBQ2snx7`FahXhYu%nCR8L%bQVqe{~F<7G>$)X|%0_pKivr#j|~3FV3}w z#L5BYy?{vP8-PT>TIh+9Qp_hxeDao0kB}6HT}U{`CXS(Mgt4tIzJU!ZAfx~kKV-@1 z2`?wlV;rUG1%J8eNmyCdBNB;>Kt zX`{xGH&qp#5_JBRNYlsM(M=WNOd;SZ4oqKL@W4j1G3G9YygqW{!SK+N*b($rfH& zOjzhvz?wX0LJzmZ0@<fGso-e_39=XFwXKmbweuq+!Cb7{QOFX zRND|y8VSe31}qcs!aR;quulj)>36gtzsx>|pNqd_P-*9>0H;qqcLjGNgPd~Wiq`4D zc|2E0daWHCcAX2DmwZ2-rw6&cx~gG+{EsIxR72=#riY{SF%>E1D{S-%DI1yQhAWwW z^%~*cpG&QOZCx$s~;i zcdBE1zA|N1Taz(_5@pN@0`dv7UMUtsq-5<8D=KDY2DpgW9%?mpvjU1ddVT zniLXQ@Q(0)sOI(QK2l_X#m_E*DUA1)h$z~l;w5U&AxX8F0ouK5g^gjLTtPy#BhM1o z9bod`AnHP<^NCt$rZ#Ar5WCqWv0v&4ULcFeZm^nO8ry-ItSPMZ-qm0(t?;ZLsA^&D zc$1KSpZI^-Uff#9n^@47%Fh|w**^#0?K+>Su*sSL!sVD zeP$vfcOHxh>N@$Qzc{ACBtR1o{hD{(=8n+k=rEI*nRr(|vPBLXWA*%8GjUqrsHSYF zEKf?1;!e^lef_RO6)q6BX=o3Mrkg`$kf(%dW`AyYxVt^8vN5r}tjG+e=F`1{j$)P* z#!M(}d1gHG_2I&Av{YPV)90J2>X*X!lgNx!d5hoUi&IH5hGdL25Yd17PQ^y4hr>jU z%?Xz0-)m1Xc3!Wt;ivraWOH8Ne;y@}@d9Uuf>AKu$o%@2Tr489lg~X_M z*7lzf(cJa2Ix8MaVqFc-NA(bQ?>{4^xvpgm+)~TN@YIx=em^71yF3YdxCd~{C^DYT zXy#y(5qAl8@&YrW>Y_-3W;E@mkYtX!(j@ow0gr2@j(H@kcF;MPzf9}WzNfC)K}c*-qL}+x5?4rP9z%uHpT4sv2YSweO^hAf`YG$ zl~A!j)XRpbD-454@aidIehO&7ZhbU`+fw23Ww*rKLeNuE5Ho^p~=tWdKWxMrJU z1VV{5VqlQZ-V!@aRUKp~!cj>A{ffAzwp3LX;AT~^;qnSeYz=F>*sF0*RQKajDK^F+ zgi$DeAzOx&^?*b0%VCU`|f3X_a=|bIz-W z5^}`k(Pun*B`8NHrnOSpz@QuYLv*-#EWSyfjyY79 z9!-sxKY-oCMIZBleW#dH;jkB4a0gjHE|(87Zaq5jcYt_UFf2pe8rvEN!iv{b60F0Q z__f5OGE2KMvNosqM;RnR*xt=s3RhX2nr}X50`6k13lz$IXmPq>qb4qsV}E^R{!p~J z!fSiGIXiLge&@cPS&F3^AB9I^anb2?It4_Qmi|{X;!$h@b|-MiS76lC;PfyNlzJkt z!=i=KkWc(Kl6ye2`i!$r=ImI#F?)cJNm;yl_nC~ ze3T?+%h1x!_wlM)qT+Z*)?EPqt0y7!5B`gMuTgIS<0f*Ka#a0&YJ`|<~JDWIVO?(ah=uHT8E*7Cd*HW#-=V~mb}SzvRc53O%34B5w1*wpK^ zayP}K_oXYc(=YuaUGwq5f-9=eqkuXDQ36ilL`3N`N|fRbK<;PUgxUb5?w0eOIdH|Z zMX6f^4d!1!&TjQFDgmg;H)!RqKq;TsU#y2vZOb3)U*FygoC6SIRLu|^p$$a#QiSzJ8F=$=6-BEQ=rP7d!cveq0H7<* zq#IPJ%(ucmMgm`ySa_6W6k?MR$CG)murY?^6qjZO@!u(NN$6v+!ecGhIOV;C{{d;8 zzOZtd(4=^Nk*6Z_BawTimYlGeb+F6I^KHZ|#bfxLKm53vUypZn+;i+}+f3iqF>h^` z&GHw=T|&zILWZkJs%lA$5)Lm4H6A&*;&}52YB9U0CbFNAcx5=0%xoatoSf=v+E)LE z-tZe69oyHT1dZRST}B@C%1%}lTkzB21n1mJANLx}nQH^uNw>=uI+?4ht3DRrh}Ot) z@3oAlVoy8FHp@Gh&V+=iOQ9L!EO0OBfs1YNth&Z@VjPuF=~~I4eqnaK!Gmf3r1fLF zal5o0%3SZu3)%}=;-1nre`1PSctMTlTbo4R<;yaSnSF9AJK|qmU9(v&+{!Na-ScfN zM1q)X%UN$_-0Q0JXng$a0j&J@p+_5)@2w%^VjU|%W44~CUcjM$?jfrX0b*9G8oBQI}N2dCNHu2KM3$zPb6TK8dvz$cCXCL zu>-R?{z#a3Hjr?)vtE7WSyWfAU_wEW)51#f@T;gaX?TybrD5EwS>t)`-5I{5kHdkh z)poP4JkJ*EQ7K*RVNF3YS;5aGK(#oiYz3KV9%88dJdr=!SMr)jHSFJM>?X5yLFLUY zIWBYxZT7dsVYWvOIa=9K6>IB`eVj+3d34MTaDO`9zH@~c7EflA#%7qfw4YH?gJFr> zr4={t?EIVcg;JF)mkNric6C%ZWPI+oxqiGIE=C^yUQoy~AlbYyo1!t-7st?R8d2q? zJn5CIKnC#Hks6^~m5{f^| zn<1XLKDd=3-cK@u*?bh&hpka&iDt%ik1Opq-|yGz>Tuig|H1aAO>bsAlYU(jYbrSP zaTF$1rzMq%-4+Yf*!uShwfT#_V`K<^hHp3Cph}V>EbIWvnSRdcj6ADfJ`S7WCzP8X zIp9033UOU0j{95Y3`N*NWJ1LZq0@(^R~MGmv&)^r6E#*#wC5TCx$Uu47%0=Hpa5mAl_(J;yP2D!)i{ zj)2Qpew$BXc8R$s$&&0qEyIWYRyoT@E>qxWsN7N&O5ZLUS&_8ou)?sA)u-q<$ay+t z9rH(B`!PkrLVh}aT1$I;ONFLR`whn~k;>7N06)0bmH&);)n{adJ?;xP?5HbcGMk8F z-?d$e$rtzFK=4L*g(uT;FovjYul|uN6T3{ja)B8_ikmw%c_RuDejOY4gM(rBre+Ww z#!=5elpayOD*{NmFe2N;Vh^$oxsu{bGj?JYOar|Kx?hRQCo1riWdK-gyj%o-_daFM zt0g>#u#D7e14onuH&e{V7nN-Dg}}5Yaea>E4-C$Cqq6kLs^U_d@8j^x3?yGqJgCDk zNXr8#4E#gyu6OfvD~e*OgqEK!{6za014YS=6Nd^G!LI@to#*30^CS~mmh+!3>YR^E zNYME#Sz`({#Z#mqu`3`Kf}An+RLVM3=N+3;i_kS4=C;bCyOrH$XH#?JtT50c&#Oza zzbaXpGtCd6Xke_4NGl2j8)PEh5Q*7f&6gJCI4N|d*cq!^$~!;Wj#4JkvSQ+{>T920 zI~>3dRp{AA^&ZF8&ggBv&pXTYk zX|WADDI!Ox&m^yc7Ce$+$x5=t7r6A)bU9*Y1Gb*s>E9Uz3;QqRS5dLLu-IO_+87tN zIrd*+$RWF~NA?X2AG-?9LtKng+i=8MUt%vDFHE(;)6HZIY5xEV-iibW)FSWX6Ps`q z+07#PCQ4h_U(lrLH1)d>r}}ZQqQmB&GF2C^wm^FAiwlB6C2B+ZV{@$nX)XT3HcPE* zoLH5sd+fIC(f0*@IEj;m!Anx#5E-LUI-jFPjvSn7Yvn3_spvNfys48%KwPB#AKLk5 zb-XXSiV4WG+LXKo)RnSOKm0kQy3`h98`d>IF)YzAU1(D$t#(Q!%xqb)ab#}^fa6up z!KAhC)U=!Ith8;4(jnw#TPT~9j&<=0#Zc%GL=AOC)bL)_H@;NWRtl|MO{u}?;|cX= z!-kS-7eV+I2;DwCmr+#C_-(AhDLh!&&KBoZ*N|J$L>DWRQWd67fcv%W5|xN>;M~wF zO@vnTtiSf1h_%DfWOym)E+Dz;es{$5%O%uN+OUR)rsA|D_A&nT+M<`2-BwEJn|CHn zTyW7uQ%4RyLQ{e#5We%7oP$r%Kfm6k@hfH!WrXD$L#r9W*w!)0Oy)M$k>+4T1s3m2 z>c{qVEe)|^W$0IyC?I=oNaZYoRGeZ=`EiB4%l}t(oiY#$Qca66SAuVdm3ZnM3 z$yII*7swzB$(H84O;32QXFvGHtac>qqcFKR3rM{#NB-&1O}%bIwyNmS99rbZRO5;q2ddz+UYg!X7fk~8 z@0bWDAYY&$Hbz@x$*_z3sl2-?Ut(LFps#!Q!I$P!JZ1*iI&%NmNboL~1bb#=@J9<| zq%6W?Aa>)kE$e=lA|T?u>i7<5v8trLJ|lq#XpWp#lr+O543;TPdM>663=>PhW>g`& zDJwLpBV{cp1N8aOy)#Poavec&K z7C**%`LZ-msjQCXSb-{uqMxoGjbTj7gaeb){uyA|B$~GIeaPYC(Q1Xmxm+dzD^82h zEWra0fIlJ++VR0|Q>YRkA)qP*1a^{+tNQ+mjizF~%uarufyo+mB06~C!# za)1a8s~FIggUP<74)(r5BPre5FW}&2jdd09?weRI0Ql2nV|S5%CINMeM24gz)HSSrZXc*dPoV>`j=C#FL=I`G{KC^ z6M2$ zyQrTn`%&_s+5sRW?zkE~LofZhGeCI}o-qj$w--I+K$#=4tmPiT^5r3~ z?2(F?FSB+$s91c9y|VpBQvSBzlAt3kegqllJYai=U^{!*s4C*DF&J)F4W{~#+UbYB?*m;qESN|mYvh=z69oh&&kj* zUQg-xYy-D9!|Fg|wsyHsX^ig;O|?Uo)N!+>nKS6*^_63+pP18g$k4V3erv5-u0c$} zbHvn?kQQ~!0Ge`01VceqZNCqVe=nyi$T6sJtZ7gxIp&&hZ`U?Ob{d~r^QHO08ChgM znxw;9NfSy-aA6pnOuIN3dM8$!H#V)2Xy)c%KbQZlEETmNirE_38pqL@jL#X+KoM4n z3Vd60Jn(~U8m7xcD!_OB&uDC?w8TaCFKx#6!oP1 z9bopaL$qeUq9&6;ifk5ReK_exOKdiUwTv7;C8PdbYHUd^b-4AvUX+jky$x=-L+d;W^pT|_!6e*(tVR|aI*{}=pZ$f9{*%io>-o`gYhIL*y(I=a z#dwUZN{_pzlu_*3T;Nh&Ed)yIFAmux#K_|VvnfY5dF#Dh<`9tRdlG^^k;+Yzf9#Rj z^AITeusY29wFRDuDBS~cKsXfFcju7sWc!D9f3$RhrE;8mNecnyNab^x zvo@aKs(P^eN7?{go`wDR99+^39yGiSC5`CVOu{#gmW}<B>=}XFT z?7sRh3T9%c4!@!{{Opj?Rl%3!R;5H4N(kkeRFm@=qx4~jM_J_l_b~oy_R+?@+Mgrw z0pF8-NX%jw^Asj-#iImzP>{uNsW_1|U}5lz+Bt0h8kw|gxomml$^Sd2_~T?)z-Bll;Jd7NQ$i)D^zv7G0-z~8cDV` z0+`Pv{8MRfPSBSX)f*_Ay1f**_^p7 zG^#)nJM-_~tT?itnt21J4`l+vP-rC18is+NA?)Rqho8e>cUqClDaYd?8iB~jTI_60lm6%$C+b#bu7oMm#)9Bvqxxee175?Sd{OlGF*87d3*H}!+8k=Q2_8t?rp z+sMZ-k|0m;r&5(_V;*a`Qb#8fEi<>l(C?-WZ(G<`h zp(+SvtgPf6fd`t=Bp8afgeUte7UX^}FKJCx(TOpo9G6f3h@i~x7jFkgJA&u{@*!a5~#P723o9#XQpwG)NZ^x3=CM|gMF;rq5Ev&J<+6< z%Ou!{gDqoNszfi1wc^=o3A=RpNS({bZ*xck42n0v0|dAT`M(7S>H+#$iPpqbXaTB= z&O>VohdQpQ)9%_{A|oVT+C}96($v!9C&UfB=lt_UU>-EpBo(4Z2QVfy5TeM7yp1#o0?S{-5ANCkTY1eB0aSGv?rtKN~#a*1aX z)}o4%HtIEE_9W^u^BaucqIibPdtG)>R7{%VT=_1!fh`# zmkH(IhXDX^tFHWP$+Y{EOUkot4*NgiFMw4z328g8=L9p&;^F|ARRX$`pumQRE9w+2 z8;cK83b8~^=sEh%<_B0Vr=#hk^%_c1Cv#q6$NR@i9pg^IzL8{ZUTi@!I^)R9HugGKNx4q7d+GiH)VFvyKM&`aMS)RxQ5DRsweA5=x7s_{iSM^(a_ zz-*vub+PGWUHnC0Zi0s30j_VgH=7H;J;hsa3Jfx$xrDR4>QqZi9=MZi+S8`h5=Bh zC3vcT{33mU7H2DKWvY&*7GISWbZafkpp7~PaP9TF7KS-8PFCB=9LO^%e&rC%QpitkX-uW)3 zt9xJD6GV(HDUS0ovT#)@v5KEgNg{iEG?j3qG8$iO#b%b;22Es$Y8>;H?WbNHv3WJ8 zMQZsR#pe8k%45fy!;Q=t66V_GoZDJi7)(u%ufWX|5h$CNT}4@TNT^Td^YY`@Wc3LY z>(BKdV0G*RK6BNjDv@*Vl9v`*bnoRCtf8?_W;DEi%_90@p~#6$!!PrWcbx8@3x$9X zlWtAi$t~AC<+u6{fPVEU#tD^Y1~moK&^@avsp!~hW~DCebBbD_T?! zZmrUPC!g;)~m1@}Tm4@u|cwnllg&&`q7vmI=0cdUE{1SfFwb=merog|aa& z{wPK?Nvhd4r?lSJ2oJBgNhWzd?l>IhJC7SZpc}!mrku}Wfdlg&k{_lKIl?=kEnIaB z?{i;XX29xmpT`x(8Vp!hd7bRc+z1&YUmIxJ`g6!q)M2ekpcfu6=PQnggwZ%>LTz>? z-gj;7T)kiR{h+ha04z3wV6Wp;1CLsAoIF`<`kmBRk5b4e5H~uvw*}B&6nn2B@fk07 zC6-lFg42TMVg1Pg&GM(Rvz+aJ1KCYz168anSYeZ7@01Q)$+1nv$5^>XhQ>P18TDX6 zBN@6k9{3e{B_YH_USy}Gbfw6iOB};#tJ7kmu8$^g%{l*$n;;p)o|DF>ppMNG2;1sF z4r1KOMSu4qKAwb?=9;kX(TS!5jCDIUl-Y0uL znl}PSVs4jVF?rfYm2(JuIhNMnoYbB=uK*X4vUh@X#6C;72RED&@ptK_*&wK+Dkp8S zDIIVa{S78K!92Fj^RLlIqA@3eM{E-eYr34=38R&%eR}&jTh&@s_G8Crh6&*}!W&IYMN@8p2QL^g{?C{(spMFadnnM43BY!ON zM45XP=FYW&pGmi?7G*6Apc`Tzz_?1>LFlZH% zWR(o~)^W)9X*BZsR(@uzZ=WAi{&;dUa^JVfryy7JBc=qDnV1^gG~(!5s%ARYPm*Mj zYGIiobEe?%NlwY1eKXlfPBteAj<6gQB(y7tjVBB)x#T&qD}Z$b=$vNSLWpKr4cWmg zCmXX7>T38C_oj(knZWr4EjtLSIF;SoLK+06mhq~1QpCfQeh6isFob9kK_!vE>~o^U zK2*4#N}xZw{{2oG;kJyl2hqKV26vv0Mbc*h`vPB`o)*v49ECp#a)7waYabl56~3nF zohz)P(44b)B!lW&-OU|^#}v&7Kw$MNO1V?ZD^To*Z-t_Qp_NZ*NY3%h#Ba|jjy*fg zGdXI*pEyUUw1oIhq{WZ`+PD1=Z}P2TM-Jr*J2~<;PuGgD`?1+X6(I}=YRN0Si;o2W z^3%;BU|MtHe(;&U2yt=9XkzH)YJN5uqe{2UDSng8VM!enUIPB45RS;(UN*7xD*|WZ z1G1Q@bpiNvN#7tafW~y4QqhoQwXAkjEul>gXA?|*3gAQG*)}31%_^wM3>|inWQI^c zQH#o%l#}gPx;nSZh`j_3^t~$wl7x>xfHLnIJR&wN<`6y3?m;R}rij6v#n2*4T1lJv z=`V|dE@~Sg4yhiH99McN!yF0-^|ed5S+L$rrwEQYD3^9ZYT%Fwd5Rt9Wyl4ziTcmG zNTW;;eNi?Rn<*=8jmt=fgw^rQ;|o>B4O2frRl79!w9{r>T}vuF>M)lUcu@~jp1!`W z+>l=Y&W7CHNprz+&Y|6wuop%#LPXKmQoxdL1};X)D$c zSe@W?@c5?1lbrldW^=Ae?A5jn98kLtpLjyJhI4%OOYfLp=mFR<)E-nv~(^2DeX0 zf&R^wH^EnPP9tG6lw~U*PfFb?e!|;Cm1C7K7st!Io*C&%Hpikr)qkm{#nZQ3ROv?v zvplP{7KebNM>@{4g-MDb2aj;N{ValZ{Tsmfp7vX<9*{;VM3h?SyQP^sImQHJHj|1G zI~&Ub6-3C*N@0F2adD4=^AK6-TL`in}Ao<=nV3s=#rT}nI8;qSE(fJT}1Z5J!P#%b1=7|q-NT4_N7 zkLgyC!TphIf>S4uz>`6~I-mqN&^5b0h{_jKj&#h*1Eb4?KMD7%gmI2IwJeCl5o3JVf+8%wQ8Z|`@8m}IN(pL(mjp0Sr+#6)T}fa`d{Q27 zcMEhHO*Z$ZOEZPWMn(mN-vi}mW~hIsW^g)=K+03Mv9k2{n19vcG6mh)Ap7}oetUY_ z9EL7G7W)@#uKNT3nM^UD4?_E6CbqX^}VjE*yQ!Xw{Yr?m5XMC}KEfJ1RKRJ)$`w#5alWS_VH)tr@e? zkclgZhSQS6>UL?j#aI+_K=(V7^KN;uIw|8PH=75&myV0~@#U(_YqRkKzKfIhYfAQ; z|KvHohmpxxkAkb5Bqn_1I|3}mw=SRrVpzTi5p|M7eWRF$P>OuX|Eotkfq8IfQiBRAe>8X?962EDTjTxjx8 zm!v*^HhnZQa^^sUD9mv0Wym%GTs4zYjcCiow$Gk3F3is2#Ggb$hdqYwd?{P@h> zOsC5FGR2pyl~c?G(3Om-(AEG5Ffz@s|4%sH@M^YCnM|7jjUD$px5s63w=uX^ZDo1R zadu5Q*EuCkLtv{|)4_eLdoI^`d+u!qm-uMIhW*FA`$gwT2ZU%-tA(-Hl0Vn^M*WVj zW2vgfm*I%BIe?>-S(P{36%gt>=MMvSn{3`o#_2KWvYu zI(oZLyD_p#Sbs-T@SlJmVEq2`FbJt}G24a-Mxw*PbG5y_tbNIJZ97|dSnDME{m^dh zO-FW+roAfK!!{Nz(8H$sxZGG1acN29@wTGP$bTrY0qL0%U>$pji`2p2)7UZE`_LiY z)7beiC$(k$PJhw*a6!G+T3Oy$X>9UQ>-qN6`N5%j`m+vTi10sf_~(;<9uo2W^2h+K z;)U|sANC8hdwy>%#aq^R%$@tV(B`?GLNM^!{fj&@We5o|Cq4m0_Fk+7jGVNEbcl6G*&aKub?Z-$&o{w~HBp&pUm(@n^ zS5?T&$J5S^kIauN5#A@QJ0b*U2cE0bKRPEFiO&Nfs-~Zp#Q^a?e)oT0rf8Cp?cxwm zknaL75D2(GNSxq{gdbG znl=mPnd|lLOin+Znn`avzQ;m2PZaUWP>e}8Hm3Y<_<)ym$pCXDD0{y4_Jhwl*R-GRp8-d&F1rtUF|%r_bX)P9Ti8(7boe>v_@H1(Q@+}g z>lZ-7Md_tg^RnEEPnvyMah=s~&?B<5Lh` zb}t}L{SR@`${rt4)>JQ3<%Ya1dmZ@ecG{2oFLp!^ULOV1Qf!G{7eTliOG-#B@8{NF zZ0|2z>u>WHyI!8kLQ6a4<3F7oncuF_6%B84w4nV48E-7?5r5`Yaw z&n`UA-tg6)o+9u&^^N%)4t`pD`vi>lKYZjhQGc)^oxfa_KfFCAx_dj^wDYz-tzV8m z1Q6x({yH6MHx)UL;Og)e7%spT?`S+bztHkta1LwmK4>}HShpKwg9&4^>e!QTW%DFx zqiHfdA^X@cR(gBm+ITI$@Z9UZc2DzOn|)vLjOZ*XEuu93f7b!P;NIUleZR5zfC>H~ z)poM7G48qn@xkzz|KU9P9WYqy_!}Mk3F6SLgVvHqPg8&feZDnrRiC zC+-UycWWn&-i?P7e;kQdug=PyJ=`U3)fdpb-;5jgUT+gC--)jiUejv4>&{@B7d-b{ zd9@$P{i3|j$UdGD@ZVNMK3?!QuJ3tY+zurG_Op;;{xPlH(h)9-TcbKG0UU@_zd0q| z(SAyup1ay@rtruhUr;d3_C0uQ;%2_~9G_gQ)|~-qgrZSxhCM z=OmrQ^HX7`;$=wXUggtq)W^;!@7TzDcc&M_LoRm52c7n!!}vkxLrm0b=f?}1wR`VE zN4n=n1#U#t-1qh8lVy=o=kINC2}Y4)#1MKUf!U`lKz}v?Fm+As4Ix~4h!~oFNQ@PW ztrNF29NL(l1tVY)186F%;t{e81F$lY|GUmA3OQdO+_l-`v&^>FHlD2}S~ottc@bY1 z>l-~v4=XM<9JJSQ5v*y7+qN1YpsmQEwB27Cz1?mWyzO=dUa>n;oqLt98$6!|S2}Kc zbLif6PJ%8zUTZgKUT@jFt~a#bw!FQrUOt|iFTiMJwYrwwf1RqLT#La>I8*_kS+?}SA_rlM9EscS9mXP0dwEl&+o3*s>6*P zaW_9NJYT#$uTx)7I@KH&5Y87p*XX$Lk2o&{-#UUXTyGOk&fodgr8syOW7FNw&^rJ- z30Sw>+ox5$oi=(u7gv5fc6Q#rd4Ie*zmq(cN0ogH1oPb8js5gG^FOgunz{r+;(200 z<4L}I@V;~QZn?t7K5zdxPZgoWH#aLQTmP=iV#_Lc@c;Rg;J2p0A|al)ropP%7j9GNo~|RPPLvxxKg}5l!+g zt-1bG9cJUl-gr81e0w3|iC1shGVae#bACTf`pL8Gd|2E*S;sxEdLtu0tl4Valudk5 z*ywTM&inDo=6yXJu@z-({TwwJ{O?c(5U_%xyUP&@so%biGtQl%p+04UQ+*BJwZJ4KQXVe4;; zLSp-YIY>ZQ{l|&*g!s*16ZZhmI;Cy;^bjJDkYBLzuxn{eesr_=V{I0vV;d$E0b2;8N>EV5n z#JYjzwprb(_MDc1=gR1HyL2!R+Yx$ti*Nuig>0fDFJ-%;0& zB(d-;wgv6c<{`~+sm;N)AXARi(vh^0=%1%%5b~K7q4tc01MCk_#;vjYyAyIt{msnPIrL(jla*5~bVgTi+Ye3ND?*Cbp$EH~JjiA$# zm#glNBz|N9O9kAFMvo#_@RBa^xa6#LF6)MZGH*9L4JUFnWPEms!!98p$S-HTWJs5c zFag(C#wxBJBfHAfAK7gGO?;x%jNTf6q#`-jKd;E4e5%j!5_0SH23^|TA1Ua%*=`iE|7Wz6QV3w7$?JRga260HK6=j|1G)e8Ah9JL!@p4Goo9U+BR01RJaQz0xQgM7odP2V#z6r#h$N}>{s_7{3xKTgzkC3{!` zS@H-CTU9Cxcx|2wO?3w&jlz;52az&j?Hb}`(dhfT`(8XTml*=}P1w;unr5mBqb7#xx7Q79QgwR}m@2V}X`Q0LkuRq5z@(hX zYQq8!zwkQ$G%mTV!m7dTzXDpyo7%3KeI*4*g|Y}{0q2(V@-PK2A1CmTUr2xEjwimNo1AmxSuhuo%(B$N(Q5*|u92T_bBx z_oj2?KeqAfpIsAcn_Vj^^&_@|ei${%;0W1bTPNKyZdr4_h%Z)GxQ_1)4Vx!O{ZmOf z1O%G~roEYN7B9;$$$y@ZZ$a~u|EXluW$N-QfX4X7BO#AUC%uG=s3zmE3WvW&9MD~@ z2!2mchwU+&Lm!RZYA_&O`_xFwWWi$Wz9_RBt|3)igfv#5K9)qnPy=P9<|}$Xd&Lkv z=iVntD12tJi|g%?5`{+AxLmX;c@iE`_L8K<(Z(_^)joXTZk?THaGYDB4VwftsMoKE zkw!Up&ZDC`bP4z+zI}{QM-ziQ4l1lFiQEl5L2}+VDD+iRGhn-Y~*Wh;IYDn9R0e^NriE&JLplxS$ zAi9`=f>z815^^642NJrEz!S>`mdm(0+r{oM;-U$u8KbbBeyO2}KYE6^4$^e!_8Pi$ zGUa9D#AP*88Y5%dQ~a9>_w~Y(W(>y8ek?H>t2Z`;ym31z_jOcqTC|vXn2U%LT8iuY z_pA_1V9R$0wvR;8OS9+5skl{yr@~E7{2^#AWt=E=7pp3DW9ooRhC@O25Y#W|bfoq) z5F<9@Npuh}d8E)Lb})*YBsx4h3_e~t`oP9?C-IhN{1~UCJ;^1)WMUo4auALA`q^Uf z&O7mC!-~B0E_smwyp5$+=fR#d`5xua3Cp(?^V@aJ4uxT7${CdZ)ux7ULKxw3*s{2@ z>Tmt=!(V_ApI-_J#%Al-Eg(wu&v*rtxkpMud(;aWT+>QPpZk{;8f-<{2%PTV2aeKM zf2|6|ki6)aPHTd@=ko+J<37^#I9bBEGWJ17JCcF$-EsVyjIagXwXKTu1S=}%3V$U zW|vR|4=*qy^ygnBW8~jx1r6w4+qV>}=lK-IPF1_TD3Rn+N7K{Eb3?b-Xy?)qVDm8& z@)w+}neHv(Nz;#zfkTapwkpVLrvoclVIg`5nAKE)93Fq#(THFs;AA$@OPxf~IRg@&AYO{W_zZPyM9 zNu0jO8d`gC?T2Rh9|}PHn$6osLc7#BiVUyC8w1M7Y()FdlB;#IAVPI zFefkmsF@3Q|FDUa<{Kmb?p`gdeh81*gqQikIB)f&#ZGl{X1O8n5VyMzU-W>x zu6Hso^|6wc6t#NYmT9kf4OU=v-Yl}d-)42(;#hmU55B#v^Cn@e++4mavszL;FD7~2 zP+aW?y&q?ky`5U(J7qcrlLj?~T=0KUO^mL&lJb5Ucz->dc$ z0jd!6&dgJlw}%^O#plTdf)IVV8-}{1Ss#y0y}GMxX(n|Uh^3; zm$Ku;sR{O+`V^1$W>QHGi-5qeOWdUjQ`G4tx=Nh9$|?FQ=qD@Vlp zTtH(mx$akIE@o9x1yuj46{)F3tD#jp##a<2_=PY%sd_Ftb#W%ZHcPBYZ6QHqrV%M( zEOc0_pd-!yQ%jBBlz4nSC$tegmY`g+Amz5C-vfqJ9Gf{Y>6Y3FX6#R!{7%7_Fat_L zs_>DTxzDMJNQzl^nj@3z)mz8a5Z{_0md{YG9iiYWDrh6? z{M9Zs41as75KX0Jl+ugQ$UF0z=W4zD=?M2aM*xE#FEQ0{wr4n()V&s4c=2AdM~WC$ zI}^)q40Vq+u)?wtwgt8Ku9wXZAL3?8N@XbHf`dQxSpvD}udza&QlG!G*SF2B4s;lD zLFX$7VIL97trOeIU%9R;KmwtFg~zR+$Kf=OZjH>QAzzMMUStuEZxy?3cH&#yB~D@z zuQ>JLISa}hIX!^0iZX%xCV$`PdL%lrcF|a>_&vU>M9-0bd#@2;M9V)3a635)7B*83 zQcYI`cic?vB>L;5C}!gL3)S)8Ze0g4kw#m~>Gw1*G8}D$O6UYflKhPQuhgx*kK3>A zztzKWzA0Ojs&(ZN^)MtvDVImq_X$h?-3Dp9>IwWTqH_ekY;3D&o0Qf_7C^4P(UN{_ znkynV0uKm*HAk~DTXow>#cj^k}(VaBTw@$!oV z2EVxjKTj6Ry0;MGjU9@~WXxmz6>WaD)%h&+H3IXQ3yeq*=`8&vncHBcU; z(~m+Cv3~4D^S6E~V{P9(^?u7VUrT+=I)6DJTfe_NiK=`)>UuvWBcYazpY94ii};{pTC6hK3>0f@H~WI5zeo=x$lg(SGoNu&|+JHR2NkYyyyo8g>_oz_!3?;(`5qUc7# zcB?O{87y{HkPvt)|B!@_+^ikF)aY7|s+XKZz0Fr56Ji6DI+#mR<8fpZ$Ye8rFw_|3SfEeb|Crdlo5Gpt#^n&NG0HV&#PV)}P_<)Y ztc?p{9hXt(u#l~jWUkrgn~Avs+*SAUGeE|o*K6NFBzn4MP@deJXRjijMu$3#cVJ%x zi$mc?YSX2eWn-aM3sDN?kXpdyN<)x5O10}W&1G{EdPFONMoGU-HQu>rC6k9QHXEy% z@s}aI6iFtzUo)f#9%mJyR9L&nLOP44#n9m%TIiYAsQo@V<<92B6M0M2e0MNQ5JU zB)-s%^&JkC_!gAbIsC9wk05O$syCJE+O8Y5$TXCMDPtL&S!}CVwdi|4K(BBtPMdXH zZPkfkFEx)}18hR*=uBdmnp?`94B=waBslW9kP>0Cj>|(~Z*bjl1TDgp4tq=qoYS&O zxMCURA%HP8hRQlBKTQ`xHNl}&5`O~r+x1!A;pycgr(H!x1=rb z^j|EzzC6SwF+8=3JlOsOg}0>LTztru#V`mQ_LM}_=cD0Ti@Q?}(!#SWhZH7c zXHhrX6?{0%v};#hZt!f%012eo_}0QdJkGYJW`fKTotA|4WrrpCk-n-{F$!aP8%NHZ z!|$G>_X_Gx1iweu_}rT6Czk_X@lQZBei14QvPLx++?L?B6xD~F9P#ze`_*RHr-6>?4u&p z6@i!bu2T-w>@UI(Q?WoZH&G7vqp{DglbvzYP<}GtS7k~PG5m=5x}Nzl8qFNFtVYO% z+@nRh6~PW-_E9HhTQlSpniy#a&5h+F#V}BL(G5*4A%DfTV?D>pzk`nnV~C>jKp@qn zyUmvR4UFCT57I3NvX!_vjQ@q8PmyF62_;qkm<^p6nkqSrlh=o&YO3Q#+r?0eO%>X= zvO=nBJL+Z5|4N-27!n!5G%j4)5PY>fBlu47oAvXZaWhDScWmqzU2D*iX5IbFjQ-kA z6NI>NTTiOI>=L`7>gmg=PPwz&<|f^H29>nARm6AwUN zqD+mbF%34@kdlgT99UUfW9Q&swjx&g+w7EN%p9|J)9&d`NqiKFk}Gn)j1_w;#L9eW z&|57$uY}s=M0Ioj)&o|?=B$>duT;J=C!EnGf{p=ZIu0nkEgg68%*Bu<%k|riABbh; z;gGd3&zR1!K4?rK@PM!~GBw7WYH$I8t`g(|qGGkWVwlNPy6q7`*h`{n?2-FFixSIQ zZsnu0)N|&8)DQhtnx@s87CMioiS-*Y@5eb_&q2tyCm{dd%fNfVx>|QG&wVf4 z=eg|MXnJqkxWm)#+EznsRQ=u1v~Rg^^T+$yVegse38?yRsJ}r8kivM!T^f7e6ulqE zWxe&?vRZXKHe%&DuQlq^ni0O=>SnK2$OsfijVp@+1&_xFvT5g+M4x-@S&Yx`>;s(K zE1$j$r?I>Qb2~V8#Dgd_d9Zqsqb_?iXb*Cbdg2z zD=KLnkVqyu6(}zuQ?Ip=zy*dppn&6YtdJWDVfh~l(|hg#$~<%yCY_e*?Th?IUj4;4 zEQ?Rku*k!<52HfI5@4U#q^PaYqI0(B$Jb~;LZ$1{=c}DL_XV+#%*S4`i*sNVN$MY` zfYnFer8sV=KLuC&kY;y6R)~F(U|+P9P?jwp(;@Ir-CS-BJ*-GvZ7b!{5(rlCeCNDe zx9O~r{_oQ1XZETxZu7TTv#hicNu3fp`&mcXeREz3tp#!#`T9$LW z1Ao$`*@Nbvfu+WZeh(SqAm))yx|m-Aqay`xS60n&6KJs*X`z%s2_iY_&uWj4(cBK- zJsIyzkBbF#;upE(HpKL4;TTz?pE{X@lH3s$%`6Hhm3vGzMVChMB9vz_l29giMEQ(~ zT+q)012Uw}x=xYFEj|Zf-};<bxVz(A zRUa^j{c3tBK|kE3Cof;r;#HC?VGuCJ9ophR<#dxWLaerjo)ROZD>@`IXnn*aa(8M* zh;JwPw=+b;%O=1^|24UuHdiz!o4!1?tb0nXp3t=tdYH^s~w=5aD&7`KqF1+q|^^Z8~C;WM{mQTr577D^UUd zAf~+XB2f3Ftq3hqsPmcX;lA6Yk`BuxoN&1=B!zBcKCM|E+(wpec3oIEOh`YrHcG6) z;!pi{Qb-DddndJ`U>iX{q0${;kFLF;;yQrCPXVqLe} zHs)=5n|RxjUb{);b$xb!J32jY2zZzH-B#tgJjnB!6T!P^qilK8v-d2t08Zfa9w~D6 zGS{H}IsyBB)fx4+?(MR!9d`kTxHi$N{rZZf^z!Gu$?DZx+k1O%sDkyQCICzuxdZak zsgiJIB0eo8h3}j^GPt{4K^sD$8!nqnyiW1nkC$lN`|gPD?~3okI-^c#{MdIa16mJX zBDI?YT~~v^ux5+Du@NSkE`_MT-YqU&u1jEa*ZOj zzY)@pB*V?qSDd%MSk8A(!wIb2-1vN$jRLil+6%|=FBXB-)UNidUF5%DVHfPv@Ph6s zj(?y=hR}2D+hFGBVCVQHVeL~z6EftNt>_Hfj(yeKBkvcb4ksMntf0L0$tgu-kx2bW z6hOIVL-ungHFOHT!k>$@LMV_x(|O3{UThUELVVRCD~Q+a)TxuXOwvds60vpU@UW}>gC*sln+ zyEf>~Y?%CGnL=g(1dhGvwHogou1PPmO7!!f|ls}m$i{b!t0<1_BPWOaQ#Gf_RYs6T<7W6`~ZsPqrC^~L5+ zfV&;C{Zcf0p|tF<-Bxz_Cxp>I@dCV{00U7|YL1sSD%B<5-r5sBZECiq%G2DoB3V^P z(vtRo(v*z&B1OgX)^Jajv2AtI7CGeFtgzH#fkVc%8No&Z5-6++=oICL zAV^%?Ote5=axUZM2q-(Ce!V0eLXjLBvR6vFQ>~DYqi^%EhQm4WboQo88IlM<6-gNa`o`6t}TP<0tLSq zpeZ%Mp*sxb5_Hm*DDLaixiva#gSxyYO!P_2?Fj3yhFoI!|JmUKrAvA&WPvi52l7Hu zgH+G58~357xsuV$c4oEdU@|f>uWhJUnIFVe-)GN1gVw~rk^rw2;C z&aNpCKGWW>ErA6E-j0?Hhn7>6wBbq5#PPb4=PjXqJ!$j_@8BfkxBi5}6K>rzgRHn5 zy<}4n*o*!|*^NQLfNj$g4uf$hi2cN=-fHOwmbA47L2jnAp=LgjsJtLxj4LWz^H9J8 zD~)@q$Fc6{AW7#C+8A9311F&_w)(l@7sG}cB^6_t9}HLFzFiw8?3IM95QtPCLInK5 zvH>rZ2E0u8ITCWI|D-1Mb0UJnQ$#dz0$~u>=PAA->0qE6In+Z$SD9M8A-Jx@V?UBU zKwJ_s2m6@c;_T6hzkf1w(8F!%F5%1^1Z_5d#Uy4^8gkc?@4+r_N+TW<Q2h1RsGPdY~XE4%Nk#Sdn#C?YbOw_W#)i^TH6U` zs}4QyhlNoNo$}`65|4KFfi=hnBM%)oA(ynNwMZTEKW&y<)B3{Ni>0 zi}yOa{3eOHNnchL^RZ z<0e|`7HMc>q>FkFKWncr^A)dQnHDE{omR!$*Rv8|G!53Q*bv^LCsW2|z_Wm_K5MT&*Rm8jSm_MjT6*yIzt>qMB^uZwfs zQ}eJ)vi(n<*VHkMx9FkQHbVvA<)babHUqH@gJ=7(Se)-s82A1(#CjLUBh@TF&2$hSK z4dkSrTIrZ;R7RgUK${v{EHmpjh;?W!uy9!3P$`oXM`jwbqvECZa!AkC`~TLn$NCDK zA)3WWe2=q}MH*}!rMbk`g)<|pUllfAu2@}*siyNN4d5}Cqp|DNjk`cT!U5j>qsTpe zDTn5cQgu^zb7sNcM?UzyS2NQ2i3@?{i?^&$vhf!}^)^8`)3~yI7 z*6-%V_=D>}9{66z`hJq(Ck{l3<61xx}$83z&BmW>q?>xsUuqPI&TVAH8zz>#L90A%^AzQOzfckDkJ z*UTndsLXko^m#SSTaDQhYf7L^t*tuAlC+G>f?^hYswR<_I3{0cq00aGD(lP246a(T zEMku7V;F${AhBr8D=>0k12YrIW%D|i+htzoCIiU}_Ub%vBRf7mChEI0pAch5x?}Geg<%Yei~S0HAR^5@c15+A2L3!_ zJkL?0AO3WdudaeT022nQPz&je{X}DN#B%)+7U_=@y|_{ikhOm>>WZUd(gUIdH6={( zNPm?v$1%xmAKC@)FsQ>OIw4Vcm>dtA?V0fOgSQqVvr#yJKFITSKqy5jS|sH``&P{K$4Znmhf~>V=N#ugDl_bNK z)BI-xsrO_rGFQbJ{qul$(Jf)CY#?;yBu#iw_Q^PZs8URQNIr%{Si}xJ5&VL)zqunJ|n?ZcN}BO$Bc<_9Oqhk zb^sjczXKKmMSWX@y{bfEjS?6t7+ggbAUFccyX@lcTyoklrW}0|e!Z7~iDarG%DV43 z*2EW_CKiRif7$IE%${c0BPPflNq6~&KL`RD{S9R*Pva4yC@$IpX@sagjt76ifCQ9t z2`Jh__qUcRNEV>xwjuDUnwoT5KoC%c+s<#HiC^P*wuE-_kEupQviIQvG^~$}KuPJm z(lh_5rf^^tWtUaB^8JaG6@uq!6W-&(^W1A)9C7Ws)45~+)yU=idHs|MUAg02lQ;wT z4&M6hnz!4%yRsMlwI^ODslEPqQWL7C`)?_srWL2Zs|DAJNHHW}D=<*0+TVcpE(~B% z4AuITglM7iHvJw*K9M%QTWQ2*RpvC*Zc+2v0Q;D?pX#_A;)XQNL%u15Z>Eij$Jb-!fpXd!-(U=mlNRP zr+LP6kV%NBz*k$^Sjq7&ud#6gLOK~<=qtxmrVk~^`Ljq7RY9}DmBlA8<+F;tptz(Z z1B<^JcMf$2?WTz(2(~3eUG)3BdBr zZt3zLjvrpbV~8(1r_&`se@tR^RC6{$k*gummPPMxf)OB3h}!~=ySz3P&C#nXM|xxn zfmd!Vof<+8^v}~{M?Ycz_f#!=Ya?Z;xJ`G2N4ef|`l})h=Bk(X>Fxga`f~iUCSvEi z|M~7O)|d-_Jg=;Pizqu!a62fmP4{(p&u30R*m9q?{^}Ks{b%Hz;Lon{ycO+I<8I=z z)spSR>OHT@fVe-k^9C%Ybr3*tAo$3Q$#l}!p6P@z#?T4et(GHy2)DHSiq^&JlKYC> z`;efg`2##UM^8qQHCW6hlBHJ?ez4M_#3$w@zKR^b=qT3sDY64&CXskDv;emYSk;Os z*zR67tq044R9mw$n4!)?t`Js$(h-MvAFZ$kI}Z9HNugYXMd7ao|7WF~%9!L@64$hU z-?mS(inFOW*+|nn{lTlL2@65-noIEtfvcto;DK@+t+QNM-J$wgVY$Q>%JiPU4jrJ3 z&5_b+PeADujk+-qP-<^yUSA&LKt(VbXwT{cz4rrZ-DG&l*TV6v$)l+LZnJJ7GoE;? z#);B`GfM-4zt;a;nk#Z(*bMm0cK#W~0@kmaXLV zR5{C+ORBpZ_JkP*mIRkpX5ksp#3Zshq_|oCOBI~k%E4rdH88wRt@U}-96l$RePbUe z(#N!gtGE@WCoP zGp9mXT5}tS|IY2eL;%mJD5h_6zWfJih?If5(_rHPy8-y71k?Z{9TLY?sot+6gI

)c^?m> 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 Date: Wed, 14 Apr 2021 19:22:18 -0400 Subject: [PATCH 40/43] Test user for maps test verifying sample data maps (#96109) test user for sample maps test & removing geoall_writer_role from add layer --- test/functional/config.js | 15 ++++++ .../import_geojson/add_layer_import_panel.js | 6 +-- .../test/functional/apps/maps/sample_data.js | 53 ++++++++----------- 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/test/functional/config.js b/test/functional/config.js index 05d6cf9dd6b68..1048bd72dc575 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -177,6 +177,21 @@ export default async function ({ readConfigFile }) { kibana: [], }, + kibana_sample_read: { + elasticsearch: { + cluster: [], + indices: [ + { + names: ['kibana_sample*'], + privileges: ['read', 'view_index_metadata'], + field_security: { grant: ['*'], except: [] }, + }, + ], + run_as: [], + }, + kibana: [], + }, + kibana_date_nanos: { elasticsearch: { cluster: [], diff --git a/x-pack/test/functional/apps/maps/import_geojson/add_layer_import_panel.js b/x-pack/test/functional/apps/maps/import_geojson/add_layer_import_panel.js index 5af0a2c6d1edb..7bdaa3898aa47 100644 --- a/x-pack/test/functional/apps/maps/import_geojson/add_layer_import_panel.js +++ b/x-pack/test/functional/apps/maps/import_geojson/add_layer_import_panel.js @@ -17,11 +17,7 @@ export default function ({ getPageObjects, getService }) { describe('GeoJSON import layer panel', () => { before(async () => { - await security.testUser.setRoles([ - 'global_maps_all', - 'geoall_data_writer', - 'global_index_pattern_management_all', - ]); + await security.testUser.setRoles(['global_maps_all', 'global_index_pattern_management_all']); await PageObjects.maps.openNewMap(); }); diff --git a/x-pack/test/functional/apps/maps/sample_data.js b/x-pack/test/functional/apps/maps/sample_data.js index 0c0af2affe50b..10e760fa9d94d 100644 --- a/x-pack/test/functional/apps/maps/sample_data.js +++ b/x-pack/test/functional/apps/maps/sample_data.js @@ -13,12 +13,22 @@ export default function ({ getPageObjects, getService, updateBaselines }) { const screenshot = getService('screenshots'); const testSubjects = getService('testSubjects'); const kibanaServer = getService('kibanaServer'); + const security = getService('security'); // Only update the baseline images from Jenkins session images after comparing them // These tests might fail locally because of scaling factors and resolution. describe('maps loaded from sample data', () => { before(async () => { + //installing the sample data with test user with super user role and then switching roles with limited privileges + await security.testUser.setRoles(['superuser'], false); + await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { + useActualUrl: true, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.home.addSampleDataSet('ecommerce'); + await PageObjects.home.addSampleDataSet('flights'); + await PageObjects.home.addSampleDataSet('logs'); const SAMPLE_DATA_RANGE = `[ { "from": "now-30d", @@ -80,15 +90,23 @@ export default function ({ getPageObjects, getService, updateBaselines }) { await kibanaServer.uiSettings.update({ [UI_SETTINGS.TIMEPICKER_QUICK_RANGES]: SAMPLE_DATA_RANGE, }); + //running the rest of the tests with limited roles + await security.testUser.setRoles(['global_maps_all', 'kibana_sample_read'], false); + }); + + after(async () => { + await security.testUser.restoreDefaults(); + await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { + useActualUrl: true, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.home.removeSampleDataSet('ecommerce'); + await PageObjects.home.removeSampleDataSet('flights'); + await PageObjects.home.removeSampleDataSet('logs'); }); describe('ecommerce', () => { before(async () => { - await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { - useActualUrl: true, - }); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.home.addSampleDataSet('ecommerce'); await PageObjects.maps.loadSavedMap('[eCommerce] Orders by Country'); await PageObjects.maps.toggleLayerVisibility('Road map'); await PageObjects.maps.toggleLayerVisibility('United Kingdom'); @@ -104,11 +122,6 @@ export default function ({ getPageObjects, getService, updateBaselines }) { after(async () => { await PageObjects.maps.existFullScreen(); - await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { - useActualUrl: true, - }); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.home.removeSampleDataSet('ecommerce'); }); it('should load layers', async () => { @@ -122,11 +135,6 @@ export default function ({ getPageObjects, getService, updateBaselines }) { describe('flights', () => { before(async () => { - await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { - useActualUrl: true, - }); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.home.addSampleDataSet('flights'); await PageObjects.maps.loadSavedMap('[Flights] Origin and Destination Flight Time'); await PageObjects.maps.toggleLayerVisibility('Road map'); await PageObjects.timePicker.setCommonlyUsedTime('sample_data range'); @@ -138,11 +146,6 @@ export default function ({ getPageObjects, getService, updateBaselines }) { after(async () => { await PageObjects.maps.existFullScreen(); - await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { - useActualUrl: true, - }); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.home.removeSampleDataSet('flights'); }); it('should load saved object and display layers', async () => { @@ -156,11 +159,6 @@ export default function ({ getPageObjects, getService, updateBaselines }) { describe('web logs', () => { before(async () => { - await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { - useActualUrl: true, - }); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.home.addSampleDataSet('logs'); await PageObjects.maps.loadSavedMap('[Logs] Total Requests and Bytes'); await PageObjects.maps.toggleLayerVisibility('Road map'); await PageObjects.maps.toggleLayerVisibility('Total Requests by Country'); @@ -173,11 +171,6 @@ export default function ({ getPageObjects, getService, updateBaselines }) { after(async () => { await PageObjects.maps.existFullScreen(); - await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { - useActualUrl: true, - }); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.home.removeSampleDataSet('logs'); }); it('should load saved object and display layers', async () => { From de4bcdb9d9a0fbd135c0f179c2f28f25134544d7 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Wed, 14 Apr 2021 19:27:06 -0400 Subject: [PATCH 41/43] [Fleet] Rename `force` to `revoke` agent unenroll APIs (#97041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - fcbc9d9 Rename `force` param to `revoke` for `/agents/{agent_id}/unenroll` & `/agents/bulk_unenroll` - 03b9b90 Add new `force` param See https://github.com/elastic/kibana/issues/96873 for background
Unenroll AgentRevoke API Keys
RegularHosted
Rename force to revoke
Current force=false|undefined
Proposed revoke=false|undefined
Current force=true
Proposed revoke=true
Change force param
Proposed force=false|undefined
Proposed force=true
Proposed force=true & revoke=true
### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### Changes required for consumers Any call to `/agents/{agent_id}/unenroll` & `/agents/bulk_unenroll` which passes the `force` param should change to `revoke` to maintain the current behavior. --- .../server/routes/agent/unenroll_handler.ts | 10 +- .../fleet/server/services/agents/acks.ts | 2 +- .../server/services/agents/unenroll.test.ts | 140 +++++++++++++++++- .../fleet/server/services/agents/unenroll.ts | 126 +++++++++------- .../fleet/server/types/rest_spec/agent.ts | 4 +- .../apis/agents/reassign.ts | 2 +- .../apis/agents/unenroll.ts | 14 +- .../apis/agents/upgrade.ts | 4 +- 8 files changed, 224 insertions(+), 78 deletions(-) diff --git a/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts b/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts index 1505955215515..40dbc2fc49e66 100644 --- a/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts +++ b/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts @@ -28,11 +28,10 @@ export const postAgentUnenrollHandler: RequestHandler< const soClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asInternalUser; try { - if (request.body?.force === true) { - await AgentService.forceUnenrollAgent(soClient, esClient, request.params.agentId); - } else { - await AgentService.unenrollAgent(soClient, esClient, request.params.agentId); - } + await AgentService.unenrollAgent(soClient, esClient, request.params.agentId, { + force: request.body?.force, + revoke: request.body?.revoke, + }); const body: PostAgentUnenrollResponse = {}; return response.ok({ body }); @@ -62,6 +61,7 @@ export const postBulkAgentsUnenrollHandler: RequestHandler< try { const results = await AgentService.unenrollAgents(soClient, esClient, { ...agentOptions, + revoke: request.body?.revoke, force: request.body?.force, }); const body = results.items.reduce((acc, so) => { diff --git a/x-pack/plugins/fleet/server/services/agents/acks.ts b/x-pack/plugins/fleet/server/services/agents/acks.ts index a29937e1257eb..3acdfc2708eab 100644 --- a/x-pack/plugins/fleet/server/services/agents/acks.ts +++ b/x-pack/plugins/fleet/server/services/agents/acks.ts @@ -81,7 +81,7 @@ export async function acknowledgeAgentActions( const isAgentUnenrolled = actions.some((action) => action.type === 'UNENROLL'); if (isAgentUnenrolled) { - await forceUnenrollAgent(soClient, esClient, agent.id); + await forceUnenrollAgent(soClient, esClient, agent); } const upgradeAction = actions.find((action) => action.type === 'UPGRADE'); diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts index 3d0692c242096..938ece1364b40 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts @@ -46,7 +46,7 @@ describe('unenrollAgent (singular)', () => { expect(calledWith[0]?.body).toHaveProperty('doc.unenrollment_started_at'); }); - it('cannot unenroll from managed policy', async () => { + it('cannot unenroll from managed policy by default', async () => { const { soClient, esClient } = createClientMock(); await expect(unenrollAgent(soClient, esClient, agentInManagedDoc._id)).rejects.toThrowError( AgentUnenrollmentError @@ -54,6 +54,35 @@ describe('unenrollAgent (singular)', () => { // does not call ES update expect(esClient.update).toBeCalledTimes(0); }); + + it('cannot unenroll from managed policy with revoke=true', async () => { + const { soClient, esClient } = createClientMock(); + await expect( + unenrollAgent(soClient, esClient, agentInManagedDoc._id, { revoke: true }) + ).rejects.toThrowError(AgentUnenrollmentError); + // does not call ES update + expect(esClient.update).toBeCalledTimes(0); + }); + + it('can unenroll from managed policy with force=true', async () => { + const { soClient, esClient } = createClientMock(); + await unenrollAgent(soClient, esClient, agentInManagedDoc._id, { force: true }); + // calls ES update with correct values + expect(esClient.update).toBeCalledTimes(1); + const calledWith = esClient.update.mock.calls[0]; + expect(calledWith[0]?.id).toBe(agentInManagedDoc._id); + expect(calledWith[0]?.body).toHaveProperty('doc.unenrollment_started_at'); + }); + + it('can unenroll from managed policy with force=true and revoke=true', async () => { + const { soClient, esClient } = createClientMock(); + await unenrollAgent(soClient, esClient, agentInManagedDoc._id, { force: true, revoke: true }); + // calls ES update with correct values + expect(esClient.update).toBeCalledTimes(1); + const calledWith = esClient.update.mock.calls[0]; + expect(calledWith[0]?.id).toBe(agentInManagedDoc._id); + expect(calledWith[0]?.body).toHaveProperty('doc.unenrolled_at'); + }); }); describe('unenrollAgents (plural)', () => { @@ -68,13 +97,12 @@ describe('unenrollAgents (plural)', () => { .filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); const docs = calledWith?.body.filter((i: any) => i.doc).map((i: any) => i.doc); - expect(ids).toHaveLength(2); expect(ids).toEqual(idsToUnenroll); for (const doc of docs) { expect(doc).toHaveProperty('unenrollment_started_at'); } }); - it('cannot unenroll from a managed policy', async () => { + it('cannot unenroll from a managed policy by default', async () => { const { soClient, esClient } = createClientMock(); const idsToUnenroll = [ @@ -91,12 +119,116 @@ describe('unenrollAgents (plural)', () => { .filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); const docs = calledWith?.body.filter((i: any) => i.doc).map((i: any) => i.doc); - expect(ids).toHaveLength(onlyUnmanaged.length); expect(ids).toEqual(onlyUnmanaged); for (const doc of docs) { expect(doc).toHaveProperty('unenrollment_started_at'); } }); + + it('cannot unenroll from a managed policy with revoke=true', async () => { + const { soClient, esClient } = createClientMock(); + + const idsToUnenroll = [ + agentInUnmanagedDoc._id, + agentInManagedDoc._id, + agentInUnmanagedDoc2._id, + ]; + + const unenrolledResponse = await unenrollAgents(soClient, esClient, { + agentIds: idsToUnenroll, + revoke: true, + }); + + expect(unenrolledResponse.items).toMatchObject([ + { + id: 'agent-in-unmanaged-policy', + success: true, + }, + { + id: 'agent-in-managed-policy', + success: false, + }, + { + id: 'agent-in-unmanaged-policy2', + success: true, + }, + ]); + + // calls ES update with correct values + const onlyUnmanaged = [agentInUnmanagedDoc._id, agentInUnmanagedDoc2._id]; + const calledWith = esClient.bulk.mock.calls[0][0]; + const ids = calledWith?.body + .filter((i: any) => i.update !== undefined) + .map((i: any) => i.update._id); + const docs = calledWith?.body.filter((i: any) => i.doc).map((i: any) => i.doc); + expect(ids).toEqual(onlyUnmanaged); + for (const doc of docs) { + expect(doc).toHaveProperty('unenrolled_at'); + } + }); + + it('can unenroll from managed policy with force=true', async () => { + const { soClient, esClient } = createClientMock(); + const idsToUnenroll = [ + agentInUnmanagedDoc._id, + agentInManagedDoc._id, + agentInUnmanagedDoc2._id, + ]; + await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll, force: true }); + + // calls ES update with correct values + const calledWith = esClient.bulk.mock.calls[1][0]; + const ids = calledWith?.body + .filter((i: any) => i.update !== undefined) + .map((i: any) => i.update._id); + const docs = calledWith?.body.filter((i: any) => i.doc).map((i: any) => i.doc); + expect(ids).toEqual(idsToUnenroll); + for (const doc of docs) { + expect(doc).toHaveProperty('unenrollment_started_at'); + } + }); + + it('can unenroll from managed policy with force=true and revoke=true', async () => { + const { soClient, esClient } = createClientMock(); + + const idsToUnenroll = [ + agentInUnmanagedDoc._id, + agentInManagedDoc._id, + agentInUnmanagedDoc2._id, + ]; + + const unenrolledResponse = await unenrollAgents(soClient, esClient, { + agentIds: idsToUnenroll, + revoke: true, + force: true, + }); + + expect(unenrolledResponse.items).toMatchObject([ + { + id: 'agent-in-unmanaged-policy', + success: true, + }, + { + id: 'agent-in-managed-policy', + success: true, + }, + { + id: 'agent-in-unmanaged-policy2', + success: true, + }, + ]); + + // calls ES update with correct values + const calledWith = esClient.bulk.mock.calls[0][0]; + const ids = calledWith?.body + .filter((i: any) => i.update !== undefined) + .map((i: any) => i.update._id); + const docs = calledWith?.body.filter((i: any) => i.doc).map((i: any) => i.doc); + expect(ids).toEqual(idsToUnenroll); + for (const doc of docs) { + expect(doc).toHaveProperty('unenrolled_at'); + } + }); }); function createClientMock() { diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.ts index 59ec3a0a63206..85bc5eecd78b9 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.ts @@ -39,10 +39,18 @@ async function unenrollAgentIsAllowed( export async function unenrollAgent( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, - agentId: string + agentId: string, + options?: { + force?: boolean; + revoke?: boolean; + } ) { - await unenrollAgentIsAllowed(soClient, esClient, agentId); - + if (!options?.force) { + await unenrollAgentIsAllowed(soClient, esClient, agentId); + } + if (options?.revoke) { + return forceUnenrollAgent(soClient, esClient, agentId); + } const now = new Date().toISOString(); await createAgentAction(soClient, esClient, { agent_id: agentId, @@ -57,15 +65,17 @@ export async function unenrollAgent( export async function unenrollAgents( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, - options: GetAgentsOptions & { force?: boolean } + options: GetAgentsOptions & { + force?: boolean; + revoke?: boolean; + } ): Promise<{ items: BulkActionResult[] }> { // start with all agents specified const givenAgents = await getAgents(esClient, options); - const outgoingErrors: Record = {}; // Filter to those not already unenrolled, or unenrolling const agentsEnrolled = givenAgents.filter((agent) => { - if (options.force) { + if (options.revoke) { return !agent.unenrolled_at; } return !agent.unenrollment_started_at && !agent.unenrolled_at; @@ -76,34 +86,23 @@ export async function unenrollAgents( unenrollAgentIsAllowed(soClient, esClient, agent.id).then((_) => agent) ) ); - const agentsToUpdate = agentResults.reduce((agents, result, index) => { - if (result.status === 'fulfilled') { - agents.push(result.value); - } else { - const id = givenAgents[index].id; - outgoingErrors[id] = result.reason; - } - return agents; - }, []); + const outgoingErrors: Record = {}; + const agentsToUpdate = options.force + ? agentsEnrolled + : agentResults.reduce((agents, result, index) => { + if (result.status === 'fulfilled') { + agents.push(result.value); + } else { + const id = givenAgents[index].id; + outgoingErrors[id] = result.reason; + } + return agents; + }, []); const now = new Date().toISOString(); - if (options.force) { + if (options.revoke) { // Get all API keys that need to be invalidated - const apiKeys = agentsToUpdate.reduce((keys, agent) => { - if (agent.access_api_key_id) { - keys.push(agent.access_api_key_id); - } - if (agent.default_api_key_id) { - keys.push(agent.default_api_key_id); - } - - return keys; - }, []); - - // Invalidate all API keys - if (apiKeys.length) { - await APIKeyService.invalidateAPIKeys(apiKeys); - } + await invalidateAPIKeysForAgents(agentsToUpdate); } else { // Create unenroll action for each agent await bulkCreateAgentActions( @@ -118,7 +117,7 @@ export async function unenrollAgents( } // Update the necessary agents - const updateData = options.force + const updateData = options.revoke ? { unenrolled_at: now, active: false } : { unenrollment_started_at: now }; @@ -127,39 +126,52 @@ export async function unenrollAgents( agentsToUpdate.map(({ id }) => ({ agentId: id, data: updateData })) ); - const out = { - items: givenAgents.map((agent, index) => { - const hasError = agent.id in outgoingErrors; - const result: BulkActionResult = { - id: agent.id, - success: !hasError, - }; - if (hasError) { - result.error = outgoingErrors[agent.id]; - } - return result; - }), + const getResultForAgent = (agent: Agent) => { + const hasError = agent.id in outgoingErrors; + const result: BulkActionResult = { + id: agent.id, + success: !hasError, + }; + if (hasError) { + result.error = outgoingErrors[agent.id]; + } + return result; }; - return out; + + return { + items: givenAgents.map(getResultForAgent), + }; +} + +export async function invalidateAPIKeysForAgents(agents: Agent[]) { + const apiKeys = agents.reduce((keys, agent) => { + if (agent.access_api_key_id) { + keys.push(agent.access_api_key_id); + } + if (agent.default_api_key_id) { + keys.push(agent.default_api_key_id); + } + + return keys; + }, []); + + if (apiKeys.length) { + await APIKeyService.invalidateAPIKeys(apiKeys); + } } export async function forceUnenrollAgent( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, - agentId: string + agentIdOrAgent: string | Agent ) { - const agent = await getAgentById(esClient, agentId); - - await Promise.all([ - agent.access_api_key_id - ? APIKeyService.invalidateAPIKeys([agent.access_api_key_id]) - : undefined, - agent.default_api_key_id - ? APIKeyService.invalidateAPIKeys([agent.default_api_key_id]) - : undefined, - ]); + const agent = + typeof agentIdOrAgent === 'string' + ? await getAgentById(esClient, agentIdOrAgent) + : agentIdOrAgent; - await updateAgent(esClient, agentId, { + await invalidateAPIKeysForAgents([agent]); + await updateAgent(esClient, agent.id, { active: false, unenrolled_at: new Date().toISOString(), }); diff --git a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts index e74a4e6ed55bd..a58849ee4ab4b 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts @@ -172,7 +172,8 @@ export const PostAgentUnenrollRequestSchema = { }), body: schema.nullable( schema.object({ - force: schema.boolean(), + force: schema.maybe(schema.boolean()), + revoke: schema.maybe(schema.boolean()), }) ), }; @@ -181,6 +182,7 @@ export const PostBulkAgentUnenrollRequestSchema = { body: schema.object({ agents: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]), force: schema.maybe(schema.boolean()), + revoke: schema.maybe(schema.boolean()), }), }; diff --git a/x-pack/test/fleet_api_integration/apis/agents/reassign.ts b/x-pack/test/fleet_api_integration/apis/agents/reassign.ts index 5737794eefeab..f8a38913ecfe2 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/reassign.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/reassign.ts @@ -181,7 +181,7 @@ export default function (providerContext: FtrProviderContext) { .post(`/api/fleet/agents/bulk_reassign`) .set('kbn-xsrf', 'xxx') .send({ - agents: 'fleet-agents.active: true', + agents: 'active: true', policy_id: 'policy2', }) .expect(200); diff --git a/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts b/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts index d7e16b7e7224b..64665d87c82d6 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts @@ -94,12 +94,12 @@ export default function (providerContext: FtrProviderContext) { await supertest.post(`/api/fleet/agents/agent1/unenroll`).set('kbn-xsrf', 'xxx').expect(200); }); - it('/agents/{agent_id}/unenroll { force: true } should invalidate related API keys', async () => { + it('/agents/{agent_id}/unenroll { revoke: true } should invalidate related API keys', async () => { await supertest .post(`/api/fleet/agents/agent1/unenroll`) .set('kbn-xsrf', 'xxx') .send({ - force: true, + revoke: true, }) .expect(200); @@ -116,7 +116,7 @@ export default function (providerContext: FtrProviderContext) { expect(outputAPIKeys[0].invalidated).eql(true); }); - it('/agents/{agent_id}/bulk_unenroll should not allow unenroll from managed policy', async () => { + it('/agents/bulk_unenroll should not allow unenroll from managed policy', async () => { // set policy to managed await supertest .put(`/api/fleet/agent_policies/policy1`) @@ -157,7 +157,7 @@ export default function (providerContext: FtrProviderContext) { expect(agent2data.body.item.active).to.eql(true); }); - it('/agents/{agent_id}/bulk_unenroll should allow to unenroll multiple agents by id from an unmanaged policy', async () => { + it('/agents/bulk_unenroll should allow to unenroll multiple agents by id from an unmanaged policy', async () => { // set policy to unmanaged await supertest .put(`/api/fleet/agent_policies/policy1`) @@ -187,13 +187,13 @@ export default function (providerContext: FtrProviderContext) { expect(agent2data.body.item.active).to.eql(true); }); - it('/agents/{agent_id}/bulk_unenroll should allow to unenroll multiple agents by kuery', async () => { + it('/agents/bulk_unenroll should allow to unenroll multiple agents by kuery', async () => { await supertest .post(`/api/fleet/agents/bulk_unenroll`) .set('kbn-xsrf', 'xxx') .send({ - agents: 'fleet-agents.active: true', - force: true, + agents: 'active: true', + revoke: true, }) .expect(200); diff --git a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts index 008614f075514..545399134c79d 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts @@ -167,7 +167,7 @@ export default function (providerContext: FtrProviderContext) { it('should respond 400 if trying to upgrade an agent that is unenrolling', async () => { const kibanaVersion = await kibanaServer.version.get(); await supertest.post(`/api/fleet/agents/agent1/unenroll`).set('kbn-xsrf', 'xxx').send({ - force: true, + revoke: true, }); await supertest .post(`/api/fleet/agents/agent1/upgrade`) @@ -331,7 +331,7 @@ export default function (providerContext: FtrProviderContext) { it('should not upgrade an unenrolling agent during bulk_upgrade', async () => { const kibanaVersion = await kibanaServer.version.get(); await supertest.post(`/api/fleet/agents/agent1/unenroll`).set('kbn-xsrf', 'xxx').send({ - force: true, + revoke: true, }); await es.update({ id: 'agent1', From 82b70824598a6f8bbd9ccad6d383a3c0f601b719 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 14 Apr 2021 16:41:05 -0700 Subject: [PATCH 42/43] skip flaky suite (#97085) --- .../api_keys/api_keys_grid/api_keys_grid_page.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx index ba879e99f1598..72fd79805f970 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx @@ -70,7 +70,8 @@ authc.getCurrentUser.mockResolvedValue( }) ); -describe('APIKeysGridPage', () => { +// FLAKY: https://github.com/elastic/kibana/issues/97085 +describe.skip('APIKeysGridPage', () => { it('loads and displays API keys', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }); From 2bb97f234a6f77d55eec1038fb855fe3ba42af48 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 15 Apr 2021 02:33:38 +0100 Subject: [PATCH 43/43] chore(NA): moving @kbn/utility-types into bazel (#97151) * chore(NA): moving @kbn/utility-types into bazel * chore(NA): solve ts config errors --- .../monorepo-packages.asciidoc | 1 + package.json | 2 +- packages/BUILD.bazel | 1 + packages/kbn-utility-types/BUILD.bazel | 80 +++++++++++++++++++ packages/kbn-utility-types/package.json | 6 +- packages/kbn-utility-types/tsconfig.json | 6 +- .../kbn-utility-types/tsd_tests/empty.d.ts | 9 +++ .../kbn-utility-types/tsd_tests/package.json | 9 +++ .../test_d}/method_keys_of.ts | 3 +- .../test_d}/public_contract.ts | 3 +- .../test_d}/public_keys.ts | 3 +- .../test_d}/public_methods_of.ts | 3 +- .../test_d}/shallow_promise.ts | 3 +- .../test_d}/union_to_intersection.ts | 3 +- .../test_d}/unwrap_observable.ts | 3 +- .../test_d}/unwrap_promise.ts | 3 +- .../{test-d => tsd_tests/test_d}/values.ts | 3 +- .../{test-d => tsd_tests/test_d}/writable.ts | 3 +- x-pack/plugins/cases/server/client/mocks.ts | 2 +- .../elasticsearch/transform/transform.test.ts | 2 +- yarn.lock | 2 +- 21 files changed, 128 insertions(+), 22 deletions(-) create mode 100644 packages/kbn-utility-types/BUILD.bazel create mode 100644 packages/kbn-utility-types/tsd_tests/empty.d.ts create mode 100644 packages/kbn-utility-types/tsd_tests/package.json rename packages/kbn-utility-types/{test-d => tsd_tests/test_d}/method_keys_of.ts (84%) rename packages/kbn-utility-types/{test-d => tsd_tests/test_d}/public_contract.ts (83%) rename packages/kbn-utility-types/{test-d => tsd_tests/test_d}/public_keys.ts (83%) rename packages/kbn-utility-types/{test-d => tsd_tests/test_d}/public_methods_of.ts (88%) rename packages/kbn-utility-types/{test-d => tsd_tests/test_d}/shallow_promise.ts (87%) rename packages/kbn-utility-types/{test-d => tsd_tests/test_d}/union_to_intersection.ts (82%) rename packages/kbn-utility-types/{test-d => tsd_tests/test_d}/unwrap_observable.ts (79%) rename packages/kbn-utility-types/{test-d => tsd_tests/test_d}/unwrap_promise.ts (84%) rename packages/kbn-utility-types/{test-d => tsd_tests/test_d}/values.ts (89%) rename packages/kbn-utility-types/{test-d => tsd_tests/test_d}/writable.ts (85%) diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index fc78729be5a69..bc47e46f6763b 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -65,4 +65,5 @@ yarn kbn watch-bazel - @kbn/apm-utils - @kbn/config-schema - @kbn/tinymath +- @kbn/utility-types diff --git a/package.json b/package.json index ff7f76df4aee5..cc2532704114f 100644 --- a/package.json +++ b/package.json @@ -141,7 +141,7 @@ "@kbn/tinymath": "link:bazel-bin/packages/kbn-tinymath/npm_module", "@kbn/ui-framework": "link:packages/kbn-ui-framework", "@kbn/ui-shared-deps": "link:packages/kbn-ui-shared-deps", - "@kbn/utility-types": "link:packages/kbn-utility-types", + "@kbn/utility-types": "link:bazel-bin/packages/kbn-utility-types/npm_module", "@kbn/utils": "link:packages/kbn-utils", "@loaders.gl/core": "^2.3.1", "@loaders.gl/json": "^2.3.1", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 182013c356bb0..fe0e8efe0d44f 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -7,5 +7,6 @@ filegroup( "//packages/kbn-apm-utils:build", "//packages/kbn-config-schema:build", "//packages/kbn-tinymath:build", + "//packages/kbn-utility-types:build", ], ) diff --git a/packages/kbn-utility-types/BUILD.bazel b/packages/kbn-utility-types/BUILD.bazel new file mode 100644 index 0000000000000..e22ba38b24a48 --- /dev/null +++ b/packages/kbn-utility-types/BUILD.bazel @@ -0,0 +1,80 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-utility-types" +PKG_REQUIRE_NAME = "@kbn/utility-types" + +SOURCE_FILES = glob([ + "jest/index.ts", + "index.ts" +]) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "jest/package.json", + "package.json", + "README.md", +] + +SRC_DEPS = [ + "@npm//utility-types", +] + +TYPES_DEPS = [ + "@npm//@types/node", + "@npm//@types/jest", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = ".", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = [], + deps = [":tsc"] + DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + srcs = NPM_MODULE_EXTRA_FILES, + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-utility-types/package.json b/packages/kbn-utility-types/package.json index ad7dcc6b906c3..95fbd5d00f395 100644 --- a/packages/kbn-utility-types/package.json +++ b/packages/kbn-utility-types/package.json @@ -9,10 +9,6 @@ "devOnly": false }, "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "../../node_modules/.bin/tsc", - "kbn:watch": "../../node_modules/.bin/tsc --watch", - "test": "../../node_modules/.bin/tsd", - "clean": "../../node_modules/.bin/del target" + "test": "../../node_modules/.bin/tsd tsd_tests" } } \ No newline at end of file diff --git a/packages/kbn-utility-types/tsconfig.json b/packages/kbn-utility-types/tsconfig.json index cfa782e5d38d2..50fa71155bee8 100644 --- a/packages/kbn-utility-types/tsconfig.json +++ b/packages/kbn-utility-types/tsconfig.json @@ -1,12 +1,12 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "./target", - "declarationDir": "./target", "stripInternal": true, "declaration": true, "declarationMap": true, + "rootDir": "./", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-utility-types", "types": [ @@ -17,6 +17,6 @@ "include": [ "index.ts", "jest/**/*", - "test-d/**/*" + "tsd_tests/**/*" ] } diff --git a/packages/kbn-utility-types/tsd_tests/empty.d.ts b/packages/kbn-utility-types/tsd_tests/empty.d.ts new file mode 100644 index 0000000000000..c5184fc78704b --- /dev/null +++ b/packages/kbn-utility-types/tsd_tests/empty.d.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// This is just an empty mock file to provide a workaround to run tsd correctly isolated diff --git a/packages/kbn-utility-types/tsd_tests/package.json b/packages/kbn-utility-types/tsd_tests/package.json new file mode 100644 index 0000000000000..fb549abca3197 --- /dev/null +++ b/packages/kbn-utility-types/tsd_tests/package.json @@ -0,0 +1,9 @@ +{ + "types": "empty.d.ts", + "tsd": { + "directory": "test_d", + "compilerOptions": { + "rootDir": "../" + } + } +} \ No newline at end of file diff --git a/packages/kbn-utility-types/test-d/method_keys_of.ts b/packages/kbn-utility-types/tsd_tests/test_d/method_keys_of.ts similarity index 84% rename from packages/kbn-utility-types/test-d/method_keys_of.ts rename to packages/kbn-utility-types/tsd_tests/test_d/method_keys_of.ts index 03ef517c76b68..6169f2d92f81b 100644 --- a/packages/kbn-utility-types/test-d/method_keys_of.ts +++ b/packages/kbn-utility-types/tsd_tests/test_d/method_keys_of.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ +// eslint-disable-next-line import/no-extraneous-dependencies import { expectType } from 'tsd'; -import { MethodKeysOf } from '../index'; +import { MethodKeysOf } from '../../index'; class Test { public name: string = ''; diff --git a/packages/kbn-utility-types/test-d/public_contract.ts b/packages/kbn-utility-types/tsd_tests/test_d/public_contract.ts similarity index 83% rename from packages/kbn-utility-types/test-d/public_contract.ts rename to packages/kbn-utility-types/tsd_tests/test_d/public_contract.ts index 53e657084fec4..ef488f42805ee 100644 --- a/packages/kbn-utility-types/test-d/public_contract.ts +++ b/packages/kbn-utility-types/tsd_tests/test_d/public_contract.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ +// eslint-disable-next-line import/no-extraneous-dependencies import { expectType } from 'tsd'; -import { PublicContract } from '../index'; +import { PublicContract } from '../../index'; class Test { public str: string = ''; diff --git a/packages/kbn-utility-types/test-d/public_keys.ts b/packages/kbn-utility-types/tsd_tests/test_d/public_keys.ts similarity index 83% rename from packages/kbn-utility-types/test-d/public_keys.ts rename to packages/kbn-utility-types/tsd_tests/test_d/public_keys.ts index d9377579f0011..1674520daffba 100644 --- a/packages/kbn-utility-types/test-d/public_keys.ts +++ b/packages/kbn-utility-types/tsd_tests/test_d/public_keys.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ +// eslint-disable-next-line import/no-extraneous-dependencies import { expectType } from 'tsd'; -import { PublicKeys } from '../index'; +import { PublicKeys } from '../../index'; class Test { public str: string = ''; diff --git a/packages/kbn-utility-types/test-d/public_methods_of.ts b/packages/kbn-utility-types/tsd_tests/test_d/public_methods_of.ts similarity index 88% rename from packages/kbn-utility-types/test-d/public_methods_of.ts rename to packages/kbn-utility-types/tsd_tests/test_d/public_methods_of.ts index 66754f8473846..5db1117bf47f3 100644 --- a/packages/kbn-utility-types/test-d/public_methods_of.ts +++ b/packages/kbn-utility-types/tsd_tests/test_d/public_methods_of.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ +// eslint-disable-next-line import/no-extraneous-dependencies import { expectAssignable, expectNotAssignable } from 'tsd'; -import { PublicMethodsOf } from '../index'; +import { PublicMethodsOf } from '../../index'; class Test { public name: string = ''; diff --git a/packages/kbn-utility-types/test-d/shallow_promise.ts b/packages/kbn-utility-types/tsd_tests/test_d/shallow_promise.ts similarity index 87% rename from packages/kbn-utility-types/test-d/shallow_promise.ts rename to packages/kbn-utility-types/tsd_tests/test_d/shallow_promise.ts index 4b806a2860626..712189f43bfe2 100644 --- a/packages/kbn-utility-types/test-d/shallow_promise.ts +++ b/packages/kbn-utility-types/tsd_tests/test_d/shallow_promise.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ +// eslint-disable-next-line import/no-extraneous-dependencies import { expectType } from 'tsd'; -import { ShallowPromise } from '../index'; +import { ShallowPromise } from '../../index'; type P1 = ShallowPromise; type P2 = ShallowPromise>; diff --git a/packages/kbn-utility-types/test-d/union_to_intersection.ts b/packages/kbn-utility-types/tsd_tests/test_d/union_to_intersection.ts similarity index 82% rename from packages/kbn-utility-types/test-d/union_to_intersection.ts rename to packages/kbn-utility-types/tsd_tests/test_d/union_to_intersection.ts index 07c8cfb4cfd3f..a37cdc5160edb 100644 --- a/packages/kbn-utility-types/test-d/union_to_intersection.ts +++ b/packages/kbn-utility-types/tsd_tests/test_d/union_to_intersection.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ +// eslint-disable-next-line import/no-extraneous-dependencies import { expectAssignable } from 'tsd'; -import { UnionToIntersection } from '../index'; +import { UnionToIntersection } from '../../index'; type INTERSECTED = UnionToIntersection<{ foo: 'bar' } | { baz: 'qux' }>; diff --git a/packages/kbn-utility-types/test-d/unwrap_observable.ts b/packages/kbn-utility-types/tsd_tests/test_d/unwrap_observable.ts similarity index 79% rename from packages/kbn-utility-types/test-d/unwrap_observable.ts rename to packages/kbn-utility-types/tsd_tests/test_d/unwrap_observable.ts index 667ae22984d90..beaf692341615 100644 --- a/packages/kbn-utility-types/test-d/unwrap_observable.ts +++ b/packages/kbn-utility-types/tsd_tests/test_d/unwrap_observable.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ +// eslint-disable-next-line import/no-extraneous-dependencies import { expectAssignable } from 'tsd'; -import { UnwrapObservable, ObservableLike } from '../index'; +import { UnwrapObservable, ObservableLike } from '../../index'; type STRING = UnwrapObservable>; diff --git a/packages/kbn-utility-types/test-d/unwrap_promise.ts b/packages/kbn-utility-types/tsd_tests/test_d/unwrap_promise.ts similarity index 84% rename from packages/kbn-utility-types/test-d/unwrap_promise.ts rename to packages/kbn-utility-types/tsd_tests/test_d/unwrap_promise.ts index 9384f58f7fdea..6491555b883bf 100644 --- a/packages/kbn-utility-types/test-d/unwrap_promise.ts +++ b/packages/kbn-utility-types/tsd_tests/test_d/unwrap_promise.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ +// eslint-disable-next-line import/no-extraneous-dependencies import { expectAssignable } from 'tsd'; -import { UnwrapPromise } from '../index'; +import { UnwrapPromise } from '../../index'; type STRING = UnwrapPromise>; type TUPLE = UnwrapPromise>; diff --git a/packages/kbn-utility-types/test-d/values.ts b/packages/kbn-utility-types/tsd_tests/test_d/values.ts similarity index 89% rename from packages/kbn-utility-types/test-d/values.ts rename to packages/kbn-utility-types/tsd_tests/test_d/values.ts index 099e94c6b549d..aeb867b78e13d 100644 --- a/packages/kbn-utility-types/test-d/values.ts +++ b/packages/kbn-utility-types/tsd_tests/test_d/values.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ +// eslint-disable-next-line import/no-extraneous-dependencies import { expectAssignable } from 'tsd'; -import { Values } from '../index'; +import { Values } from '../../index'; // Arrays type STRING = Values; diff --git a/packages/kbn-utility-types/test-d/writable.ts b/packages/kbn-utility-types/tsd_tests/test_d/writable.ts similarity index 85% rename from packages/kbn-utility-types/test-d/writable.ts rename to packages/kbn-utility-types/tsd_tests/test_d/writable.ts index a9fbf4a1def8f..cfaba555a7980 100644 --- a/packages/kbn-utility-types/test-d/writable.ts +++ b/packages/kbn-utility-types/tsd_tests/test_d/writable.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ +// eslint-disable-next-line import/no-extraneous-dependencies import { expectAssignable } from 'tsd'; -import { Writable } from '../index'; +import { Writable } from '../../index'; type WritableArray = Writable; expectAssignable(['1']); diff --git a/x-pack/plugins/cases/server/client/mocks.ts b/x-pack/plugins/cases/server/client/mocks.ts index 51119070a798d..4c0f89cf77a67 100644 --- a/x-pack/plugins/cases/server/client/mocks.ts +++ b/x-pack/plugins/cases/server/client/mocks.ts @@ -6,7 +6,7 @@ */ import { ElasticsearchClient } from 'kibana/server'; -import { DeeplyMockedKeys } from 'packages/kbn-utility-types/target/jest'; +import { DeeplyMockedKeys } from '@kbn/utility-types/target/jest'; import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { AlertServiceContract, diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/transform.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/transform.test.ts index 732f03440ce9d..7fc8d59628738 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/transform.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/transform.test.ts @@ -19,7 +19,7 @@ jest.mock('./common', () => { }); import { ResponseError } from '@elastic/elasticsearch/lib/errors'; -import type { DeeplyMockedKeys } from 'packages/kbn-utility-types/target/jest'; +import type { DeeplyMockedKeys } from '@kbn/utility-types/target/jest'; import type { ElasticsearchClient, SavedObject, SavedObjectsClientContract } from 'kibana/server'; import { ElasticsearchAssetType } from '../../../../types'; diff --git a/yarn.lock b/yarn.lock index 2aaf94250b966..c43641d668ae2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2752,7 +2752,7 @@ version "0.0.0" uid "" -"@kbn/utility-types@link:packages/kbn-utility-types": +"@kbn/utility-types@link:bazel-bin/packages/kbn-utility-types/npm_module": version "0.0.0" uid ""