From d5883beb25fd868fffcde73ea1a74c9e9b21c3ab Mon Sep 17 00:00:00 2001 From: ymao1 Date: Wed, 24 Mar 2021 10:19:35 -0400 Subject: [PATCH 1/8] Adding ES query rule type to stack alerts feature privilege (#95225) --- x-pack/plugins/stack_alerts/server/feature.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/stack_alerts/server/feature.ts b/x-pack/plugins/stack_alerts/server/feature.ts index d421832a4cd970..9f91398cc7d24b 100644 --- a/x-pack/plugins/stack_alerts/server/feature.ts +++ b/x-pack/plugins/stack_alerts/server/feature.ts @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import { ID as IndexThreshold } from './alert_types/index_threshold/alert_type'; import { GEO_CONTAINMENT_ID as GeoContainment } from './alert_types/geo_containment/alert_type'; +import { ES_QUERY_ID as ElasticsearchQuery } from './alert_types/es_query/alert_type'; import { STACK_ALERTS_FEATURE_ID } from '../common'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; @@ -21,7 +22,7 @@ export const BUILT_IN_ALERTS_FEATURE = { management: { insightsAndAlerting: ['triggersActions'], }, - alerting: [IndexThreshold, GeoContainment], + alerting: [IndexThreshold, GeoContainment, ElasticsearchQuery], privileges: { all: { app: [], @@ -30,7 +31,7 @@ export const BUILT_IN_ALERTS_FEATURE = { insightsAndAlerting: ['triggersActions'], }, alerting: { - all: [IndexThreshold, GeoContainment], + all: [IndexThreshold, GeoContainment, ElasticsearchQuery], read: [], }, savedObject: { @@ -48,7 +49,7 @@ export const BUILT_IN_ALERTS_FEATURE = { }, alerting: { all: [], - read: [IndexThreshold, GeoContainment], + read: [IndexThreshold, GeoContainment, ElasticsearchQuery], }, savedObject: { all: [], From edaa64f150d804123645778ea3de01e0c4b143d7 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Wed, 24 Mar 2021 15:38:58 +0100 Subject: [PATCH 2/8] [ML] Functional tests for Anomaly swim lane (#94723) * [ML] update @elastic/charts * [ML] swim lane service, axes tests * [ML] check single cell selection and current URL * [ML] clear selection * [ML] assert anomaly explorer charts * [ML] fix unit test * [ML] assert anomalies table and top influencers list * [ML] update apiDoc version * [ML] exclude host from the URL assertion * [ML] clicks view by swim lane * [ML] fix method for cell selection * [ML] brush action tests * [ML] use debug state flag * [ML] declare window interface * [ML] pagination tests * [ML] enable test * [ML] scroll into view for swim lane actions * [ML] rename URL assertion method * [ML] fix assertion for charts count * [ML] extend assertion * [ML] refactor test subjects selection * [ML] fix assertSelection * [ML] reduce timeout for charts assertion --- .../services/visualizations/elastic_chart.ts | 6 +- .../application/explorer/anomaly_timeline.tsx | 6 +- .../explorer_charts_container.js | 2 +- .../explorer_charts_container.test.js | 2 +- .../explorer/swimlane_container.tsx | 25 ++- .../explorer/swimlane_pagination.tsx | 17 +- x-pack/plugins/ml/server/routes/apidoc.json | 7 +- .../routes/apidoc_scripts/version_filter.ts | 2 +- .../ml/anomaly_detection/anomaly_explorer.ts | 188 ++++++++++++++++ .../functional/services/ml/anomalies_table.ts | 8 + .../services/ml/anomaly_explorer.ts | 25 +++ x-pack/test/functional/services/ml/index.ts | 3 + .../test/functional/services/ml/navigation.ts | 23 +- .../test/functional/services/ml/swim_lane.ts | 212 ++++++++++++++++++ 14 files changed, 504 insertions(+), 22 deletions(-) create mode 100644 x-pack/test/functional/services/ml/swim_lane.ts diff --git a/test/functional/services/visualizations/elastic_chart.ts b/test/functional/services/visualizations/elastic_chart.ts index 010394b2752286..80483100a06dd3 100644 --- a/test/functional/services/visualizations/elastic_chart.ts +++ b/test/functional/services/visualizations/elastic_chart.ts @@ -29,7 +29,11 @@ export function ElasticChartProvider({ getService }: FtrProviderContext) { const browser = getService('browser'); class ElasticChart { - public async getCanvas() { + public async getCanvas(dataTestSubj?: string) { + if (dataTestSubj) { + const chart = await this.getChart(dataTestSubj); + return await chart.findByClassName('echCanvasRenderer'); + } return await find.byCssSelector('.echChart canvas:last-of-type'); } diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx index 4289986bb6a594..7c63d4087ce1e0 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -160,7 +160,11 @@ export const AnomalyTimeline: FC = React.memo( {selectedCells ? ( - + - + {seriesToUse.length > 0 && seriesToUse.map((series) => ( { ); expect(wrapper.html()).toBe( - '
' + '
' ); }); 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 8deffa15cd6bd7..c108257094b6aa 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -26,6 +26,8 @@ import { HeatmapSpec, TooltipSettings, HeatmapBrushEvent, + Position, + ScaleType, } from '@elastic/charts'; import moment from 'moment'; @@ -44,6 +46,15 @@ import './_explorer.scss'; import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control'; import { useUiSettings } from '../contexts/kibana'; +declare global { + interface Window { + /** + * Flag used to enable debugState on elastic charts + */ + _echDebugStateFlag?: boolean; + } +} + /** * Ignore insignificant resize, e.g. browser scrollbar appearance. */ @@ -352,7 +363,7 @@ export const SwimlaneContainer: FC = ({ direction={'column'} style={{ width: '100%', height: '100%', overflow: 'hidden' }} ref={resizeRef} - data-test-subj="mlSwimLaneContainer" + data-test-subj={dataTestSubj} > = ({ }} grow={false} > -
+
{showSwimlane && !isLoading && ( = ({ valueAccessor="value" highlightedData={highlightedData} valueFormatter={getFormattedSeverityScore} - xScaleType="time" + xScaleType={ScaleType.Time} ySortPredicate="dataIndex" config={swimLaneConfig} /> diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_pagination.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_pagination.tsx index 8b205d2b8d5a17..18297d06dd6fe9 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_pagination.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_pagination.tsx @@ -57,6 +57,7 @@ export const SwimLanePagination: FC = ({ closePopover(); setPerPage(v); }} + data-test-subj={`${v} rows`} > = ({ iconType="arrowDown" iconSide="right" onClick={onButtonClick} + data-test-subj="mlSwimLanePageSizeControl" > - + + + } isOpen={isPopoverOpen} closePopover={closePopover} panelPaddingSize="none" > - + @@ -102,6 +106,7 @@ export const SwimLanePagination: FC = ({ pageCount={pageCount} activePage={componentFromPage} onPageClick={goToPage} + data-test-subj="mlSwimLanePagination" /> diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index 1a10046380658a..ba61a987d69ef1 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -1,6 +1,6 @@ { "name": "ml_kibana_api", - "version": "7.11.0", + "version": "7.13.0", "description": "This is the documentation of the REST API provided by the Machine Learning Kibana plugin. Each API is experimental and can include breaking changes in any version.", "title": "ML Kibana API", "order": [ @@ -159,6 +159,9 @@ "GetTrainedModel", "GetTrainedModelStats", "GetTrainedModelPipelines", - "DeleteTrainedModel" + "DeleteTrainedModel", + + "Alerting", + "PreviewAlert" ] } diff --git a/x-pack/plugins/ml/server/routes/apidoc_scripts/version_filter.ts b/x-pack/plugins/ml/server/routes/apidoc_scripts/version_filter.ts index ad00915f28d6d1..430f105fb27d41 100644 --- a/x-pack/plugins/ml/server/routes/apidoc_scripts/version_filter.ts +++ b/x-pack/plugins/ml/server/routes/apidoc_scripts/version_filter.ts @@ -7,7 +7,7 @@ import { Block } from './types'; -const API_VERSION = '7.8.0'; +const API_VERSION = '7.13.0'; /** * Post Filter parsed results. diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts b/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts index 086b6c7e7f9d7b..ff38544fa8c030 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts @@ -5,6 +5,7 @@ * 2.0. */ +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; import { Job, Datafeed } from '../../../../../plugins/ml/common/types/anomaly_detection_jobs'; @@ -51,9 +52,15 @@ const testDataList = [ }, ]; +const cellSize = 15; + +const overallSwimLaneTestSubj = 'mlAnomalyExplorerSwimlaneOverall'; +const viewBySwimLaneTestSubj = 'mlAnomalyExplorerSwimlaneViewBy'; + export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); + const elasticChart = getService('elasticChart'); describe('anomaly explorer', function () { this.tags(['mlqa']); @@ -76,12 +83,16 @@ export default function ({ getService }: FtrProviderContext) { }); after(async () => { + await elasticChart.setNewChartUiDebugFlag(false); await ml.api.cleanMlIndices(); }); it('opens a job from job list link', async () => { await ml.testExecution.logTestStep('navigate to job list'); await ml.navigation.navigateToMl(); + // Set debug state has to happen at this point + // because page refresh happens after navigation to the ML app. + await elasticChart.setNewChartUiDebugFlag(true); await ml.navigation.navigateToJobManagement(); await ml.testExecution.logTestStep('open job in anomaly explorer'); @@ -126,6 +137,183 @@ export default function ({ getService }: FtrProviderContext) { await ml.anomaliesTable.assertTableNotEmpty(); }); + it('renders Overall swim lane', async () => { + await ml.testExecution.logTestStep('has correct axes labels'); + await ml.swimLane.assertAxisLabels(overallSwimLaneTestSubj, 'x', [ + '2016-02-07 00:00', + '2016-02-08 00:00', + '2016-02-09 00:00', + '2016-02-10 00:00', + '2016-02-11 00:00', + '2016-02-12 00:00', + ]); + await ml.swimLane.assertAxisLabels(overallSwimLaneTestSubj, 'y', ['Overall']); + }); + + it('renders View By swim lane', async () => { + await ml.testExecution.logTestStep('has correct axes labels'); + await ml.swimLane.assertAxisLabels(viewBySwimLaneTestSubj, 'x', [ + '2016-02-07 00:00', + '2016-02-08 00:00', + '2016-02-09 00:00', + '2016-02-10 00:00', + '2016-02-11 00:00', + '2016-02-12 00:00', + ]); + await ml.swimLane.assertAxisLabels(viewBySwimLaneTestSubj, 'y', [ + 'AAL', + 'VRD', + 'EGF', + 'SWR', + 'AMX', + 'JZA', + 'TRS', + 'ACA', + 'BAW', + 'ASA', + ]); + }); + + it('supports cell selection by click on Overall swim lane', async () => { + await ml.testExecution.logTestStep('checking page state before the cell selection'); + await ml.anomalyExplorer.assertClearSelectionButtonVisible(false); + await ml.anomaliesTable.assertTableRowsCount(25); + await ml.anomalyExplorer.assertInfluencerFieldListLength('airline', 10); + await ml.anomalyExplorer.assertAnomalyExplorerChartsCount(0); + + await ml.testExecution.logTestStep('clicks on the Overall swim lane cell'); + const sampleCell = (await ml.swimLane.getCells(overallSwimLaneTestSubj))[0]; + await ml.swimLane.selectSingleCell(overallSwimLaneTestSubj, { + x: sampleCell.x + cellSize, + y: sampleCell.y + cellSize, + }); + // TODO extend cell data with X and Y values, and cell width + await ml.swimLane.assertSelection(overallSwimLaneTestSubj, { + x: [1454846400000, 1454860800000], + y: ['Overall'], + }); + await ml.anomalyExplorer.assertClearSelectionButtonVisible(true); + + await ml.testExecution.logTestStep('updates the View By swim lane'); + await ml.swimLane.assertAxisLabels(viewBySwimLaneTestSubj, 'y', ['EGF', 'DAL']); + + await ml.testExecution.logTestStep('renders anomaly explorer charts'); + await ml.anomalyExplorer.assertAnomalyExplorerChartsCount(4); + + await ml.testExecution.logTestStep('updates top influencers list'); + await ml.anomalyExplorer.assertInfluencerFieldListLength('airline', 2); + + await ml.testExecution.logTestStep('updates anomalies table'); + await ml.anomaliesTable.assertTableRowsCount(4); + + await ml.testExecution.logTestStep('updates the URL state'); + await ml.navigation.assertCurrentURLContains( + 'selectedLanes%3A!(Overall)%2CselectedTimes%3A!(1454846400%2C1454860800)%2CselectedType%3Aoverall%2CshowTopFieldValues%3A!t%2CviewByFieldName%3Aairline%2CviewByFromPage%3A1%2CviewByPerPage%3A10' + ); + + await ml.testExecution.logTestStep('clears the selection'); + await ml.anomalyExplorer.clearSwimLaneSelection(); + await ml.navigation.assertCurrentURLNotContain( + 'selectedLanes%3A!(Overall)%2CselectedTimes%3A!(1454846400%2C1454860800)%2CselectedType%3Aoverall%2CshowTopFieldValues%3A!t%2CviewByFieldName%3Aairline%2CviewByFromPage%3A1%2CviewByPerPage%3A10' + ); + await ml.anomaliesTable.assertTableRowsCount(25); + await ml.anomalyExplorer.assertInfluencerFieldListLength('airline', 10); + await ml.anomalyExplorer.assertAnomalyExplorerChartsCount(0); + }); + + it('allows to change the swim lane pagination', async () => { + await ml.testExecution.logTestStep('checks default pagination'); + await ml.swimLane.assertPageSize(viewBySwimLaneTestSubj, 10); + await ml.swimLane.assertActivePage(viewBySwimLaneTestSubj, 1); + + await ml.testExecution.logTestStep('updates pagination'); + await ml.swimLane.setPageSize(viewBySwimLaneTestSubj, 5); + + const axisLabels = await ml.swimLane.getAxisLabels(viewBySwimLaneTestSubj, 'y'); + expect(axisLabels.length).to.eql(5); + + await ml.swimLane.selectPage(viewBySwimLaneTestSubj, 3); + + await ml.testExecution.logTestStep('resets pagination'); + await ml.swimLane.setPageSize(viewBySwimLaneTestSubj, 10); + await ml.swimLane.assertActivePage(viewBySwimLaneTestSubj, 1); + }); + + it('supports cell selection by click on View By swim lane', async () => { + await ml.testExecution.logTestStep('checking page state before the cell selection'); + await ml.anomalyExplorer.assertClearSelectionButtonVisible(false); + await ml.anomaliesTable.assertTableRowsCount(25); + await ml.anomalyExplorer.assertInfluencerFieldListLength('airline', 10); + await ml.anomalyExplorer.assertAnomalyExplorerChartsCount(0); + + await ml.testExecution.logTestStep('clicks on the View By swim lane cell'); + await ml.anomalyExplorer.assertSwimlaneViewByExists(); + const sampleCell = (await ml.swimLane.getCells(viewBySwimLaneTestSubj))[0]; + await ml.swimLane.selectSingleCell(viewBySwimLaneTestSubj, { + x: sampleCell.x + cellSize, + y: sampleCell.y + cellSize, + }); + + await ml.testExecution.logTestStep('check page content'); + await ml.swimLane.assertSelection(viewBySwimLaneTestSubj, { + x: [1454817600000, 1454832000000], + y: ['AAL'], + }); + + await ml.anomaliesTable.assertTableRowsCount(1); + await ml.anomalyExplorer.assertInfluencerFieldListLength('airline', 1); + await ml.anomalyExplorer.assertAnomalyExplorerChartsCount(1); + + await ml.testExecution.logTestStep('highlights the Overall swim lane'); + await ml.swimLane.assertSelection(overallSwimLaneTestSubj, { + x: [1454817600000, 1454832000000], + y: ['Overall'], + }); + + await ml.testExecution.logTestStep('clears the selection'); + await ml.anomalyExplorer.clearSwimLaneSelection(); + await ml.anomaliesTable.assertTableRowsCount(25); + await ml.anomalyExplorer.assertInfluencerFieldListLength('airline', 10); + await ml.anomalyExplorer.assertAnomalyExplorerChartsCount(0); + }); + + it('supports cell selection by brush action', async () => { + await ml.testExecution.logTestStep('checking page state before the cell selection'); + await ml.anomalyExplorer.assertClearSelectionButtonVisible(false); + await ml.anomaliesTable.assertTableRowsCount(25); + await ml.anomalyExplorer.assertInfluencerFieldListLength('airline', 10); + await ml.anomalyExplorer.assertAnomalyExplorerChartsCount(0); + + await ml.anomalyExplorer.assertSwimlaneViewByExists(); + const cells = await ml.swimLane.getCells(viewBySwimLaneTestSubj); + + const sampleCell1 = cells[0]; + // Get cell from another row + const sampleCell2 = cells.find((c) => c.y !== sampleCell1.y); + + await ml.swimLane.selectCells(viewBySwimLaneTestSubj, { + x1: sampleCell1.x + cellSize, + y1: sampleCell1.y + cellSize, + x2: sampleCell2!.x + cellSize, + y2: sampleCell2!.y + cellSize, + }); + + await ml.swimLane.assertSelection(viewBySwimLaneTestSubj, { + x: [1454817600000, 1454846400000], + y: ['AAL', 'VRD'], + }); + + await ml.anomaliesTable.assertTableRowsCount(2); + await ml.anomalyExplorer.assertInfluencerFieldListLength('airline', 2); + await ml.anomalyExplorer.assertAnomalyExplorerChartsCount(2); + + await ml.testExecution.logTestStep('clears the selection'); + await ml.anomalyExplorer.clearSwimLaneSelection(); + await ml.anomaliesTable.assertTableRowsCount(25); + await ml.anomalyExplorer.assertInfluencerFieldListLength('airline', 10); + await ml.anomalyExplorer.assertAnomalyExplorerChartsCount(0); + }); + it('adds swim lane embeddable to a dashboard', async () => { // should be the last step because it navigates away from the Anomaly Explorer page await ml.testExecution.logTestStep( diff --git a/x-pack/test/functional/services/ml/anomalies_table.ts b/x-pack/test/functional/services/ml/anomalies_table.ts index 54109e40a75262..30bb3e67bc862d 100644 --- a/x-pack/test/functional/services/ml/anomalies_table.ts +++ b/x-pack/test/functional/services/ml/anomalies_table.ts @@ -22,6 +22,14 @@ export function MachineLearningAnomaliesTableProvider({ getService }: FtrProvide return await testSubjects.findAll('mlAnomaliesTable > ~mlAnomaliesListRow'); }, + async assertTableRowsCount(expectedCount: number) { + const actualCount = (await this.getTableRows()).length; + expect(actualCount).to.eql( + expectedCount, + `Expect anomaly table rows count to be ${expectedCount}, got ${actualCount}` + ); + }, + async getRowSubjByRowIndex(rowIndex: number) { const tableRows = await this.getTableRows(); expect(tableRows.length).to.be.greaterThan( diff --git a/x-pack/test/functional/services/ml/anomaly_explorer.ts b/x-pack/test/functional/services/ml/anomaly_explorer.ts index 7fe53b1e3773ab..4b1992777b8a76 100644 --- a/x-pack/test/functional/services/ml/anomaly_explorer.ts +++ b/x-pack/test/functional/services/ml/anomaly_explorer.ts @@ -111,5 +111,30 @@ export function MachineLearningAnomalyExplorerProvider({ getService }: FtrProvid await searchBarInput.clearValueWithKeyboard(); await searchBarInput.type(filter); }, + + async assertClearSelectionButtonVisible(expectVisible: boolean) { + if (expectVisible) { + await testSubjects.existOrFail('mlAnomalyTimelineClearSelection'); + } else { + await testSubjects.missingOrFail('mlAnomalyTimelineClearSelection'); + } + }, + + async clearSwimLaneSelection() { + await this.assertClearSelectionButtonVisible(true); + await testSubjects.click('mlAnomalyTimelineClearSelection'); + await this.assertClearSelectionButtonVisible(false); + }, + + async assertAnomalyExplorerChartsCount(expectedChartsCount: number) { + const chartsContainer = await testSubjects.find('mlExplorerChartsContainer'); + const actualChartsCount = ( + await chartsContainer.findAllByClassName('ml-explorer-chart-container', 3000) + ).length; + expect(actualChartsCount).to.eql( + expectedChartsCount, + `Expect ${expectedChartsCount} charts to appear, got ${actualChartsCount}` + ); + }, }; } diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts index 894ba3d6ef07d0..83c0c5e4164344 100644 --- a/x-pack/test/functional/services/ml/index.ts +++ b/x-pack/test/functional/services/ml/index.ts @@ -45,6 +45,7 @@ import { MachineLearningTestExecutionProvider } from './test_execution'; import { MachineLearningTestResourcesProvider } from './test_resources'; import { MachineLearningDataVisualizerTableProvider } from './data_visualizer_table'; import { MachineLearningAlertingProvider } from './alerting'; +import { SwimLaneProvider } from './swim_lane'; export function MachineLearningProvider(context: FtrProviderContext) { const commonAPI = MachineLearningCommonAPIProvider(context); @@ -96,6 +97,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { const testExecution = MachineLearningTestExecutionProvider(context); const testResources = MachineLearningTestResourcesProvider(context); const alerting = MachineLearningAlertingProvider(context, commonUI); + const swimLane = SwimLaneProvider(context); return { anomaliesTable, @@ -134,6 +136,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { settingsCalendar, settingsFilterList, singleMetricViewer, + swimLane, testExecution, testResources, }; diff --git a/x-pack/test/functional/services/ml/navigation.ts b/x-pack/test/functional/services/ml/navigation.ts index 93b8a5efecc079..075c788a863363 100644 --- a/x-pack/test/functional/services/ml/navigation.ts +++ b/x-pack/test/functional/services/ml/navigation.ts @@ -14,6 +14,7 @@ export function MachineLearningNavigationProvider({ getPageObjects, }: FtrProviderContext) { const retry = getService('retry'); + const browser = getService('browser'); const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['common']); @@ -156,7 +157,7 @@ export function MachineLearningNavigationProvider({ }, async navigateToSingleMetricViewerViaAnomalyExplorer() { - // clicks the `Single Metric Viewere` icon on the button group to switch result views + // clicks the `Single Metric Viewer` icon on the button group to switch result views await testSubjects.click('mlAnomalyResultsViewSelectorSingleMetricViewer'); await retry.tryForTime(60 * 1000, async () => { // verify that the single metric viewer page is visible @@ -193,5 +194,25 @@ export function MachineLearningNavigationProvider({ await testSubjects.existOrFail('homeApp', { timeout: 2000 }); }); }, + + /** + * Assert the active URL. + * @param expectedUrlPart - URL component excluding host + */ + async assertCurrentURLContains(expectedUrlPart: string) { + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.include.string( + expectedUrlPart, + `Expected the current URL "${currentUrl}" to include ${expectedUrlPart}` + ); + }, + + async assertCurrentURLNotContain(expectedUrlPart: string) { + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.not.include.string( + expectedUrlPart, + `Expected the current URL "${currentUrl}" to not include ${expectedUrlPart}` + ); + }, }; } diff --git a/x-pack/test/functional/services/ml/swim_lane.ts b/x-pack/test/functional/services/ml/swim_lane.ts new file mode 100644 index 00000000000000..d659b24559a430 --- /dev/null +++ b/x-pack/test/functional/services/ml/swim_lane.ts @@ -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 expect from '@kbn/expect'; +import { DebugState } from '@elastic/charts'; +import { DebugStateAxis } from '@elastic/charts/dist/state/types'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper'; + +type HeatmapDebugState = Required>; + +export function SwimLaneProvider({ getService }: FtrProviderContext) { + const elasticChart = getService('elasticChart'); + const browser = getService('browser'); + const testSubjects = getService('testSubjects'); + + /** + * Y axis labels width + padding + */ + const xOffset = 185; + + /** + * Get coordinates relative to the left top corner of the canvas + * and transpose them from the center point. + */ + async function getCoordinatesFromCenter( + el: WebElementWrapper, + coordinates: { x: number; y: number } + ) { + const { width, height } = await el.getSize(); + + const elCenter = { + x: Math.round(width / 2), + y: Math.round(height / 2), + }; + + /** + * Origin of the element uses the center point, hence we need ot adjust + * the click coordinated accordingly. + */ + const resultX = xOffset + Math.round(coordinates.x) - elCenter.x; + const resultY = Math.round(coordinates.y) - elCenter.y; + + return { + x: resultX, + y: resultY, + }; + } + + const getRenderTracker = async (testSubj: string) => { + const renderCount = await elasticChart.getVisualizationRenderingCount(testSubj); + + return { + async verify() { + if (testSubj === 'mlAnomalyExplorerSwimlaneViewBy') { + // We have a glitchy behaviour when clicking on the View By swim lane. + // The entire charts is re-rendered, hence it requires a different check + await testSubjects.existOrFail(testSubj); + await elasticChart.waitForRenderComplete(testSubj); + } else { + await elasticChart.waitForRenderingCount(renderCount + 1, testSubj); + } + }, + }; + }; + + return { + async getDebugState(testSubj: string): Promise { + const state = await elasticChart.getChartDebugData(testSubj); + if (!state) { + throw new Error('Swim lane debug state is not available'); + } + return state as HeatmapDebugState; + }, + + async getAxisLabels(testSubj: string, axis: 'x' | 'y'): Promise { + const state = await this.getDebugState(testSubj); + return state.axes[axis][0].labels; + }, + + async assertAxisLabels(testSubj: string, axis: 'x' | 'y', expectedValues: string[]) { + const actualValues = await this.getAxisLabels(testSubj, axis); + expect(actualValues).to.eql( + expectedValues, + `Expected swim lane ${axis} labels to be ${expectedValues}, got ${actualValues}` + ); + }, + + async getCells(testSubj: string): Promise { + const state = await this.getDebugState(testSubj); + return state.heatmap.cells; + }, + + async getHighlighted(testSubj: string): Promise { + const state = await this.getDebugState(testSubj); + return state.heatmap.selection; + }, + + async assertSelection( + testSubj: string, + expectedData: HeatmapDebugState['heatmap']['selection']['data'], + expectedArea?: HeatmapDebugState['heatmap']['selection']['area'] + ) { + const actualSelection = await this.getHighlighted(testSubj); + expect(actualSelection.data).to.eql( + expectedData, + `Expected swim lane to have ${ + expectedData + ? `selected X-axis values ${expectedData.x.join( + ',' + )} and Y-axis values ${expectedData.y.join(',')}` + : 'no data selected' + }, got ${ + actualSelection.data + ? `${actualSelection.data.x.join(',')} and ${actualSelection.data.y.join(',')}` + : 'null' + }` + ); + if (expectedArea) { + expect(actualSelection.area).to.eql(expectedArea); + } + }, + + /** + * Selects a single cell + * @param testSubj + * @param x - number of pixels from the Y-axis + * @param y - number of pixels from the top of the canvas element + */ + async selectSingleCell(testSubj: string, { x, y }: { x: number; y: number }) { + await testSubjects.existOrFail(testSubj); + await testSubjects.scrollIntoView(testSubj); + const renderTracker = await getRenderTracker(testSubj); + const el = await elasticChart.getCanvas(testSubj); + + const { x: resultX, y: resultY } = await getCoordinatesFromCenter(el, { x, y }); + + await browser + .getActions() + .move({ x: resultX, y: resultY, origin: el._webElement }) + .click() + .perform(); + + await renderTracker.verify(); + }, + + async selectCells( + testSubj: string, + coordinates: { x1: number; x2: number; y1: number; y2: number } + ) { + await testSubjects.existOrFail(testSubj); + await testSubjects.scrollIntoView(testSubj); + const renderTracker = await getRenderTracker(testSubj); + + const el = await elasticChart.getCanvas(testSubj); + + const { x: resultX1, y: resultY1 } = await getCoordinatesFromCenter(el, { + x: coordinates.x1, + y: coordinates.y1, + }); + const { x: resultX2, y: resultY2 } = await getCoordinatesFromCenter(el, { + x: coordinates.x2, + y: coordinates.y2, + }); + + await browser.dragAndDrop( + { + location: el, + offset: { x: resultX1, y: resultY1 }, + }, + { + location: el, + offset: { x: resultX2, y: resultY2 }, + } + ); + + await renderTracker.verify(); + }, + + async assertActivePage(testSubj: string, expectedPage: number) { + const pagination = await testSubjects.find(`${testSubj} > mlSwimLanePagination`); + const activePage = await pagination.findByCssSelector( + '.euiPaginationButton-isActive .euiButtonEmpty__text' + ); + const text = await activePage.getVisibleText(); + expect(text).to.eql(expectedPage); + }, + + async assertPageSize(testSubj: string, expectedPageSize: number) { + const actualPageSize = await testSubjects.find( + `${testSubj} > ${expectedPageSize.toString()}` + ); + expect(await actualPageSize.isDisplayed()).to.be(true); + }, + + async selectPage(testSubj: string, page: number) { + await testSubjects.click(`${testSubj} > pagination-button-${page - 1}`); + await this.assertActivePage(testSubj, page); + }, + + async setPageSize(testSubj: string, rowsCount: 5 | 10 | 20 | 50 | 100) { + await testSubjects.click(`${testSubj} > mlSwimLanePageSizeControl`); + await testSubjects.existOrFail('mlSwimLanePageSizePanel'); + await testSubjects.click(`mlSwimLanePageSizePanel > ${rowsCount} rows`); + await this.assertPageSize(testSubj, rowsCount); + }, + }; +} From cf33d72442d2bf330e937d17dca9750d8a4bb756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 24 Mar 2021 15:52:41 +0100 Subject: [PATCH 3/8] [Logs UI] Tolerate log entries for which fields retrieval fails (#94972) --- .../log_entries/log_entries_search_strategy.ts | 10 +++++----- .../services/log_entries/log_entry_search_strategy.ts | 2 +- .../server/services/log_entries/queries/log_entries.ts | 2 +- .../server/services/log_entries/queries/log_entry.ts | 4 +++- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.ts b/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.ts index 190464ab6d5c1f..161685aac29ad4 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.ts @@ -203,13 +203,13 @@ const getLogEntryFromHit = ( } else if ('messageColumn' in column) { return { columnId: column.messageColumn.id, - message: messageFormattingRules.format(hit.fields, hit.highlight || {}), + message: messageFormattingRules.format(hit.fields ?? {}, hit.highlight || {}), }; } else { return { columnId: column.fieldColumn.id, field: column.fieldColumn.field, - value: hit.fields[column.fieldColumn.field] ?? [], + value: hit.fields?.[column.fieldColumn.field] ?? [], highlights: hit.highlight?.[column.fieldColumn.field] ?? [], }; } @@ -233,9 +233,9 @@ const pickRequestCursor = ( const getContextFromHit = (hit: LogEntryHit): LogEntryContext => { // Get all context fields, then test for the presence and type of the ones that go together - const containerId = hit.fields['container.id']?.[0]; - const hostName = hit.fields['host.name']?.[0]; - const logFilePath = hit.fields['log.file.path']?.[0]; + const containerId = hit.fields?.['container.id']?.[0]; + const hostName = hit.fields?.['host.name']?.[0]; + const logFilePath = hit.fields?.['log.file.path']?.[0]; if (typeof containerId === 'string') { return { 'container.id': containerId }; diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts index 2088761800cfe4..85eacba823b2bd 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts @@ -121,5 +121,5 @@ const createLogEntryFromHit = (hit: LogEntryHit) => ({ id: hit._id, index: hit._index, cursor: getLogEntryCursorFromHit(hit), - fields: Object.entries(hit.fields).map(([field, value]) => ({ field, value })), + fields: Object.entries(hit.fields ?? {}).map(([field, value]) => ({ field, value })), }); diff --git a/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts b/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts index 460703b22766f1..aa640f106d1ee9 100644 --- a/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts +++ b/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts @@ -120,10 +120,10 @@ const createHighlightQuery = ( export const logEntryHitRT = rt.intersection([ commonHitFieldsRT, rt.type({ - fields: rt.record(rt.string, jsonArrayRT), sort: rt.tuple([rt.number, rt.number]), }), rt.partial({ + fields: rt.record(rt.string, jsonArrayRT), highlight: rt.record(rt.string, rt.array(rt.string)), }), ]); diff --git a/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts b/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts index 74a12f14adcaa7..51714be775e977 100644 --- a/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts +++ b/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts @@ -39,9 +39,11 @@ export const createGetLogEntryQuery = ( export const logEntryHitRT = rt.intersection([ commonHitFieldsRT, rt.type({ - fields: rt.record(rt.string, jsonArrayRT), sort: rt.tuple([rt.number, rt.number]), }), + rt.partial({ + fields: rt.record(rt.string, jsonArrayRT), + }), ]); export type LogEntryHit = rt.TypeOf; From 1527ab510b9d580b9f6f2b5dbbfa6f789118554c Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 24 Mar 2021 16:01:52 +0100 Subject: [PATCH 4/8] [Search Sessions] Improve search session name edit test (#95152) --- x-pack/test/functional/services/search_sessions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/services/search_sessions.ts b/x-pack/test/functional/services/search_sessions.ts index 3ecb056c06074a..34bea998925e50 100644 --- a/x-pack/test/functional/services/search_sessions.ts +++ b/x-pack/test/functional/services/search_sessions.ts @@ -72,7 +72,9 @@ export function SearchSessionsProvider({ getService }: FtrProviderContext) { if (searchSessionName) { await testSubjects.click('searchSessionNameEdit'); - await testSubjects.setValue('searchSessionNameInput', searchSessionName); + await testSubjects.setValue('searchSessionNameInput', searchSessionName, { + clearWithKeyboard: true, + }); await testSubjects.click('searchSessionNameSave'); } From 759a52c74db62ac9d86416896f356b21eb8ec064 Mon Sep 17 00:00:00 2001 From: Constance Date: Wed, 24 Mar 2021 08:36:23 -0700 Subject: [PATCH 5/8] [App Search] Add describe('listeners') blocks to older logic tests (#95215) * Add describe('listener') blocks to older logic tests * [Misc] LogRetentionLogic - move 2 it() blocks not within a describe() to its parent listener --- .../credentials/credentials_logic.test.ts | 2 + .../document_creation_logic.test.ts | 2 + .../documents/document_detail_logic.test.ts | 2 + .../components/engine/engine_logic.test.ts | 2 + .../engine_overview_logic.test.ts | 2 + .../log_retention/log_retention_logic.test.ts | 64 ++++++++++--------- 6 files changed, 43 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts index a178228f4996bf..bf84b03e7603ee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts @@ -1025,7 +1025,9 @@ describe('CredentialsLogic', () => { }); }); }); + }); + describe('listeners', () => { describe('fetchCredentials', () => { const meta = { page: { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts index 37d3d1577767f0..2c6cadf9a8ece5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts @@ -294,7 +294,9 @@ describe('DocumentCreationLogic', () => { }); }); }); + }); + describe('listeners', () => { describe('onSubmitFile', () => { describe('with a valid file', () => { beforeAll(() => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts index d2683fac649a0d..add5e9414be133 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts @@ -54,7 +54,9 @@ describe('DocumentDetailLogic', () => { }); }); }); + }); + describe('listeners', () => { describe('getDocumentDetails', () => { it('will call an API endpoint and then store the result', async () => { const fields = [{ name: 'name', value: 'python', type: 'string' }]; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts index bf2fba6344e7a4..b9ec83db99f709 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts @@ -172,7 +172,9 @@ describe('EngineLogic', () => { }); }); }); + }); + describe('listeners', () => { describe('initializeEngine', () => { it('fetches and sets engine data', async () => { mount({ engineName: 'some-engine' }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts index df8ed920e88dfa..decadba1092d39 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts @@ -78,7 +78,9 @@ describe('EngineOverviewLogic', () => { }); }); }); + }); + describe('listeners', () => { describe('pollForOverviewMetrics', () => { it('fetches data and calls onPollingSuccess', async () => { mount(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts index 19bd2af50aad97..7b63397ac6380e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts @@ -177,7 +177,9 @@ describe('LogRetentionLogic', () => { }); }); }); + }); + describe('listeners', () => { describe('saveLogRetention', () => { beforeEach(() => { mount(); @@ -264,6 +266,37 @@ describe('LogRetentionLogic', () => { LogRetentionOptions.Analytics ); }); + + it('will call saveLogRetention if NOT already enabled', () => { + mount({ + logRetention: { + [LogRetentionOptions.Analytics]: { + enabled: false, + }, + }, + }); + jest.spyOn(LogRetentionLogic.actions, 'saveLogRetention'); + + LogRetentionLogic.actions.toggleLogRetention(LogRetentionOptions.Analytics); + + expect(LogRetentionLogic.actions.saveLogRetention).toHaveBeenCalledWith( + LogRetentionOptions.Analytics, + true + ); + }); + + it('will do nothing if logRetention option is not yet set', () => { + mount({ + logRetention: {}, + }); + jest.spyOn(LogRetentionLogic.actions, 'saveLogRetention'); + jest.spyOn(LogRetentionLogic.actions, 'setOpenedModal'); + + LogRetentionLogic.actions.toggleLogRetention(LogRetentionOptions.API); + + expect(LogRetentionLogic.actions.saveLogRetention).not.toHaveBeenCalled(); + expect(LogRetentionLogic.actions.setOpenedModal).not.toHaveBeenCalled(); + }); }); describe('fetchLogRetention', () => { @@ -306,36 +339,5 @@ describe('LogRetentionLogic', () => { expect(http.get).not.toHaveBeenCalled(); }); }); - - it('will call saveLogRetention if NOT already enabled', () => { - mount({ - logRetention: { - [LogRetentionOptions.Analytics]: { - enabled: false, - }, - }, - }); - jest.spyOn(LogRetentionLogic.actions, 'saveLogRetention'); - - LogRetentionLogic.actions.toggleLogRetention(LogRetentionOptions.Analytics); - - expect(LogRetentionLogic.actions.saveLogRetention).toHaveBeenCalledWith( - LogRetentionOptions.Analytics, - true - ); - }); - - it('will do nothing if logRetention option is not yet set', () => { - mount({ - logRetention: {}, - }); - jest.spyOn(LogRetentionLogic.actions, 'saveLogRetention'); - jest.spyOn(LogRetentionLogic.actions, 'setOpenedModal'); - - LogRetentionLogic.actions.toggleLogRetention(LogRetentionOptions.API); - - expect(LogRetentionLogic.actions.saveLogRetention).not.toHaveBeenCalled(); - expect(LogRetentionLogic.actions.setOpenedModal).not.toHaveBeenCalled(); - }); }); }); From 3639aa442283bdbdd9132860a77f267bc841e061 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Wed, 24 Mar 2021 11:44:22 -0400 Subject: [PATCH 6/8] [Fleet] Bulk reassign response should include all given ids (#95024) ## Summary `/agents/bulk_reassign` should return a response with a result for each agent given; including invalid or missing ids. It currently filters out missing or invalid before updating. This PR leaves them in and includes their error results in the response. [Added/updated tests](https://github.com/elastic/kibana/pull/95024/files#diff-7ec94bee3e2bae79e5d98b8c17c17b26fad14736143ffa144f3e035773d4cad1R113-R128) to confirm ### 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 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../fleet/common/types/rest_spec/agent.ts | 11 ++- .../fleet/server/routes/agent/handlers.ts | 20 ++-- .../fleet/server/services/agents/crud.ts | 44 +++++---- .../fleet/server/services/agents/reassign.ts | 95 ++++++++++++++----- x-pack/plugins/fleet/server/types/index.tsx | 6 ++ .../apis/agents/reassign.ts | 64 ++++++++++++- 6 files changed, 180 insertions(+), 60 deletions(-) diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index 93cbb8369a3b13..b654c513e0afbc 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -164,12 +164,13 @@ export interface PostBulkAgentReassignRequest { }; } -export interface PostBulkAgentReassignResponse { - [key: string]: { +export type PostBulkAgentReassignResponse = Record< + Agent['id'], + { success: boolean; - error?: Error; - }; -} + error?: string; + } +>; export interface GetOneAgentEventsRequest { params: { diff --git a/x-pack/plugins/fleet/server/routes/agent/handlers.ts b/x-pack/plugins/fleet/server/routes/agent/handlers.ts index e6188a83c49e9b..5ac264e29f079f 100644 --- a/x-pack/plugins/fleet/server/routes/agent/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/handlers.ts @@ -308,26 +308,26 @@ export const postBulkAgentsReassignHandler: RequestHandler< const soClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asInternalUser; + const agentOptions = Array.isArray(request.body.agents) + ? { agentIds: request.body.agents } + : { kuery: request.body.agents }; try { const results = await AgentService.reassignAgents( soClient, esClient, - Array.isArray(request.body.agents) - ? { agentIds: request.body.agents } - : { kuery: request.body.agents }, + agentOptions, request.body.policy_id ); - const body: PostBulkAgentReassignResponse = results.items.reduce((acc, so) => { - return { - ...acc, - [so.id]: { - success: !so.error, - error: so.error || undefined, - }, + const body = results.items.reduce((acc, so) => { + acc[so.id] = { + success: !so.error, + error: so.error?.message, }; + return acc; }, {}); + return response.ok({ body }); } catch (error) { return defaultIngestErrorHandler({ error, response }); diff --git a/x-pack/plugins/fleet/server/services/agents/crud.ts b/x-pack/plugins/fleet/server/services/agents/crud.ts index 22e9f559c56b8a..9aa7bbc9f2b18b 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.ts @@ -9,8 +9,8 @@ import Boom from '@hapi/boom'; import type { SearchResponse, MGetResponse, GetResponse } from 'elasticsearch'; import type { SavedObjectsClientContract, ElasticsearchClient } from 'src/core/server'; +import type { AgentSOAttributes, Agent, BulkActionResult, ListWithKuery } from '../../types'; import type { ESSearchResponse } from '../../../../../../typings/elasticsearch'; -import type { AgentSOAttributes, Agent, ListWithKuery } from '../../types'; import { appContextService, agentPolicyService } from '../../services'; import type { FleetServerAgent } from '../../../common'; import { isAgentUpgradeable, SO_SEARCH_LIMIT } from '../../../common'; @@ -69,22 +69,23 @@ export type GetAgentsOptions = }; export async function getAgents(esClient: ElasticsearchClient, options: GetAgentsOptions) { - let initialResults = []; - + let agents: Agent[] = []; if ('agentIds' in options) { - initialResults = await getAgentsById(esClient, options.agentIds); + agents = await getAgentsById(esClient, options.agentIds); } else if ('kuery' in options) { - initialResults = ( + agents = ( await getAllAgentsByKuery(esClient, { kuery: options.kuery, showInactive: options.showInactive ?? false, }) ).agents; } else { - throw new IngestManagerError('Cannot get agents'); + throw new IngestManagerError( + 'Either options.agentIds or options.kuery are required to get agents' + ); } - return initialResults; + return agents; } export async function getAgentsByKuery( @@ -188,7 +189,7 @@ export async function countInactiveAgents( export async function getAgentById(esClient: ElasticsearchClient, agentId: string) { const agentNotFoundError = new AgentNotFoundError(`Agent ${agentId} not found`); try { - const agentHit = await esClient.get>({ + const agentHit = await esClient.get({ index: AGENTS_INDEX, id: agentId, }); @@ -207,10 +208,17 @@ export async function getAgentById(esClient: ElasticsearchClient, agentId: strin } } -async function getAgentDocuments( +export function isAgentDocument( + maybeDocument: any +): maybeDocument is GetResponse { + return '_id' in maybeDocument && '_source' in maybeDocument; +} + +export type ESAgentDocumentResult = GetResponse; +export async function getAgentDocuments( esClient: ElasticsearchClient, agentIds: string[] -): Promise>> { +): Promise { const res = await esClient.mget>({ index: AGENTS_INDEX, body: { docs: agentIds.map((_id) => ({ _id })) }, @@ -221,14 +229,16 @@ async function getAgentDocuments( export async function getAgentsById( esClient: ElasticsearchClient, - agentIds: string[], - options: { includeMissing?: boolean } = { includeMissing: false } + agentIds: string[] ): Promise { const allDocs = await getAgentDocuments(esClient, agentIds); - const agentDocs = options.includeMissing - ? allDocs - : allDocs.filter((res) => res._id && res._source); - const agents = agentDocs.map((doc) => searchHitToAgent(doc)); + const agents = allDocs.reduce((results, doc) => { + if (isAgentDocument(doc)) { + results.push(searchHitToAgent(doc)); + } + + return results; + }, []); return agents; } @@ -276,7 +286,7 @@ export async function bulkUpdateAgents( agentId: string; data: Partial; }> -) { +): Promise<{ items: BulkActionResult[] }> { if (updateData.length === 0) { return { items: [] }; } diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.ts b/x-pack/plugins/fleet/server/services/agents/reassign.ts index 74e60c42b99734..5574c42ced0530 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign.ts @@ -8,13 +8,20 @@ import type { SavedObjectsClientContract, ElasticsearchClient } from 'kibana/server'; import Boom from '@hapi/boom'; -import type { Agent } from '../../types'; +import type { Agent, BulkActionResult } from '../../types'; import { agentPolicyService } from '../agent_policy'; import { AgentReassignmentError } from '../../errors'; -import { getAgents, getAgentPolicyForAgent, updateAgent, bulkUpdateAgents } from './crud'; +import { + getAgentDocuments, + getAgents, + getAgentPolicyForAgent, + updateAgent, + bulkUpdateAgents, +} from './crud'; import type { GetAgentsOptions } from './index'; import { createAgentAction, bulkCreateAgentActions } from './actions'; +import { searchHitToAgent } from './helpers'; export async function reassignAgent( soClient: SavedObjectsClientContract, @@ -67,39 +74,67 @@ export async function reassignAgentIsAllowed( export async function reassignAgents( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, - options: { agents: Agent[] } | GetAgentsOptions, + options: ({ agents: Agent[] } | GetAgentsOptions) & { force?: boolean }, newAgentPolicyId: string -): Promise<{ items: Array<{ id: string; success: boolean; error?: Error }> }> { +): Promise<{ items: BulkActionResult[] }> { const agentPolicy = await agentPolicyService.get(soClient, newAgentPolicyId); if (!agentPolicy) { throw Boom.notFound(`Agent policy not found: ${newAgentPolicyId}`); } - const allResults = 'agents' in options ? options.agents : await getAgents(esClient, options); + const outgoingErrors: Record = {}; + let givenAgents: Agent[] = []; + if ('agents' in options) { + givenAgents = options.agents; + } else if ('agentIds' in options) { + const givenAgentsResults = await getAgentDocuments(esClient, options.agentIds); + for (const agentResult of givenAgentsResults) { + if (agentResult.found === false) { + outgoingErrors[agentResult._id] = new AgentReassignmentError( + `Cannot find agent ${agentResult._id}` + ); + } else { + givenAgents.push(searchHitToAgent(agentResult)); + } + } + } else if ('kuery' in options) { + givenAgents = await getAgents(esClient, options); + } + const givenOrder = + 'agentIds' in options ? options.agentIds : givenAgents.map((agent) => agent.id); + // which are allowed to unenroll - const settled = await Promise.allSettled( - allResults.map((agent) => - reassignAgentIsAllowed(soClient, esClient, agent.id, newAgentPolicyId).then((_) => agent) - ) + const agentResults = await Promise.allSettled( + givenAgents.map(async (agent, index) => { + if (agent.policy_id === newAgentPolicyId) { + throw new AgentReassignmentError(`${agent.id} is already assigned to ${newAgentPolicyId}`); + } + + const isAllowed = await reassignAgentIsAllowed( + soClient, + esClient, + agent.id, + newAgentPolicyId + ); + if (isAllowed) { + return agent; + } + throw new AgentReassignmentError(`${agent.id} may not be reassigned to ${newAgentPolicyId}`); + }) ); // Filter to agents that do not already use the new agent policy ID - const agentsToUpdate = allResults.filter((agent, index) => { - if (settled[index].status === 'fulfilled') { - if (agent.policy_id === newAgentPolicyId) { - settled[index] = { - status: 'rejected', - reason: new AgentReassignmentError( - `${agent.id} is already assigned to ${newAgentPolicyId}` - ), - }; - } else { - return true; - } + 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 res = await bulkUpdateAgents( + await bulkUpdateAgents( esClient, agentsToUpdate.map((agent) => ({ agentId: agent.id, @@ -110,6 +145,18 @@ export async function reassignAgents( })) ); + const orderedOut = givenOrder.map((agentId) => { + const hasError = agentId in outgoingErrors; + const result: BulkActionResult = { + id: agentId, + success: !hasError, + }; + if (hasError) { + result.error = outgoingErrors[agentId]; + } + return result; + }); + const now = new Date().toISOString(); await bulkCreateAgentActions( soClient, @@ -121,5 +168,5 @@ export async function reassignAgents( })) ); - return res; + return { items: orderedOut }; } diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index c25b047c0e1ad6..2b46f7e76a7198 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -90,5 +90,11 @@ export type AgentPolicyUpdateHandler = ( agentPolicyId: string ) => Promise; +export interface BulkActionResult { + id: string; + success: boolean; + error?: Error; +} + export * from './models'; export * from './rest_spec'; 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 77da9ecce3294c..627cb299f0909d 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/reassign.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/reassign.ts @@ -101,15 +101,32 @@ export default function (providerContext: FtrProviderContext) { expect(agent3data.body.item.policy_id).to.eql('policy2'); }); - it('should allow to reassign multiple agents by id -- some invalid', async () => { - await supertest + it('should allow to reassign multiple agents by id -- mix valid & invalid', async () => { + const { body } = await supertest .post(`/api/fleet/agents/bulk_reassign`) .set('kbn-xsrf', 'xxx') .send({ agents: ['agent2', 'INVALID_ID', 'agent3', 'MISSING_ID', 'etc'], policy_id: 'policy2', - }) - .expect(200); + }); + + expect(body).to.eql({ + agent2: { success: true }, + INVALID_ID: { + success: false, + error: 'Cannot find agent INVALID_ID', + }, + agent3: { success: true }, + MISSING_ID: { + success: false, + error: 'Cannot find agent MISSING_ID', + }, + etc: { + success: false, + error: 'Cannot find agent etc', + }, + }); + const [agent2data, agent3data] = await Promise.all([ supertest.get(`/api/fleet/agents/agent2`), supertest.get(`/api/fleet/agents/agent3`), @@ -118,6 +135,45 @@ export default function (providerContext: FtrProviderContext) { expect(agent3data.body.item.policy_id).to.eql('policy2'); }); + it('should allow to reassign multiple agents by id -- mixed invalid, managed, etc', async () => { + // agent1 is enrolled in policy1. set policy1 to managed + await supertest + .put(`/api/fleet/agent_policies/policy1`) + .set('kbn-xsrf', 'xxx') + .send({ name: 'Test policy', namespace: 'default', is_managed: true }) + .expect(200); + + const { body } = await supertest + .post(`/api/fleet/agents/bulk_reassign`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: ['agent2', 'INVALID_ID', 'agent3'], + policy_id: 'policy2', + }); + + expect(body).to.eql({ + agent2: { + success: false, + error: 'Cannot reassign an agent from managed agent policy policy1', + }, + INVALID_ID: { + success: false, + error: 'Cannot find agent INVALID_ID', + }, + agent3: { + success: false, + error: 'Cannot reassign an agent from managed agent policy policy1', + }, + }); + + const [agent2data, agent3data] = await Promise.all([ + supertest.get(`/api/fleet/agents/agent2`), + supertest.get(`/api/fleet/agents/agent3`), + ]); + expect(agent2data.body.item.policy_id).to.eql('policy1'); + expect(agent3data.body.item.policy_id).to.eql('policy1'); + }); + it('should allow to reassign multiple agents by kuery', async () => { await supertest .post(`/api/fleet/agents/bulk_reassign`) From de3a7d6f0d1091cd26ab29a49b138bb468a38ae1 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Wed, 24 Mar 2021 10:45:51 -0500 Subject: [PATCH 7/8] Use `es` instead of `legacyEs` in APM API integration test (#95303) References #83910. --- x-pack/test/apm_api_integration/tests/feature_controls.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/test/apm_api_integration/tests/feature_controls.ts b/x-pack/test/apm_api_integration/tests/feature_controls.ts index e82b14d6cb7e62..edeffe1e5c296a 100644 --- a/x-pack/test/apm_api_integration/tests/feature_controls.ts +++ b/x-pack/test/apm_api_integration/tests/feature_controls.ts @@ -14,7 +14,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) const supertestWithoutAuth = getService('supertestWithoutAuth'); const security = getService('security'); const spaces = getService('spaces'); - const es = getService('legacyEs'); + const es = getService('es'); const log = getService('log'); const start = encodeURIComponent(new Date(Date.now() - 10000).toISOString()); From 0551472cd90c2b29919c44ccf1c492b85e0fdb62 Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 24 Mar 2021 08:47:10 -0700 Subject: [PATCH 8/8] [jest] switch to jest-environment-jsdom (#95125) Co-authored-by: spalger Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 1 + packages/kbn-test/jest-preset.js | 2 +- x-pack/plugins/global_search_bar/jest.config.js | 3 +++ x-pack/plugins/lens/jest.config.js | 3 +++ x-pack/plugins/security_solution/jest.config.js | 3 +++ 5 files changed, 11 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 32cf8dc1aee0f3..7cb6a505eeafe4 100644 --- a/package.json +++ b/package.json @@ -697,6 +697,7 @@ "jest-cli": "^26.6.3", "jest-diff": "^26.6.2", "jest-environment-jsdom-thirteen": "^1.0.1", + "jest-environment-jsdom": "^26.6.2", "jest-raw-loader": "^1.0.1", "jest-silent-reporter": "^0.2.1", "jest-snapshot": "^26.6.2", diff --git a/packages/kbn-test/jest-preset.js b/packages/kbn-test/jest-preset.js index a1475985af8df0..4949d6d1f9fad4 100644 --- a/packages/kbn-test/jest-preset.js +++ b/packages/kbn-test/jest-preset.js @@ -68,7 +68,7 @@ module.exports = { ], // The test environment that will be used for testing - testEnvironment: 'jest-environment-jsdom-thirteen', + testEnvironment: 'jest-environment-jsdom', // The glob patterns Jest uses to detect test files testMatch: ['**/*.test.{js,mjs,ts,tsx}'], diff --git a/x-pack/plugins/global_search_bar/jest.config.js b/x-pack/plugins/global_search_bar/jest.config.js index 73cf5402a83a93..26a6934226ec43 100644 --- a/x-pack/plugins/global_search_bar/jest.config.js +++ b/x-pack/plugins/global_search_bar/jest.config.js @@ -9,4 +9,7 @@ module.exports = { preset: '@kbn/test', rootDir: '../../..', roots: ['/x-pack/plugins/global_search_bar'], + + // TODO: migrate to "jest-environment-jsdom" https://github.com/elastic/kibana/issues/95200 + testEnvironment: 'jest-environment-jsdom-thirteen', }; diff --git a/x-pack/plugins/lens/jest.config.js b/x-pack/plugins/lens/jest.config.js index 615e540eaedce2..9a3f12e1ead32b 100644 --- a/x-pack/plugins/lens/jest.config.js +++ b/x-pack/plugins/lens/jest.config.js @@ -9,4 +9,7 @@ module.exports = { preset: '@kbn/test', rootDir: '../../..', roots: ['/x-pack/plugins/lens'], + + // TODO: migrate to "jest-environment-jsdom" https://github.com/elastic/kibana/issues/95202 + testEnvironment: 'jest-environment-jsdom-thirteen', }; diff --git a/x-pack/plugins/security_solution/jest.config.js b/x-pack/plugins/security_solution/jest.config.js index 700eaebf6c2023..b4dcedfcceeeeb 100644 --- a/x-pack/plugins/security_solution/jest.config.js +++ b/x-pack/plugins/security_solution/jest.config.js @@ -9,4 +9,7 @@ module.exports = { preset: '@kbn/test', rootDir: '../../..', roots: ['/x-pack/plugins/security_solution'], + + // TODO: migrate to "jest-environment-jsdom" https://github.com/elastic/kibana/issues/95201 + testEnvironment: 'jest-environment-jsdom-thirteen', };