From f0fd2ef0bcf0725061ecb638855d546273b72e9f Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Thu, 11 Feb 2021 11:13:24 +0100 Subject: [PATCH 01/53] Update dependency @elastic/charts to v24.5.1 (#89822) (#91083) Updates @elastic/charts to 24.5.1 with some Kibana related fixes: - align tooltip z-index to EUI tooltip z-index - external tooltip legend extra value sync - legend: hierarchical legend order should follow the tree paths fix #84307 Co-authored-by: Renovate Bot Co-authored-by: Marco Vettorello Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Renovate Bot Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 2 +- .../vis_type_xy/public/config/get_config.ts | 2 +- .../vis_type_xy/public/utils/domain.ts | 2 +- .../public/utils/render_all_series.tsx | 2 +- test/functional/apps/visualize/_area_chart.ts | 9 ++- .../apps/visualize/_point_series_options.ts | 20 +++--- .../apps/visualize/_vertical_bar_chart.ts | 68 +++++++++++++------ .../_vertical_bar_chart_nontimeindex.ts | 64 +++++++++++------ .../RumDashboard/Charts/PageLoadDistChart.tsx | 2 +- .../RumDashboard/Charts/PageViewsChart.tsx | 2 +- .../render_function.test.tsx | 13 ++-- .../pie_visualization/render_helpers.test.ts | 20 ++++-- .../explorer/swimlane_container.tsx | 4 +- .../__snapshots__/donut_chart.test.tsx.snap | 12 +++- .../common/charts/duration_line_bar_list.tsx | 3 +- yarn.lock | 14 ++-- 16 files changed, 157 insertions(+), 82 deletions(-) diff --git a/package.json b/package.json index 06c233b1ff544..2af2233836c90 100644 --- a/package.json +++ b/package.json @@ -348,7 +348,7 @@ "@cypress/webpack-preprocessor": "^5.5.0", "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/charts": "24.4.0", + "@elastic/charts": "24.5.1", "@elastic/eslint-config-kibana": "link:packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", diff --git a/src/plugins/vis_type_xy/public/config/get_config.ts b/src/plugins/vis_type_xy/public/config/get_config.ts index 444428ce8ad3b..b19366fc22dbb 100644 --- a/src/plugins/vis_type_xy/public/config/get_config.ts +++ b/src/plugins/vis_type_xy/public/config/get_config.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { ScaleContinuousType } from '@elastic/charts/dist/scales'; +import { ScaleContinuousType } from '@elastic/charts'; import { Datatable } from '../../../expressions/public'; import { BUCKET_TYPES } from '../../../data/public'; diff --git a/src/plugins/vis_type_xy/public/utils/domain.ts b/src/plugins/vis_type_xy/public/utils/domain.ts index 6c01e97d678d1..a59b2fd20cb5a 100644 --- a/src/plugins/vis_type_xy/public/utils/domain.ts +++ b/src/plugins/vis_type_xy/public/utils/domain.ts @@ -21,7 +21,7 @@ export const getXDomain = (params: Aspect['params']): DomainRange => { const minInterval = (params as DateHistogramParams | HistogramParams)?.interval ?? undefined; if ((params as DateHistogramParams).date) { - const bounds = getTimefilter().getBounds(); + const bounds = getTimefilter().getActiveBounds(); if (bounds) { return { diff --git a/src/plugins/vis_type_xy/public/utils/render_all_series.tsx b/src/plugins/vis_type_xy/public/utils/render_all_series.tsx index 7f80f3772e7ca..585c42eccf9d5 100644 --- a/src/plugins/vis_type_xy/public/utils/render_all_series.tsx +++ b/src/plugins/vis_type_xy/public/utils/render_all_series.tsx @@ -17,8 +17,8 @@ import { SeriesName, Accessor, AccessorFn, + ColorVariant, } from '@elastic/charts'; -import { ColorVariant } from '@elastic/charts/dist/utils/commons'; import { DatatableRow } from '../../../expressions/public'; diff --git a/test/functional/apps/visualize/_area_chart.ts b/test/functional/apps/visualize/_area_chart.ts index 05fbdc2e0c283..9b8abc7ae60a8 100644 --- a/test/functional/apps/visualize/_area_chart.ts +++ b/test/functional/apps/visualize/_area_chart.ts @@ -96,7 +96,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show correct chart', async function () { const xAxisLabels = await PageObjects.visChart.getExpectedValue( ['2015-09-20 00:00', '2015-09-21 00:00', '2015-09-22 00:00', '2015-09-23 00:00'], - ['2015-09-19 12:00', '2015-09-20 12:00', '2015-09-21 12:00', '2015-09-22 12:00'] + [ + '2015-09-20 00:00', + '2015-09-20 12:00', + '2015-09-21 00:00', + '2015-09-21 12:00', + '2015-09-22 00:00', + '2015-09-22 12:00', + ] ); const yAxisLabels = await PageObjects.visChart.getExpectedValue( ['0', '200', '400', '600', '800', '1,000', '1,200', '1,400', '1,600'], diff --git a/test/functional/apps/visualize/_point_series_options.ts b/test/functional/apps/visualize/_point_series_options.ts index a24be29f876ea..09f9694ea9474 100644 --- a/test/functional/apps/visualize/_point_series_options.ts +++ b/test/functional/apps/visualize/_point_series_options.ts @@ -169,8 +169,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.toggleGridCategoryLines(); await PageObjects.visEditor.clickGo(); const gridLines = await PageObjects.visChart.getGridLines(); - const expectedCount = await PageObjects.visChart.getExpectedValue(9, 5); - expect(gridLines.length).to.be(expectedCount); + // FLAKY relaxing as depends on chart size/browser size and produce differences between local and CI + // The objective here is to check whenever the grid lines are rendered, not the exact quantity + expect(gridLines.length).to.be.greaterThan(0); gridLines.forEach((gridLine) => { expect(gridLine.y).to.be(0); }); @@ -181,8 +182,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.toggleGridCategoryLines(); await PageObjects.visEditor.clickGo(); const gridLines = await PageObjects.visChart.getGridLines(); - const expectedCount = await PageObjects.visChart.getExpectedValue(9, 8); - expect(gridLines.length).to.be(expectedCount); + // FLAKY relaxing as depends on chart size/browser size and produce differences between local and CI + // The objective here is to check whenever the grid lines are rendered, not the exact quantity + expect(gridLines.length).to.be.greaterThan(0); gridLines.forEach((gridLine) => { expect(gridLine.x).to.be(0); }); @@ -267,7 +269,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show round labels in default timezone', async function () { const expectedLabels = await PageObjects.visChart.getExpectedValue( ['2015-09-20 00:00', '2015-09-21 00:00', '2015-09-22 00:00'], - ['2015-09-19 12:00', '2015-09-20 12:00', '2015-09-21 12:00', '2015-09-22 12:00'] + ['2015-09-20 00:00', '2015-09-20 18:00', '2015-09-21 12:00', '2015-09-22 06:00'] ); await initChart(); const labels = await PageObjects.visChart.getXAxisLabels(); @@ -277,13 +279,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show round labels in different timezone', async function () { const expectedLabels = await PageObjects.visChart.getExpectedValue( ['2015-09-20 00:00', '2015-09-21 00:00', '2015-09-22 00:00'], - [ - '2015-09-19 12:00', - '2015-09-20 06:00', - '2015-09-21 00:00', - '2015-09-21 18:00', - '2015-09-22 12:00', - ] + ['2015-09-19 18:00', '2015-09-20 12:00', '2015-09-21 06:00', '2015-09-22 00:00'] ); await kibanaServer.uiSettings.update({ 'dateFormat:tz': 'America/Phoenix' }); diff --git a/test/functional/apps/visualize/_vertical_bar_chart.ts b/test/functional/apps/visualize/_vertical_bar_chart.ts index 5a3442a1b9fb5..5dafdd5b04010 100644 --- a/test/functional/apps/visualize/_vertical_bar_chart.ts +++ b/test/functional/apps/visualize/_vertical_bar_chart.ts @@ -441,7 +441,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visChart.waitForVisualizationRenderingStabilized(); await PageObjects.visEditor.clickGo(); - const expectedEntries = ['200', '404', '503']; + const expectedEntries = await PageObjects.visChart.getExpectedValue( + ['200', '404', '503'], + ['503', '404', '200'] // sorting aligned with rendered geometries + ); const legendEntries = await PageObjects.visChart.getLegendEntries(); expect(legendEntries).to.eql(expectedEntries); }); @@ -451,7 +454,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.selectCustomSortMetric(3, 'Min', 'bytes'); await PageObjects.visEditor.clickGo(); - const expectedEntries = ['404', '200', '503']; + const expectedEntries = await PageObjects.visChart.getExpectedValue( + ['404', '200', '503'], + ['503', '200', '404'] // sorting aligned with rendered geometries + ); const legendEntries = await PageObjects.visChart.getLegendEntries(); expect(legendEntries).to.eql(expectedEntries); }); @@ -484,23 +490,42 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visChart.waitForVisualizationRenderingStabilized(); await PageObjects.visEditor.clickGo(); - const expectedEntries = [ - '200 - win 8', - '200 - win xp', - '200 - ios', - '200 - osx', - '200 - win 7', - '404 - ios', - '503 - ios', - '503 - osx', - '503 - win 7', - '503 - win 8', - '503 - win xp', - '404 - osx', - '404 - win 7', - '404 - win 8', - '404 - win xp', - ]; + const expectedEntries = await PageObjects.visChart.getExpectedValue( + [ + '200 - win 8', + '200 - win xp', + '200 - ios', + '200 - osx', + '200 - win 7', + '404 - ios', + '503 - ios', + '503 - osx', + '503 - win 7', + '503 - win 8', + '503 - win xp', + '404 - osx', + '404 - win 7', + '404 - win 8', + '404 - win xp', + ], + [ + '404 - win xp', + '404 - win 8', + '404 - win 7', + '404 - osx', + '503 - win xp', + '503 - win 8', + '503 - win 7', + '503 - osx', + '503 - ios', + '404 - ios', + '200 - win 7', + '200 - osx', + '200 - ios', + '200 - win xp', + '200 - win 8', + ] + ); const legendEntries = await PageObjects.visChart.getLegendEntries(); expect(legendEntries).to.eql(expectedEntries); }); @@ -511,7 +536,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.toggleDisabledAgg(3); await PageObjects.visEditor.clickGo(); - const expectedEntries = ['win 8', 'win xp', 'ios', 'osx', 'win 7']; + const expectedEntries = await PageObjects.visChart.getExpectedValue( + ['win 8', 'win xp', 'ios', 'osx', 'win 7'], + ['win 7', 'osx', 'ios', 'win xp', 'win 8'] + ); const legendEntries = await PageObjects.visChart.getLegendEntries(); expect(legendEntries).to.eql(expectedEntries); }); diff --git a/test/functional/apps/visualize/_vertical_bar_chart_nontimeindex.ts b/test/functional/apps/visualize/_vertical_bar_chart_nontimeindex.ts index adb16167cf2ad..34f401b5afff6 100644 --- a/test/functional/apps/visualize/_vertical_bar_chart_nontimeindex.ts +++ b/test/functional/apps/visualize/_vertical_bar_chart_nontimeindex.ts @@ -213,7 +213,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.clickGo(); await PageObjects.header.waitUntilLoadingHasFinished(); - const expectedEntries = ['200', '404', '503']; + const expectedEntries = await PageObjects.visChart.getExpectedValue( + ['200', '404', '503'], + ['503', '404', '200'] // sorting aligned with rendered geometries + ); + const legendEntries = await PageObjects.visChart.getLegendEntries(); expect(legendEntries).to.eql(expectedEntries); }); @@ -239,23 +243,42 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.clickGo(); await PageObjects.header.waitUntilLoadingHasFinished(); - const expectedEntries = [ - '200 - win 8', - '200 - win xp', - '200 - ios', - '200 - osx', - '200 - win 7', - '404 - ios', - '503 - ios', - '503 - osx', - '503 - win 7', - '503 - win 8', - '503 - win xp', - '404 - osx', - '404 - win 7', - '404 - win 8', - '404 - win xp', - ]; + const expectedEntries = await PageObjects.visChart.getExpectedValue( + [ + '200 - win 8', + '200 - win xp', + '200 - ios', + '200 - osx', + '200 - win 7', + '404 - ios', + '503 - ios', + '503 - osx', + '503 - win 7', + '503 - win 8', + '503 - win xp', + '404 - osx', + '404 - win 7', + '404 - win 8', + '404 - win xp', + ], + [ + '404 - win xp', + '404 - win 8', + '404 - win 7', + '404 - osx', + '503 - win xp', + '503 - win 8', + '503 - win 7', + '503 - osx', + '503 - ios', + '404 - ios', + '200 - win 7', + '200 - osx', + '200 - ios', + '200 - win xp', + '200 - win 8', + ] + ); const legendEntries = await PageObjects.visChart.getLegendEntries(); expect(legendEntries).to.eql(expectedEntries); }); @@ -265,7 +288,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.clickGo(); await PageObjects.header.waitUntilLoadingHasFinished(); - const expectedEntries = ['win 8', 'win xp', 'ios', 'osx', 'win 7']; + const expectedEntries = await PageObjects.visChart.getExpectedValue( + ['win 8', 'win xp', 'ios', 'osx', 'win 7'], + ['win 7', 'osx', 'ios', 'win xp', 'win 8'] + ); const legendEntries = await PageObjects.visChart.getLegendEntries(); expect(legendEntries).to.eql(expectedEntries); }); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx index e195e17386849..589cce26b4d8c 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx @@ -20,12 +20,12 @@ import { DARK_THEME, LIGHT_THEME, Fit, + Position, } from '@elastic/charts'; import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT, } from '@elastic/eui/dist/eui_charts_theme'; -import { Position } from '@elastic/charts/dist/utils/commons'; import styled from 'styled-components'; import { PercentileAnnotations } from '../PageLoadDistribution/PercentileAnnotations'; import { I18LABELS } from '../translations'; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx index dfa69ded00e86..6be2eada6a9ec 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx @@ -17,8 +17,8 @@ import { SeriesNameFn, Settings, timeFormatter, + Position, } from '@elastic/charts'; -import { Position } from '@elastic/charts/dist/utils/commons'; import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT, diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx index a9a12a87f9ec3..e18878ea064ef 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx @@ -6,12 +6,14 @@ */ import React from 'react'; -import { Partition, SeriesIdentifier, Settings } from '@elastic/charts'; import { + Partition, + SeriesIdentifier, + Settings, NodeColorAccessor, ShapeTreeNode, -} from '@elastic/charts/dist/chart_types/partition_chart/layout/types/viewmodel_types'; -import { HierarchyOfArrays } from '@elastic/charts/dist/chart_types/partition_chart/layout/utils/group_by_rollup'; + HierarchyOfArrays, +} from '@elastic/charts'; import { shallow } from 'enzyme'; import { LensMultiTable } from '../types'; import { PieComponent } from './render_function'; @@ -214,7 +216,10 @@ describe('PieVisualization component', () => { const defaultArgs = getDefaultArgs(); const component = shallow(); component.find(Settings).first().prop('onElementClick')!([ - [[{ groupByRollup: 6, value: 6 }], {} as SeriesIdentifier], + [ + [{ groupByRollup: 6, value: 6, depth: 1, path: [], sortIndex: 1 }], + {} as SeriesIdentifier, + ], ]); expect(defaultArgs.onClickValue.mock.calls[0][0]).toMatchInlineSnapshot(` diff --git a/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts b/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts index 4af7b1649b3e4..6e40b07af6713 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts @@ -64,7 +64,13 @@ describe('render helpers', () => { { a: 'Foo', b: 6 }, ], }; - expect(getFilterContext([{ groupByRollup: 'Test', value: 100 }], ['a'], table)).toEqual({ + expect( + getFilterContext( + [{ groupByRollup: 'Test', value: 100, depth: 1, path: [], sortIndex: 1 }], + ['a'], + table + ) + ).toEqual({ data: [ { row: 1, @@ -90,7 +96,13 @@ describe('render helpers', () => { { a: 'Foo', b: 'Three', c: 6 }, ], }; - expect(getFilterContext([{ groupByRollup: 'Test', value: 100 }], ['a', 'b'], table)).toEqual({ + expect( + getFilterContext( + [{ groupByRollup: 'Test', value: 100, depth: 1, path: [], sortIndex: 1 }], + ['a', 'b'], + table + ) + ).toEqual({ data: [ { row: 1, @@ -119,8 +131,8 @@ describe('render helpers', () => { expect( getFilterContext( [ - { groupByRollup: 'Test', value: 100 }, - { groupByRollup: 'Two', value: 5 }, + { groupByRollup: 'Test', value: 100, depth: 1, path: [], sortIndex: 1 }, + { groupByRollup: 'Two', value: 5, depth: 1, path: [], sortIndex: 1 }, ], ['a', 'b'], table 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 57de12a7be974..8deffa15cd6bd 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -24,12 +24,12 @@ import { ElementClickListener, TooltipValue, HeatmapSpec, + TooltipSettings, + HeatmapBrushEvent, } from '@elastic/charts'; import moment from 'moment'; -import { HeatmapBrushEvent } from '@elastic/charts/dist/chart_types/heatmap/layout/types/config_types'; import { i18n } from '@kbn/i18n'; -import { TooltipSettings } from '@elastic/charts/dist/specs/settings'; import { SwimLanePagination } from './swimlane_pagination'; import { AppStateSelectedCells, OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils'; import { ANOMALY_THRESHOLD, SEVERITY_COLORS } from '../../../common'; diff --git a/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap b/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap index 967d078bde210..238ce6c3f9cee 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap @@ -39,6 +39,7 @@ exports[`DonutChart component passes correct props without errors for valid prop "fill": "white", "opacity": 1, "radius": 2, + "shape": "circle", "strokeWidth": 1, "visible": false, }, @@ -134,6 +135,7 @@ exports[`DonutChart component passes correct props without errors for valid prop "fill": "white", "opacity": 1, "radius": 2, + "shape": "circle", "strokeWidth": 1, "visible": true, }, @@ -170,12 +172,17 @@ exports[`DonutChart component passes correct props without errors for valid prop "fill": "#F5F5F5", "visible": true, }, - "line": Object { + "crossLine": Object { "dash": Array [ 5, 5, ], - "stroke": "#777", + "stroke": "#98A2B3", + "strokeWidth": 1, + "visible": true, + }, + "line": Object { + "stroke": "#98A2B3", "strokeWidth": 1, "visible": true, }, @@ -196,6 +203,7 @@ exports[`DonutChart component passes correct props without errors for valid prop "fill": "white", "opacity": 1, "radius": 2, + "shape": "circle", "strokeWidth": 1, "visible": true, }, diff --git a/x-pack/plugins/uptime/public/components/common/charts/duration_line_bar_list.tsx b/x-pack/plugins/uptime/public/components/common/charts/duration_line_bar_list.tsx index 2c6ad63b51e7b..3d0fefbd083f8 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/duration_line_bar_list.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/duration_line_bar_list.tsx @@ -7,8 +7,7 @@ import React from 'react'; import moment from 'moment'; -import { AnnotationTooltipFormatter, RectAnnotation } from '@elastic/charts'; -import { RectAnnotationDatum } from '@elastic/charts/dist/chart_types/xy_chart/utils/specs'; +import { AnnotationTooltipFormatter, RectAnnotation, RectAnnotationDatum } from '@elastic/charts'; import { AnnotationTooltip } from './annotation_tooltip'; import { ANOMALY_SEVERITY, diff --git a/yarn.lock b/yarn.lock index 73dedcce12480..22935195ec061 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2146,10 +2146,10 @@ dependencies: object-hash "^1.3.0" -"@elastic/charts@24.4.0": - version "24.4.0" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-24.4.0.tgz#217f55540f48a8f59c49250781d99c36110b2544" - integrity sha512-8dxDEs0g1mV4MjPgIArAmdDQDKjH8EitCLh8/Rouv8kkxvdXnL86VkSHpUbZNK9zPAecArwHBSkyCBZNmbqT2A== +"@elastic/charts@24.5.1": + version "24.5.1" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-24.5.1.tgz#4757721b0323b15412c92d696dd76fdef9b963f8" + integrity sha512-eHJna3xyHREaSfTRb+3/34EmyoINopH6yP9KReakXRb0jW8DD4n9IkbPFwpVN3uXQ6ND2x1ObA0ZzLPSLCPozg== dependencies: "@popperjs/core" "^2.4.0" chroma-js "^2.1.0" @@ -2161,7 +2161,6 @@ d3-scale "^1.0.7" d3-shape "^1.3.4" newtype-ts "^0.2.4" - path2d-polyfill "^0.4.2" prop-types "^15.7.2" re-reselect "^3.4.0" react-redux "^7.1.0" @@ -22841,11 +22840,6 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -path2d-polyfill@^0.4.2: - version "0.4.2" - resolved "https://registry.yarnpkg.com/path2d-polyfill/-/path2d-polyfill-0.4.2.tgz#594d3103838ef6b9dd4a7fd498fe9a88f1f28531" - integrity sha512-JSeAnUfkFjl+Ml/EZL898ivMSbGHrOH63Mirx5EQ1ycJiryHDmj1Q7Are+uEPvenVGCUN9YbolfGfyUewJfJEg== - pathval@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" From 55ff85aeb584ed406fc8ac73dc42c22b8578aa82 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Thu, 11 Feb 2021 13:24:33 +0300 Subject: [PATCH 02/53] [Create index pattern] Can't create single character index without wildcard (#90919) (#91081) --- .../components/step_index_pattern/step_index_pattern.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.tsx index 67a5ac021e701..ce856129dd051 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.tsx @@ -182,12 +182,12 @@ export class StepIndexPattern extends Component target.setSelectionRange(1, 1)); } else { - if (query === '*' && appendedWildcard) { + if (['', '*'].includes(query) && appendedWildcard) { query = ''; this.setState({ appendedWildcard: false }); } From a936fe67a6d906f67a99a88fe772876dc461c530 Mon Sep 17 00:00:00 2001 From: Sonja Krause-Harder Date: Thu, 11 Feb 2021 11:27:44 +0100 Subject: [PATCH 03/53] [Fleet] Reduce permissions. (#90302) (#91084) * Reduce permissions. * Change permissions back. * Reducing permissions on fleet_enroll role - 'write', 'create_index' -> 'auto_configure', 'create_doc' * Remove indices:admin/auto_create from privileges. --- .../fleet/server/services/api_keys/index.ts | 13 ++-------- x-pack/plugins/fleet/server/services/setup.ts | 13 ++-------- .../apis/agents_setup.ts | 13 ++-------- .../fleet_api_integration/apis/fleet_setup.ts | 24 ++++--------------- 4 files changed, 10 insertions(+), 53 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/api_keys/index.ts b/x-pack/plugins/fleet/server/services/api_keys/index.ts index 65051163c78c3..911cb700dd56b 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/index.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/index.ts @@ -22,17 +22,8 @@ export async function generateOutputApiKey( cluster: ['monitor'], index: [ { - names: [ - 'logs-*', - 'metrics-*', - 'traces-*', - '.ds-logs-*', - '.ds-metrics-*', - '.ds-traces-*', - '.logs-endpoint.diagnostic.collection-*', - '.ds-.logs-endpoint.diagnostic.collection-*', - ], - privileges: ['write', 'create_index', 'indices:admin/auto_create'], + names: ['logs-*', 'metrics-*', 'traces-*', '.logs-endpoint.diagnostic.collection-*'], + privileges: ['auto_configure', 'create_doc'], }, ], }, diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index f19ad4e7fe417..6c8f24e799574 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -192,17 +192,8 @@ async function putFleetRole(callCluster: CallESAsCurrentUser) { cluster: ['monitor', 'manage_api_key'], indices: [ { - names: [ - 'logs-*', - 'metrics-*', - 'traces-*', - '.ds-logs-*', - '.ds-metrics-*', - '.ds-traces-*', - '.logs-endpoint.diagnostic.collection-*', - '.ds-.logs-endpoint.diagnostic.collection-*', - ], - privileges: ['write', 'create_index', 'indices:admin/auto_create'], + names: ['logs-*', 'metrics-*', 'traces-*', '.logs-endpoint.diagnostic.collection-*'], + privileges: ['auto_configure', 'create_doc'], }, ], }, diff --git a/x-pack/test/fleet_api_integration/apis/agents_setup.ts b/x-pack/test/fleet_api_integration/apis/agents_setup.ts index c1abdfab566b9..20112afdf76a4 100644 --- a/x-pack/test/fleet_api_integration/apis/agents_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/agents_setup.ts @@ -60,17 +60,8 @@ export default function (providerContext: FtrProviderContext) { cluster: ['monitor', 'manage_api_key'], indices: [ { - names: [ - 'logs-*', - 'metrics-*', - 'traces-*', - '.ds-logs-*', - '.ds-metrics-*', - '.ds-traces-*', - '.logs-endpoint.diagnostic.collection-*', - '.ds-.logs-endpoint.diagnostic.collection-*', - ], - privileges: ['write', 'create_index', 'indices:admin/auto_create'], + names: ['logs-*', 'metrics-*', 'traces-*', '.logs-endpoint.diagnostic.collection-*'], + privileges: ['auto_configure', 'create_doc'], allow_restricted_indices: false, }, ], diff --git a/x-pack/test/fleet_api_integration/apis/fleet_setup.ts b/x-pack/test/fleet_api_integration/apis/fleet_setup.ts index 2c15cddc81ea1..31d620cd34931 100644 --- a/x-pack/test/fleet_api_integration/apis/fleet_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/fleet_setup.ts @@ -62,15 +62,8 @@ export default function (providerContext: FtrProviderContext) { cluster: ['monitor', 'manage_api_key'], indices: [ { - names: [ - 'logs-*', - 'metrics-*', - 'traces-*', - '.ds-logs-*', - '.ds-metrics-*', - '.ds-traces-*', - ], - privileges: ['write', 'create_index', 'indices:admin/auto_create'], + names: ['logs-*', 'metrics-*', 'traces-*'], + privileges: ['create_doc', 'indices:admin/auto_create'], allow_restricted_indices: false, }, ], @@ -101,17 +94,8 @@ export default function (providerContext: FtrProviderContext) { cluster: ['monitor', 'manage_api_key'], indices: [ { - names: [ - 'logs-*', - 'metrics-*', - 'traces-*', - '.ds-logs-*', - '.ds-metrics-*', - '.ds-traces-*', - '.logs-endpoint.diagnostic.collection-*', - '.ds-.logs-endpoint.diagnostic.collection-*', - ], - privileges: ['write', 'create_index', 'indices:admin/auto_create'], + names: ['logs-*', 'metrics-*', 'traces-*', '.logs-endpoint.diagnostic.collection-*'], + privileges: ['auto_configure', 'create_doc'], allow_restricted_indices: false, }, ], From 80bfebf8504024efb99056f703b45efd18027de9 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Thu, 11 Feb 2021 12:10:35 +0100 Subject: [PATCH 04/53] Implement custom global header banner (#87438) (#91089) * first draft * update plugin list * fix tsproject * update bundle limits file * remove unused start dep * adapt imports * POC of footer banner * update styles, mostly * plug banner to uiSettings * adding some unit tests * add tests on sort_fields * cleanup sums in sass mixins * some self review stuff * update generated doc * add tests for color field * update chrome header test snapshots * retrieve license info from the server * switch from uiSettings to plugin config * update plugin list description * update default colors * NIT * add markdown support * fix banner overlap in fullscreen mode * change banner height to 32px * change banner's font size to 14 * delete unused uiSettings --- .stylelintrc | 1 + docs/developer/plugin-list.asciidoc | 4 + .../kibana-plugin-core-public.chromestart.md | 1 + ...core-public.chromestart.setheaderbanner.md | 28 ++ ...in-core-public.chromeuserbanner.content.md | 11 + ...ana-plugin-core-public.chromeuserbanner.md | 19 + ...kibana-plugin-core-public.doclinksstart.md | 2 +- .../core/public/kibana-plugin-core-public.md | 1 + ...ana-plugin-core-public.uisettingsparams.md | 1 + ...ugin-core-public.uisettingsparams.order.md | 15 + ...ibana-plugin-core-public.uisettingstype.md | 2 +- ...ana-plugin-core-server.uisettingsparams.md | 1 + ...ugin-core-server.uisettingsparams.order.md | 15 + ...ibana-plugin-core-server.uisettingstype.md | 2 +- packages/kbn-optimizer/limits.yml | 1 + src/core/public/_mixins.scss | 43 ++ src/core/public/_variables.scss | 5 + src/core/public/chrome/chrome_service.mock.ts | 3 + src/core/public/chrome/chrome_service.tsx | 242 ++-------- src/core/public/chrome/index.ts | 20 +- src/core/public/chrome/types.ts | 241 ++++++++++ .../header/__snapshots__/header.test.tsx.snap | 107 ++++- src/core/public/chrome/ui/header/_banner.scss | 22 + src/core/public/chrome/ui/header/_index.scss | 2 +- .../public/chrome/ui/header/header.test.tsx | 4 +- src/core/public/chrome/ui/header/header.tsx | 21 +- .../public/chrome/ui/header/header_badge.tsx | 2 +- .../chrome/ui/header/header_breadcrumbs.tsx | 2 +- .../chrome/ui/header/header_help_menu.tsx | 2 +- .../chrome/ui/header/header_top_banner.tsx | 34 ++ src/core/public/core_app/styles/_mixins.scss | 8 + src/core/public/core_system.ts | 1 - src/core/public/index.scss | 1 + src/core/public/index.ts | 2 + src/core/public/public.api.md | 10 +- src/core/public/rendering/_base.scss | 32 +- .../public/rendering/rendering_service.tsx | 23 +- src/core/server/server.api.md | 3 +- src/core/types/ui_settings.ts | 10 +- .../management_app/advanced_settings.tsx | 20 +- .../field/__snapshots__/field.test.tsx.snap | 413 ++++++++++++++++++ .../components/field/field.test.tsx | 46 ++ .../management_app/components/field/field.tsx | 12 + .../public/management_app/lib/index.ts | 1 + .../management_app/lib/sort_fields.test.ts | 56 +++ .../public/management_app/lib/sort_fields.ts | 31 ++ .../management_app/lib/to_editable_config.ts | 6 +- .../public/management_app/types.ts | 1 + .../application/components/discover.scss | 4 +- .../public/application/components/_home.scss | 4 +- .../public/application/components/home.js | 3 + .../public/components/_overview.scss | 4 +- x-pack/.i18nrc.json | 3 +- x-pack/plugins/banners/README.md | 38 ++ x-pack/plugins/banners/common/index.ts | 8 + x-pack/plugins/banners/common/types.ts | 20 + x-pack/plugins/banners/jest.config.js | 12 + x-pack/plugins/banners/kibana.json | 11 + .../banners/public/components/banner.scss | 7 + .../banners/public/components/banner.tsx | 33 ++ .../banners/public/components/index.ts | 8 + .../banners/public/get_banner_info.test.ts | 35 ++ .../plugins/banners/public/get_banner_info.ts | 13 + x-pack/plugins/banners/public/index.ts | 12 + .../banners/public/plugin.test.mocks.ts | 11 + x-pack/plugins/banners/public/plugin.test.tsx | 86 ++++ x-pack/plugins/banners/public/plugin.tsx | 44 ++ x-pack/plugins/banners/public/types.ts | 12 + x-pack/plugins/banners/server/config.ts | 42 ++ x-pack/plugins/banners/server/index.ts | 12 + x-pack/plugins/banners/server/plugin.ts | 33 ++ x-pack/plugins/banners/server/routes/index.ts | 14 + x-pack/plugins/banners/server/routes/info.ts | 36 ++ x-pack/plugins/banners/server/types.ts | 15 + x-pack/plugins/banners/server/utils.test.ts | 26 ++ x-pack/plugins/banners/server/utils.ts | 12 + x-pack/plugins/banners/tsconfig.json | 22 + x-pack/plugins/maps/public/_main.scss | 10 +- .../painless_lab/public/styles/_index.scss | 7 +- .../public/application/_app.scss | 8 +- x-pack/test/tsconfig.json | 1 + x-pack/tsconfig.json | 1 + 82 files changed, 1855 insertions(+), 282 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.chromestart.setheaderbanner.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.content.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.uisettingsparams.order.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.uisettingsparams.order.md create mode 100644 src/core/public/_mixins.scss create mode 100644 src/core/public/chrome/types.ts create mode 100644 src/core/public/chrome/ui/header/_banner.scss create mode 100644 src/core/public/chrome/ui/header/header_top_banner.tsx create mode 100644 src/plugins/advanced_settings/public/management_app/lib/sort_fields.test.ts create mode 100644 src/plugins/advanced_settings/public/management_app/lib/sort_fields.ts create mode 100644 x-pack/plugins/banners/README.md create mode 100644 x-pack/plugins/banners/common/index.ts create mode 100644 x-pack/plugins/banners/common/types.ts create mode 100644 x-pack/plugins/banners/jest.config.js create mode 100644 x-pack/plugins/banners/kibana.json create mode 100644 x-pack/plugins/banners/public/components/banner.scss create mode 100644 x-pack/plugins/banners/public/components/banner.tsx create mode 100644 x-pack/plugins/banners/public/components/index.ts create mode 100644 x-pack/plugins/banners/public/get_banner_info.test.ts create mode 100644 x-pack/plugins/banners/public/get_banner_info.ts create mode 100644 x-pack/plugins/banners/public/index.ts create mode 100644 x-pack/plugins/banners/public/plugin.test.mocks.ts create mode 100644 x-pack/plugins/banners/public/plugin.test.tsx create mode 100644 x-pack/plugins/banners/public/plugin.tsx create mode 100644 x-pack/plugins/banners/public/types.ts create mode 100644 x-pack/plugins/banners/server/config.ts create mode 100644 x-pack/plugins/banners/server/index.ts create mode 100644 x-pack/plugins/banners/server/plugin.ts create mode 100644 x-pack/plugins/banners/server/routes/index.ts create mode 100644 x-pack/plugins/banners/server/routes/info.ts create mode 100644 x-pack/plugins/banners/server/types.ts create mode 100644 x-pack/plugins/banners/server/utils.test.ts create mode 100644 x-pack/plugins/banners/server/utils.ts create mode 100644 x-pack/plugins/banners/tsconfig.json diff --git a/.stylelintrc b/.stylelintrc index 29c1f4b552b48..26431cfee6f1d 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -32,6 +32,7 @@ rules: - function - return - for + - at-root comment-no-empty: true no-duplicate-at-import-rules: true no-duplicate-selectors: true diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 90d9c42c8aef3..fc565491b4f63 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -297,6 +297,10 @@ which will load the visualization's editor. |To access an elasticsearch instance that has live data you have two options: +|{kib-repo}blob/{branch}/x-pack/plugins/banners/README.md[banners] +|Allow to add a header banner that will be displayed on every page of the Kibana application + + |{kib-repo}blob/{branch}/x-pack/plugins/beats_management/readme.md[beatsManagement] |Notes: Failure to have auth enabled in Kibana will make for a broken UI. UI-based errors not yet in place diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.md index 663b326193de5..2d465745c436b 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromestart.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromestart.md @@ -67,6 +67,7 @@ core.chrome.setHelpExtension(elem => { | [setBreadcrumbs(newBreadcrumbs)](./kibana-plugin-core-public.chromestart.setbreadcrumbs.md) | Override the current set of breadcrumbs | | [setBreadcrumbsAppendExtension(breadcrumbsAppendExtension)](./kibana-plugin-core-public.chromestart.setbreadcrumbsappendextension.md) | Mount an element next to the last breadcrumb | | [setCustomNavLink(newCustomNavLink)](./kibana-plugin-core-public.chromestart.setcustomnavlink.md) | Override the current set of custom nav link | +| [setHeaderBanner(headerBanner)](./kibana-plugin-core-public.chromestart.setheaderbanner.md) | Set the banner that will appear on top of the chrome header. | | [setHelpExtension(helpExtension)](./kibana-plugin-core-public.chromestart.sethelpextension.md) | Override the current set of custom help content | | [setHelpSupportUrl(url)](./kibana-plugin-core-public.chromestart.sethelpsupporturl.md) | Override the default support URL shown in the help menu | | [setIsVisible(isVisible)](./kibana-plugin-core-public.chromestart.setisvisible.md) | Set the temporary visibility for the chrome. This does nothing if the chrome is hidden by default and should be used to hide the chrome for things like full-screen modes with an exit button. | diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.setheaderbanner.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.setheaderbanner.md new file mode 100644 index 0000000000000..02a2fa65ed478 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromestart.setheaderbanner.md @@ -0,0 +1,28 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeStart](./kibana-plugin-core-public.chromestart.md) > [setHeaderBanner](./kibana-plugin-core-public.chromestart.setheaderbanner.md) + +## ChromeStart.setHeaderBanner() method + +Set the banner that will appear on top of the chrome header. + +Signature: + +```typescript +setHeaderBanner(headerBanner?: ChromeUserBanner): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| headerBanner | ChromeUserBanner | | + +Returns: + +`void` + +## Remarks + +Using `undefined` when invoking this API will remove the banner. + diff --git a/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.content.md b/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.content.md new file mode 100644 index 0000000000000..7a77fdc6223de --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.content.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeUserBanner](./kibana-plugin-core-public.chromeuserbanner.md) > [content](./kibana-plugin-core-public.chromeuserbanner.content.md) + +## ChromeUserBanner.content property + +Signature: + +```typescript +content: MountPoint; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.md b/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.md new file mode 100644 index 0000000000000..8617c5c4d2b12 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeUserBanner](./kibana-plugin-core-public.chromeuserbanner.md) + +## ChromeUserBanner interface + + +Signature: + +```typescript +export interface ChromeUserBanner +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [content](./kibana-plugin-core-public.chromeuserbanner.content.md) | MountPoint<HTMLDivElement> | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index f4bce8b51ebb1..5be8f8ce7e8c7 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: string;
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessSyntax: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
executeWatchActionModes: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
putComponentTemplateMetadata: string;
putWatch: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: string;
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessSyntax: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
putComponentTemplateMetadata: string;
putWatch: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
} | | diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index e307b5c9971b0..5524cf328fbfe 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -56,6 +56,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ChromeRecentlyAccessed](./kibana-plugin-core-public.chromerecentlyaccessed.md) | [APIs](./kibana-plugin-core-public.chromerecentlyaccessed.md) for recently accessed history. | | [ChromeRecentlyAccessedHistoryItem](./kibana-plugin-core-public.chromerecentlyaccessedhistoryitem.md) | | | [ChromeStart](./kibana-plugin-core-public.chromestart.md) | ChromeStart allows plugins to customize the global chrome header UI and enrich the UX with additional information about the current location of the browser. | +| [ChromeUserBanner](./kibana-plugin-core-public.chromeuserbanner.md) | | | [CoreSetup](./kibana-plugin-core-public.coresetup.md) | Core services exposed to the Plugin setup lifecycle | | [CoreStart](./kibana-plugin-core-public.corestart.md) | Core services exposed to the Plugin start lifecycle | | [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) | | diff --git a/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md index 0b7e6467667cb..6fcfae559dd33 100644 --- a/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md +++ b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md @@ -23,6 +23,7 @@ export interface UiSettingsParams | [name](./kibana-plugin-core-public.uisettingsparams.name.md) | string | title in the UI | | [optionLabels](./kibana-plugin-core-public.uisettingsparams.optionlabels.md) | Record<string, string> | text labels for 'select' type UI element | | [options](./kibana-plugin-core-public.uisettingsparams.options.md) | string[] | array of permitted values for this setting | +| [order](./kibana-plugin-core-public.uisettingsparams.order.md) | number | index of the settings within its category (ascending order, smallest will be displayed first). Used for ordering in the UI. settings without order defined will be displayed last and ordered by name | | [readonly](./kibana-plugin-core-public.uisettingsparams.readonly.md) | boolean | a flag indicating that value cannot be changed | | [requiresPageReload](./kibana-plugin-core-public.uisettingsparams.requirespagereload.md) | boolean | a flag indicating whether new value applying requires page reloading | | [schema](./kibana-plugin-core-public.uisettingsparams.schema.md) | Type<T> | | diff --git a/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.order.md b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.order.md new file mode 100644 index 0000000000000..d93aaeb904616 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.order.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) > [order](./kibana-plugin-core-public.uisettingsparams.order.md) + +## UiSettingsParams.order property + +index of the settings within its category (ascending order, smallest will be displayed first). Used for ordering in the UI. + + settings without order defined will be displayed last and ordered by name + +Signature: + +```typescript +order?: number; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.uisettingstype.md b/docs/development/core/public/kibana-plugin-core-public.uisettingstype.md index 5753704ccfe03..65e6264ea1e08 100644 --- a/docs/development/core/public/kibana-plugin-core-public.uisettingstype.md +++ b/docs/development/core/public/kibana-plugin-core-public.uisettingstype.md @@ -9,5 +9,5 @@ UI element type to represent the settings. Signature: ```typescript -export declare type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image'; +export declare type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color'; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md index d35afc4a149d1..4bb7be77c595a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md +++ b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md @@ -23,6 +23,7 @@ export interface UiSettingsParams | [name](./kibana-plugin-core-server.uisettingsparams.name.md) | string | title in the UI | | [optionLabels](./kibana-plugin-core-server.uisettingsparams.optionlabels.md) | Record<string, string> | text labels for 'select' type UI element | | [options](./kibana-plugin-core-server.uisettingsparams.options.md) | string[] | array of permitted values for this setting | +| [order](./kibana-plugin-core-server.uisettingsparams.order.md) | number | index of the settings within its category (ascending order, smallest will be displayed first). Used for ordering in the UI. settings without order defined will be displayed last and ordered by name | | [readonly](./kibana-plugin-core-server.uisettingsparams.readonly.md) | boolean | a flag indicating that value cannot be changed | | [requiresPageReload](./kibana-plugin-core-server.uisettingsparams.requirespagereload.md) | boolean | a flag indicating whether new value applying requires page reloading | | [schema](./kibana-plugin-core-server.uisettingsparams.schema.md) | Type<T> | | diff --git a/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.order.md b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.order.md new file mode 100644 index 0000000000000..140bdad5d786b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.order.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) > [order](./kibana-plugin-core-server.uisettingsparams.order.md) + +## UiSettingsParams.order property + +index of the settings within its category (ascending order, smallest will be displayed first). Used for ordering in the UI. + + settings without order defined will be displayed last and ordered by name + +Signature: + +```typescript +order?: number; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.uisettingstype.md b/docs/development/core/server/kibana-plugin-core-server.uisettingstype.md index 3c439897ea031..7edee442baa24 100644 --- a/docs/development/core/server/kibana-plugin-core-server.uisettingstype.md +++ b/docs/development/core/server/kibana-plugin-core-server.uisettingstype.md @@ -9,5 +9,5 @@ UI element type to represent the settings. Signature: ```typescript -export declare type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image'; +export declare type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color'; ``` diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index a364aa4c8de29..5c399a052485f 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -105,3 +105,4 @@ pageLoadAssetSize: spacesOss: 18817 osquery: 107090 fileUpload: 25664 + banners: 17946 diff --git a/src/core/public/_mixins.scss b/src/core/public/_mixins.scss new file mode 100644 index 0000000000000..2dbef465e074e --- /dev/null +++ b/src/core/public/_mixins.scss @@ -0,0 +1,43 @@ +@import './variables'; + +/* stylelint-disable-next-line length-zero-no-unit -- need consistent unit to sum them */ +@mixin kibanaFullBodyHeight($additionalOffset: 0px) { + // default - header, no banner + height: calc(100vh - #{$kbnHeaderOffset + $additionalOffset}); + + @at-root { + // no header, no banner + .kbnBody--chromeHidden & { + height: calc(100vh - #{$additionalOffset}); + } + // header, banner + .kbnBody--hasHeaderBanner & { + height: calc(100vh - #{$kbnHeaderOffsetWithBanner + $additionalOffset}); + } + // no header, banner + .kbnBody--chromeHidden.kbnBody--hasHeaderBanner & { + height: calc(100vh - #{$kbnHeaderBannerHeight + $additionalOffset}); + } + } +} + +/* stylelint-disable-next-line length-zero-no-unit -- need consistent unit to sum them */ +@mixin kibanaFullBodyMinHeight($additionalOffset: 0px) { + // default - header, no banner + min-height: calc(100vh - #{$kbnHeaderOffset + $additionalOffset}); + + @at-root { + // no header, no banner + .kbnBody--chromeHidden & { + min-height: calc(100vh - #{$additionalOffset}); + } + // header, banner + .kbnBody--hasHeaderBanner & { + min-height: calc(100vh - #{$kbnHeaderOffsetWithBanner + $additionalOffset}); + } + // no header, banner + .kbnBody--chromeHidden.kbnBody--hasHeaderBanner & { + min-height: calc(100vh - #{$kbnHeaderBannerHeight + $additionalOffset}); + } + } +} diff --git a/src/core/public/_variables.scss b/src/core/public/_variables.scss index 8c054e770bd4b..f6ff5619bfc53 100644 --- a/src/core/public/_variables.scss +++ b/src/core/public/_variables.scss @@ -1,3 +1,8 @@ @import '@elastic/eui/src/global_styling/variables/header'; +// height of the header banner +$kbnHeaderBannerHeight: $euiSizeXL; +// total height of the header (when the banner is *not* present) $kbnHeaderOffset: $euiHeaderHeightCompensation * 2; +// total height of the header when the banner is present +$kbnHeaderOffsetWithBanner: $kbnHeaderOffset + $kbnHeaderBannerHeight; diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index cb0876f6bc725..ae9c58af69603 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -61,6 +61,8 @@ const createStartContractMock = () => { getIsNavDrawerLocked$: jest.fn(), getCustomNavLink$: jest.fn(), setCustomNavLink: jest.fn(), + setHeaderBanner: jest.fn(), + getBodyClasses$: jest.fn(), }; startContract.navLinks.getAll.mockReturnValue([]); startContract.getBrand$.mockReturnValue(new BehaviorSubject({} as ChromeBrand)); @@ -72,6 +74,7 @@ const createStartContractMock = () => { startContract.getCustomNavLink$.mockReturnValue(new BehaviorSubject(undefined)); startContract.getHelpExtension$.mockReturnValue(new BehaviorSubject(undefined)); startContract.getIsNavDrawerLocked$.mockReturnValue(new BehaviorSubject(false)); + startContract.getBodyClasses$.mockReturnValue(new BehaviorSubject([])); return startContract; }; diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index ee8d1c17ccd59..e69bf9025fdb9 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -6,69 +6,37 @@ * Side Public License, v 1. */ -import { EuiBreadcrumb, IconType } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { BehaviorSubject, combineLatest, merge, Observable, of, ReplaySubject } from 'rxjs'; import { flatMap, map, takeUntil } from 'rxjs/operators'; import { parse } from 'url'; import { EuiLink } from '@elastic/eui'; -import { MountPoint } from '../types'; import { mountReactNode } from '../utils/mount'; import { InternalApplicationStart } from '../application'; import { DocLinksStart } from '../doc_links'; import { HttpStart } from '../http'; import { InjectedMetadataStart } from '../injected_metadata'; import { NotificationsStart } from '../notifications'; -import { IUiSettingsClient } from '../ui_settings'; import { KIBANA_ASK_ELASTIC_LINK } from './constants'; import { ChromeDocTitle, DocTitleService } from './doc_title'; import { ChromeNavControls, NavControlsService } from './nav_controls'; -import { ChromeNavLinks, NavLinksService, ChromeNavLink } from './nav_links'; +import { NavLinksService, ChromeNavLink } from './nav_links'; import { ChromeRecentlyAccessed, RecentlyAccessedService } from './recently_accessed'; import { Header } from './ui'; -import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu'; export { ChromeNavControls, ChromeRecentlyAccessed, ChromeDocTitle }; +import { + ChromeBadge, + ChromeBrand, + ChromeBreadcrumb, + ChromeBreadcrumbsAppendExtension, + ChromeHelpExtension, + InternalChromeStart, + ChromeUserBanner, +} from './types'; const IS_LOCKED_KEY = 'core.chrome.isLocked'; -/** @public */ -export interface ChromeBadge { - text: string; - tooltip: string; - iconType?: IconType; -} - -/** @public */ -export interface ChromeBrand { - logo?: string; - smallLogo?: string; -} - -/** @public */ -export type ChromeBreadcrumb = EuiBreadcrumb; - -/** @public */ -export interface ChromeBreadcrumbsAppendExtension { - content: MountPoint; -} - -/** @public */ -export interface ChromeHelpExtension { - /** - * Provide your plugin's name to create a header for separation - */ - appName: string; - /** - * Creates unified links for sending users to documentation, GitHub, Discuss, or a custom link/button - */ - links?: ChromeHelpExtensionMenuLink[]; - /** - * Custom content to occur below the list of links - */ - content?: (element: HTMLDivElement) => () => void; -} - interface ConstructorParams { browserSupportsCsp: boolean; } @@ -79,7 +47,6 @@ interface StartDeps { http: HttpStart; injectedMetadata: InjectedMetadataStart; notifications: NotificationsStart; - uiSettings: IUiSettingsClient; } /** @internal */ @@ -132,7 +99,6 @@ export class ChromeService { http, injectedMetadata, notifications, - uiSettings, }: StartDeps): Promise { this.initVisibility(application); @@ -149,6 +115,17 @@ export class ChromeService { const helpSupportUrl$ = new BehaviorSubject(KIBANA_ASK_ELASTIC_LINK); const isNavDrawerLocked$ = new BehaviorSubject(localStorage.getItem(IS_LOCKED_KEY) === 'true'); + const headerBanner$ = new BehaviorSubject(undefined); + const bodyClasses$ = combineLatest([headerBanner$, this.isVisible$!]).pipe( + map(([headerBanner, isVisible]) => { + return [ + 'kbnBody', + headerBanner ? 'kbnBody--hasHeaderBanner' : 'kbnBody--noHeaderBanner', + isVisible ? 'kbnBody--chromeVisible' : 'kbnBody--chromeHidden', + ]; + }) + ); + const navControls = this.navControls.start(); const navLinks = this.navLinks.start({ application, http }); const recentlyAccessed = await this.recentlyAccessed.start({ http }); @@ -220,6 +197,7 @@ export class ChromeService { loadingCount$={http.getLoadingCount$()} application={application} appTitle$={appTitle$.pipe(takeUntil(this.stop$))} + headerBanner$={headerBanner$.pipe(takeUntil(this.stop$))} badge$={badge$.pipe(takeUntil(this.stop$))} basePath={http.basePath} breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))} @@ -312,6 +290,12 @@ export class ChromeService { setCustomNavLink: (customNavLink?: ChromeNavLink) => { customNavLink$.next(customNavLink); }, + + setHeaderBanner: (headerBanner?: ChromeUserBanner) => { + headerBanner$.next(headerBanner); + }, + + getBodyClasses$: () => bodyClasses$.pipe(takeUntil(this.stop$)), }; } @@ -320,173 +304,3 @@ export class ChromeService { this.stop$.next(); } } - -/** - * ChromeStart allows plugins to customize the global chrome header UI and - * enrich the UX with additional information about the current location of the - * browser. - * - * @remarks - * While ChromeStart exposes many APIs, they should be used sparingly and the - * developer should understand how they affect other plugins and applications. - * - * @example - * How to add a recently accessed item to the sidebar: - * ```ts - * core.chrome.recentlyAccessed.add('/app/map/1234', 'Map 1234', '1234'); - * ``` - * - * @example - * How to set the help dropdown extension: - * ```tsx - * core.chrome.setHelpExtension(elem => { - * ReactDOM.render(, elem); - * return () => ReactDOM.unmountComponentAtNode(elem); - * }); - * ``` - * - * @public - */ -export interface ChromeStart { - /** {@inheritdoc ChromeNavLinks} */ - navLinks: ChromeNavLinks; - /** {@inheritdoc ChromeNavControls} */ - navControls: ChromeNavControls; - /** {@inheritdoc ChromeRecentlyAccessed} */ - recentlyAccessed: ChromeRecentlyAccessed; - /** {@inheritdoc ChromeDocTitle} */ - docTitle: ChromeDocTitle; - - /** - * Sets the current app's title - * - * @internalRemarks - * This should be handled by the application service once it is in charge - * of mounting applications. - */ - setAppTitle(appTitle: string): void; - - /** - * Get an observable of the current brand information. - */ - getBrand$(): Observable; - - /** - * Set the brand configuration. - * - * @remarks - * Normally the `logo` property will be rendered as the - * CSS background for the home link in the chrome navigation, but when the page is - * rendered in a small window the `smallLogo` will be used and rendered at about - * 45px wide. - * - * @example - * ```js - * chrome.setBrand({ - * logo: 'url(/plugins/app/logo.png) center no-repeat' - * smallLogo: 'url(/plugins/app/logo-small.png) center no-repeat' - * }) - * ``` - * - */ - setBrand(brand: ChromeBrand): void; - - /** - * Get an observable of the current visibility state of the chrome. - */ - getIsVisible$(): Observable; - - /** - * Set the temporary visibility for the chrome. This does nothing if the chrome is hidden - * by default and should be used to hide the chrome for things like full-screen modes - * with an exit button. - */ - setIsVisible(isVisible: boolean): void; - - /** - * Get the current set of classNames that will be set on the application container. - */ - getApplicationClasses$(): Observable; - - /** - * Add a className that should be set on the application container. - */ - addApplicationClass(className: string): void; - - /** - * Remove a className added with `addApplicationClass()`. If className is unknown it is ignored. - */ - removeApplicationClass(className: string): void; - - /** - * Get an observable of the current badge - */ - getBadge$(): Observable; - - /** - * Override the current badge - */ - setBadge(badge?: ChromeBadge): void; - - /** - * Get an observable of the current list of breadcrumbs - */ - getBreadcrumbs$(): Observable; - - /** - * Override the current set of breadcrumbs - */ - setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]): void; - - /** - * Get an observable of the current extension appended to breadcrumbs - */ - getBreadcrumbsAppendExtension$(): Observable; - - /** - * Mount an element next to the last breadcrumb - */ - setBreadcrumbsAppendExtension( - breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension - ): void; - - /** - * Get an observable of the current custom nav link - */ - getCustomNavLink$(): Observable | undefined>; - - /** - * Override the current set of custom nav link - */ - setCustomNavLink(newCustomNavLink?: Partial): void; - - /** - * Get an observable of the current custom help conttent - */ - getHelpExtension$(): Observable; - - /** - * Override the current set of custom help content - */ - setHelpExtension(helpExtension?: ChromeHelpExtension): void; - - /** - * Override the default support URL shown in the help menu - * @param url The updated support URL - */ - setHelpSupportUrl(url: string): void; - - /** - * Get an observable of the current locked state of the nav drawer. - */ - getIsNavDrawerLocked$(): Observable; -} - -/** @internal */ -export interface InternalChromeStart extends ChromeStart { - /** - * Used only by MountingService to render the header UI - * @internal - */ - getHeaderComponent(): JSX.Element; -} diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index cf4106a42e0d4..069d29ca70d01 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -6,15 +6,7 @@ * Side Public License, v 1. */ -export { - ChromeBadge, - ChromeBreadcrumb, - ChromeService, - ChromeStart, - InternalChromeStart, - ChromeBrand, - ChromeHelpExtension, -} from './chrome_service'; +export { ChromeService } from './chrome_service'; export { ChromeHelpExtensionLinkBase, ChromeHelpExtensionMenuLink, @@ -28,3 +20,13 @@ export { ChromeNavLink, ChromeNavLinks, ChromeNavLinkUpdateableFields } from './ export { ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem } from './recently_accessed'; export { ChromeNavControl, ChromeNavControls } from './nav_controls'; export { ChromeDocTitle } from './doc_title'; +export { + InternalChromeStart, + ChromeStart, + ChromeHelpExtension, + ChromeBreadcrumbsAppendExtension, + ChromeBreadcrumb, + ChromeBrand, + ChromeBadge, + ChromeUserBanner, +} from './types'; diff --git a/src/core/public/chrome/types.ts b/src/core/public/chrome/types.ts new file mode 100644 index 0000000000000..732236f1ba4a1 --- /dev/null +++ b/src/core/public/chrome/types.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. + */ + +import { EuiBreadcrumb, IconType } from '@elastic/eui'; +import { Observable } from 'rxjs'; +import { MountPoint } from '../types'; +import { ChromeDocTitle } from './doc_title'; +import { ChromeNavControls } from './nav_controls'; +import { ChromeNavLinks, ChromeNavLink } from './nav_links'; +import { ChromeRecentlyAccessed } from './recently_accessed'; +import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu'; + +/** @public */ +export interface ChromeBadge { + text: string; + tooltip: string; + iconType?: IconType; +} + +/** @public */ +export interface ChromeBrand { + logo?: string; + smallLogo?: string; +} + +/** @public */ +export type ChromeBreadcrumb = EuiBreadcrumb; + +/** @public */ +export interface ChromeBreadcrumbsAppendExtension { + content: MountPoint; +} + +/** @public */ +export interface ChromeUserBanner { + content: MountPoint; +} + +/** @public */ +export interface ChromeHelpExtension { + /** + * Provide your plugin's name to create a header for separation + */ + appName: string; + /** + * Creates unified links for sending users to documentation, GitHub, Discuss, or a custom link/button + */ + links?: ChromeHelpExtensionMenuLink[]; + /** + * Custom content to occur below the list of links + */ + content?: (element: HTMLDivElement) => () => void; +} + +/** + * ChromeStart allows plugins to customize the global chrome header UI and + * enrich the UX with additional information about the current location of the + * browser. + * + * @remarks + * While ChromeStart exposes many APIs, they should be used sparingly and the + * developer should understand how they affect other plugins and applications. + * + * @example + * How to add a recently accessed item to the sidebar: + * ```ts + * core.chrome.recentlyAccessed.add('/app/map/1234', 'Map 1234', '1234'); + * ``` + * + * @example + * How to set the help dropdown extension: + * ```tsx + * core.chrome.setHelpExtension(elem => { + * ReactDOM.render(, elem); + * return () => ReactDOM.unmountComponentAtNode(elem); + * }); + * ``` + * + * @public + */ +export interface ChromeStart { + /** {@inheritdoc ChromeNavLinks} */ + navLinks: ChromeNavLinks; + /** {@inheritdoc ChromeNavControls} */ + navControls: ChromeNavControls; + /** {@inheritdoc ChromeRecentlyAccessed} */ + recentlyAccessed: ChromeRecentlyAccessed; + /** {@inheritdoc ChromeDocTitle} */ + docTitle: ChromeDocTitle; + + /** + * Sets the current app's title + * + * @internalRemarks + * This should be handled by the application service once it is in charge + * of mounting applications. + */ + setAppTitle(appTitle: string): void; + + /** + * Get an observable of the current brand information. + */ + getBrand$(): Observable; + + /** + * Set the brand configuration. + * + * @remarks + * Normally the `logo` property will be rendered as the + * CSS background for the home link in the chrome navigation, but when the page is + * rendered in a small window the `smallLogo` will be used and rendered at about + * 45px wide. + * + * @example + * ```js + * chrome.setBrand({ + * logo: 'url(/plugins/app/logo.png) center no-repeat' + * smallLogo: 'url(/plugins/app/logo-small.png) center no-repeat' + * }) + * ``` + * + */ + setBrand(brand: ChromeBrand): void; + + /** + * Get an observable of the current visibility state of the chrome. + */ + getIsVisible$(): Observable; + + /** + * Set the temporary visibility for the chrome. This does nothing if the chrome is hidden + * by default and should be used to hide the chrome for things like full-screen modes + * with an exit button. + */ + setIsVisible(isVisible: boolean): void; + + /** + * Get the current set of classNames that will be set on the application container. + */ + getApplicationClasses$(): Observable; + + /** + * Add a className that should be set on the application container. + */ + addApplicationClass(className: string): void; + + /** + * Remove a className added with `addApplicationClass()`. If className is unknown it is ignored. + */ + removeApplicationClass(className: string): void; + + /** + * Get an observable of the current badge + */ + getBadge$(): Observable; + + /** + * Override the current badge + */ + setBadge(badge?: ChromeBadge): void; + + /** + * Get an observable of the current list of breadcrumbs + */ + getBreadcrumbs$(): Observable; + + /** + * Override the current set of breadcrumbs + */ + setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]): void; + + /** + * Get an observable of the current extension appended to breadcrumbs + */ + getBreadcrumbsAppendExtension$(): Observable; + + /** + * Mount an element next to the last breadcrumb + */ + setBreadcrumbsAppendExtension( + breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension + ): void; + + /** + * Get an observable of the current custom nav link + */ + getCustomNavLink$(): Observable | undefined>; + + /** + * Override the current set of custom nav link + */ + setCustomNavLink(newCustomNavLink?: Partial): void; + + /** + * Get an observable of the current custom help conttent + */ + getHelpExtension$(): Observable; + + /** + * Override the current set of custom help content + */ + setHelpExtension(helpExtension?: ChromeHelpExtension): void; + + /** + * Override the default support URL shown in the help menu + * @param url The updated support URL + */ + setHelpSupportUrl(url: string): void; + + /** + * Get an observable of the current locked state of the nav drawer. + */ + getIsNavDrawerLocked$(): Observable; + + /** + * Set the banner that will appear on top of the chrome header. + * + * @remarks Using `undefined` when invoking this API will remove the banner. + */ + setHeaderBanner(headerBanner?: ChromeUserBanner): void; +} + +/** @internal */ +export interface InternalChromeStart extends ChromeStart { + /** + * Used only by the rendering service to render the header UI + * @internal + */ + getHeaderComponent(): JSX.Element; + /** + * Used only by the rendering service to retrieve the set of classNames + * that will be set on the body element. + * @internal + */ + getBodyClasses$(): Observable; +} 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 9e7906250949e..4f31c952b8826 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 @@ -452,6 +452,55 @@ exports[`Header renders 1`] = ` "thrownError": null, } } + headerBanner$={ + BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } helpExtension$={ BehaviorSubject { "_isScalar": false, @@ -1699,14 +1748,67 @@ exports[`Header renders 1`] = ` } } > +
({ htmlIdGenerator: () => () => 'mockId', @@ -63,6 +63,7 @@ describe('Header', () => { const navLinks$ = new BehaviorSubject([ { id: 'kibana', title: 'kibana', baseUrl: '', href: '' }, ]); + const headerBanner$ = new BehaviorSubject(undefined); const customNavLink$ = new BehaviorSubject({ id: 'cloud-deployment-link', title: 'Manage cloud deployment', @@ -85,6 +86,7 @@ describe('Header', () => { isLocked$={isLocked$} customNavLink$={customNavLink$} breadcrumbsAppendExtension$={breadcrumbsAppendExtension$} + headerBanner$={headerBanner$} /> ); expect(component.find('EuiHeader').exists()).toBeFalsy(); diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index b55e7fc412b61..16c89fdca380a 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -32,7 +32,11 @@ import { } from '../..'; import { InternalApplicationStart } from '../../../application/types'; import { HttpStart } from '../../../http'; -import { ChromeBreadcrumbsAppendExtension, ChromeHelpExtension } from '../../chrome_service'; +import { + ChromeBreadcrumbsAppendExtension, + ChromeHelpExtension, + ChromeUserBanner, +} from '../../types'; import { OnIsLockedUpdate } from './'; import { CollapsibleNav } from './collapsible_nav'; import { HeaderBadge } from './header_badge'; @@ -42,10 +46,12 @@ import { HeaderLogo } from './header_logo'; import { HeaderNavControls } from './header_nav_controls'; import { HeaderActionMenu } from './header_action_menu'; import { HeaderExtension } from './header_extension'; +import { HeaderTopBanner } from './header_top_banner'; export interface HeaderProps { kibanaVersion: string; application: InternalApplicationStart; + headerBanner$: Observable; appTitle$: Observable; badge$: Observable; breadcrumbs$: Observable; @@ -84,7 +90,12 @@ export function Header({ const breadcrumbsAppendExtension = useObservable(breadcrumbsAppendExtension$); if (!isVisible) { - return ; + return ( + <> + + + + ); } const toggleCollapsibleNavRef = createRef(); @@ -97,11 +108,13 @@ export function Header({ return ( <> +
-
+
- + ; diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx index 4db79d0233ae9..0e2bae82a3ad3 100644 --- a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx @@ -11,7 +11,7 @@ import classNames from 'classnames'; import React from 'react'; import useObservable from 'react-use/lib/useObservable'; import { Observable } from 'rxjs'; -import { ChromeBreadcrumb } from '../../chrome_service'; +import { ChromeBreadcrumb } from '../../types'; interface Props { appTitle$: Observable; diff --git a/src/core/public/chrome/ui/header/header_help_menu.tsx b/src/core/public/chrome/ui/header/header_help_menu.tsx index a20613f7e77ef..c6a09c1177a5e 100644 --- a/src/core/public/chrome/ui/header/header_help_menu.tsx +++ b/src/core/public/chrome/ui/header/header_help_menu.tsx @@ -26,7 +26,7 @@ import { import { InternalApplicationStart } from '../../../application'; import { GITHUB_CREATE_ISSUE_LINK, KIBANA_FEEDBACK_LINK } from '../../constants'; -import { ChromeHelpExtension } from '../../chrome_service'; +import { ChromeHelpExtension } from '../../types'; import { HeaderExtension } from './header_extension'; import { isModifiedOrPrevented } from './nav_link'; diff --git a/src/core/public/chrome/ui/header/header_top_banner.tsx b/src/core/public/chrome/ui/header/header_top_banner.tsx new file mode 100644 index 0000000000000..667cf9025880f --- /dev/null +++ b/src/core/public/chrome/ui/header/header_top_banner.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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, { FC } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { Observable } from 'rxjs'; +import { ChromeUserBanner } from '../../types'; +import { HeaderExtension } from './header_extension'; + +export interface HeaderTopBannerProps { + headerBanner$: Observable; +} + +export const HeaderTopBanner: FC = ({ headerBanner$ }) => { + const headerBanner = useObservable(headerBanner$, undefined); + if (!headerBanner) { + return null; + } + + return ( +
+ +
+ ); +}; diff --git a/src/core/public/core_app/styles/_mixins.scss b/src/core/public/core_app/styles/_mixins.scss index 6da7fab8c2f76..d088a47144f33 100644 --- a/src/core/public/core_app/styles/_mixins.scss +++ b/src/core/public/core_app/styles/_mixins.scss @@ -1,3 +1,5 @@ +@import '../../variables'; + @mixin flexParent($grow: 1, $shrink: 1, $basis: auto, $direction: column) { flex: $grow $shrink $basis; display: flex; @@ -82,6 +84,12 @@ overflow: auto; animation: kibanaFullScreenGraphics_FadeIn $euiAnimSpeedExtraSlow $euiAnimSlightResistance 0s forwards; + @at-root { + .kbnBody--hasHeaderBanner & { + top: $kbnHeaderBannerHeight; + } + } + &::before { position: absolute; top: 0; diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 02e12ddf4b78b..278bbe469e862 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -194,7 +194,6 @@ export class CoreSystem { http, injectedMetadata, notifications, - uiSettings, }); this.coreApp.start({ application, http, notifications, uiSettings }); diff --git a/src/core/public/index.scss b/src/core/public/index.scss index 6ba9254e5d381..04e2759c91d5d 100644 --- a/src/core/public/index.scss +++ b/src/core/public/index.scss @@ -1,4 +1,5 @@ @import './variables'; +@import './mixins'; @import './core'; @import './chrome/index'; @import './overlays/index'; diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 423b2c84072b8..3cb6e1beb4e6e 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -46,6 +46,7 @@ import { ChromeStart, ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem, + ChromeUserBanner, NavType, } from './chrome'; import { FatalErrorsSetup, FatalErrorsStart, FatalErrorInfo } from './fatal_errors'; @@ -300,6 +301,7 @@ export { ChromeDocTitle, ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem, + ChromeUserBanner, ChromeStart, DocLinksStart, FatalErrorInfo, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 99579ada8ec58..b4a2c40f3003b 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -378,11 +378,18 @@ export interface ChromeStart { setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]): void; setBreadcrumbsAppendExtension(breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension): void; setCustomNavLink(newCustomNavLink?: Partial): void; + setHeaderBanner(headerBanner?: ChromeUserBanner): void; setHelpExtension(helpExtension?: ChromeHelpExtension): void; setHelpSupportUrl(url: string): void; setIsVisible(isVisible: boolean): void; } +// @public (undocumented) +export interface ChromeUserBanner { + // (undocumented) + content: MountPoint; +} + // @internal (undocumented) export interface CoreContext { // Warning: (ae-forgotten-export) The symbol "CoreId" needs to be exported by the entry point index.d.ts @@ -1519,6 +1526,7 @@ export interface UiSettingsParams { name?: string; optionLabels?: Record; options?: string[]; + order?: number; readonly?: boolean; requiresPageReload?: boolean; // (undocumented) @@ -1537,7 +1545,7 @@ export interface UiSettingsState { } // @public -export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image'; +export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color'; // @public export type UnmountCallback = () => void; diff --git a/src/core/public/rendering/_base.scss b/src/core/public/rendering/_base.scss index b806ac270331d..de13785a17f5b 100644 --- a/src/core/public/rendering/_base.scss +++ b/src/core/public/rendering/_base.scss @@ -1,4 +1,4 @@ -@include euiHeaderAffordForFixed($kbnHeaderOffset); +@import '../mixins'; /** * stretch the root element of the Kibana application to set the base-size that @@ -15,11 +15,8 @@ display: flex; flex-flow: column nowrap; margin: 0 auto; - min-height: calc(100vh - #{$kbnHeaderOffset}); - &.hidden-chrome { - min-height: 100vh; - } + @include kibanaFullBodyMinHeight(); } .app-wrapper-panel { @@ -33,3 +30,28 @@ flex-shrink: 0; } } + +// adapted from euiHeaderAffordForFixed as we need to handle the top banner +@mixin kbnAffordForHeader($headerHeight) { + padding-top: $headerHeight; + + .euiFlyout, + .euiCollapsibleNav { + top: $headerHeight; + height: calc(100% - #{$headerHeight}); + } +} + +.kbnBody { + @include kbnAffordForHeader($kbnHeaderOffset); + + &.kbnBody--hasHeaderBanner { + @include kbnAffordForHeader($kbnHeaderOffsetWithBanner); + } + &.kbnBody--chromeHidden { + @include kbnAffordForHeader(0); + } + &.kbnBody--chromeHidden.kbnBody--hasHeaderBanner { + @include kbnAffordForHeader($kbnHeaderBannerHeight); + } +} diff --git a/src/core/public/rendering/rendering_service.tsx b/src/core/public/rendering/rendering_service.tsx index 47b340eed8468..843f2a253f33e 100644 --- a/src/core/public/rendering/rendering_service.tsx +++ b/src/core/public/rendering/rendering_service.tsx @@ -9,6 +9,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; +import { pairwise, startWith } from 'rxjs/operators'; import { InternalChromeStart } from '../chrome'; import { InternalApplicationStart } from '../application'; @@ -32,19 +33,27 @@ interface StartDeps { */ export class RenderingService { start({ application, chrome, overlays, targetDomElement }: StartDeps) { - const chromeUi = chrome.getHeaderComponent(); - const appUi = application.getComponent(); - const bannerUi = overlays.banners.getComponent(); + const chromeHeader = chrome.getHeaderComponent(); + const appComponent = application.getComponent(); + const bannerComponent = overlays.banners.getComponent(); + + const body = document.querySelector('body')!; + chrome + .getBodyClasses$() + .pipe(startWith([]), pairwise()) + .subscribe(([previousClasses, newClasses]) => { + body.classList.remove(...previousClasses); + body.classList.add(...newClasses); + }); ReactDOM.render(
- {chromeUi} - + {chromeHeader}
-
{bannerUi}
- {appUi} +
{bannerComponent}
+ {appComponent}
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 67330e7d4dfb3..5419441bbb1e2 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -3106,6 +3106,7 @@ export interface UiSettingsParams { name?: string; optionLabels?: Record; options?: string[]; + order?: number; readonly?: boolean; requiresPageReload?: boolean; // (undocumented) @@ -3128,7 +3129,7 @@ export interface UiSettingsServiceStart { } // @public -export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image'; +export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color'; // @public export interface UserProvidedValues { diff --git a/src/core/types/ui_settings.ts b/src/core/types/ui_settings.ts index e50dc18d9ff1f..235553293d153 100644 --- a/src/core/types/ui_settings.ts +++ b/src/core/types/ui_settings.ts @@ -22,7 +22,8 @@ export type UiSettingsType = | 'boolean' | 'string' | 'array' - | 'image'; + | 'image' + | 'color'; /** * UiSettings deprecation field options. @@ -65,6 +66,13 @@ export interface UiSettingsParams { type?: UiSettingsType; /** optional deprecation information. Used to generate a deprecation warning. */ deprecation?: DeprecationSettings; + /** + * index of the settings within its category (ascending order, smallest will be displayed first). + * Used for ordering in the UI. + * + * @remark settings without order defined will be displayed last and ordered by name + */ + order?: number; /* * Allows defining a custom validation applicable to value change on the client. * @deprecated diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx index 0be582a4c0294..c7a8c0a6135c7 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx @@ -12,7 +12,7 @@ import { UnregisterCallback } from 'history'; import { parse } from 'query-string'; import { UiCounterMetricType } from '@kbn/analytics'; -import { Comparators, EuiFlexGroup, EuiFlexItem, EuiSpacer, Query } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, Query } from '@elastic/eui'; import { IUiSettingsClient, @@ -28,7 +28,7 @@ import { Form } from './components/form'; import { AdvancedSettingsVoiceAnnouncement } from './components/advanced_settings_voice_announcement'; import { ComponentRegistry } from '../'; -import { getAriaName, toEditableConfig, DEFAULT_CATEGORY } from './lib'; +import { getAriaName, toEditableConfig, fieldSorter, DEFAULT_CATEGORY } from './lib'; import { FieldSetting, SettingsChanges } from './types'; import { parseErrorMsg } from './components/search/search'; @@ -185,17 +185,17 @@ export class AdvancedSettings extends Component { + .map(([settingId, settingDef]) => { return toEditableConfig({ - def: setting[1], - name: setting[0], - value: setting[1].userValue, - isCustom: config.isCustom(setting[0]), - isOverridden: config.isOverridden(setting[0]), + def: settingDef, + name: settingId, + value: settingDef.userValue, + isCustom: config.isCustom(settingId), + isOverridden: config.isOverridden(settingId), }); }) - .filter((c) => !c.readonly) - .sort(Comparators.property('name', Comparators.default('asc'))); + .filter((c) => !c.readOnly) + .sort(fieldSorter); } mapSettings(settings: FieldSetting[]) { diff --git a/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap b/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap index 19bf9e6d73757..517a6238c2519 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap +++ b/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap @@ -866,6 +866,419 @@ exports[`Field for boolean setting should render user value if there is user val `; +exports[`Field for color setting should render as read only if saving is disabled 1`] = ` + +
+ + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + +

+ } +> + + + + +`; + +exports[`Field for color setting should render as read only with help text if overridden 1`] = ` + +
+ + + + + + null + , + } + } + /> + + + + + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + +

+ } +> + + + + } + label="color:test:setting" + labelType="label" + > + + + +`; + +exports[`Field for color setting should render custom setting icon if it is custom 1`] = ` + +
+ + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + } + type="asterisk" + /> + +

+ } +> + + + + +`; + +exports[`Field for color setting should render default value if there is no user value set 1`] = ` + +
+ + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + +

+ } +> + + + + +`; + +exports[`Field for color setting should render unsaved value if there are unsaved changes 1`] = ` + +
+ + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + } + type="asterisk" + /> + +

+ } +> + + + +

+ Setting is currently not saved. +

+
+
+ +`; + +exports[`Field for color setting should render user value if there is user value is set 1`] = ` + +
+ + + + + + null + , + } + } + /> + + + + + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + +

+ } +> + + + + + +     + + + } + label="color:test:setting" + labelType="label" + > + + + +`; + exports[`Field for image setting should render as read only if saving is disabled 1`] = ` = { isOverridden: false, ...defaults, }, + color: { + name: 'color:test:setting', + ariaName: 'color test setting', + displayName: 'Color test setting', + description: 'Description for Color test setting', + type: 'color', + value: undefined, + defVal: null, + isCustom: false, + isOverridden: false, + ...defaults, + }, }; const userValues = { array: ['user', 'value'], @@ -174,6 +187,7 @@ const userValues = { select: 'banana', string: 'foo', stringWithValidation: 'fooUserValue', + color: '#FACF0C', }; const invalidUserValues = { @@ -187,6 +201,8 @@ const getFieldSettingValue = (wrapper: ReactWrapper, name: string, type: string) const field = findTestSubject(wrapper, `advancedSetting-editField-${name}`); if (type === 'boolean') { return field.props()['aria-checked']; + } else if (type === 'color') { + return field.props().color; } else { return field.props().value; } @@ -423,6 +439,36 @@ describe('Field', () => { }); } }); + } else if (type === 'color') { + describe(`for changing ${type} setting`, () => { + const { wrapper, component } = setup(); + const userValue = userValues[type]; + + it('should be able to change value', async () => { + await (component.instance() as Field).onFieldChange(userValue); + const updated = wrapper.update(); + expect(handleChange).toBeCalledWith(setting.name, { value: userValue }); + updated.setProps({ unsavedChanges: { value: userValue } }); + const currentValue = wrapper.find('EuiColorPicker').prop('color'); + expect(currentValue).toEqual(userValue); + }); + + it('should be able to reset to default value', async () => { + await wrapper.setProps({ + unsavedChanges: {}, + setting: { ...setting, value: userValue }, + }); + const updated = wrapper.update(); + findTestSubject(updated, `advancedSetting-resetField-${setting.name}`).simulate('click'); + const expectedEditableValue = getEditableValue(setting.type, setting.defVal); + expect(handleChange).toBeCalledWith(setting.name, { + value: expectedEditableValue, + }); + updated.setProps({ unsavedChanges: { value: expectedEditableValue } }); + const currentValue = wrapper.find('EuiColorPicker').prop('color'); + expect(currentValue).toEqual(expectedEditableValue); + }); + }); } else { describe(`for changing ${type} setting`, () => { const { wrapper, component } = setup(); diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx index 5569a6e11872a..f5db5c3e371b3 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx @@ -17,6 +17,7 @@ import { EuiBadge, EuiCode, EuiCodeBlock, + EuiColorPicker, EuiScreenReaderOnly, EuiCodeEditor, EuiDescribedFormGroup, @@ -392,6 +393,17 @@ export class Field extends PureComponent { data-test-subj={`advancedSetting-editField-${name}`} /> ); + case 'color': + return ( + + ); default: return ( ): FieldSetting => ({ + displayName: 'displayName', + name: 'field', + value: 'value', + requiresPageReload: false, + type: 'string', + category: [], + ariaName: 'ariaName', + isOverridden: false, + defVal: 'defVal', + isCustom: false, + ...parts, +}); + +describe('fieldSorter', () => { + it('sort fields based on their `order` field if present on both', () => { + const fieldA = createField({ order: 3 }); + const fieldB = createField({ order: 1 }); + const fieldC = createField({ order: 2 }); + + expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldB, fieldC, fieldA]); + }); + it('fields with order defined are ordered first', () => { + const fieldA = createField({ order: 2 }); + const fieldB = createField({ order: undefined }); + const fieldC = createField({ order: 1 }); + + expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldC, fieldA, fieldB]); + }); + it('sorts by `name` when fields have the same `order`', () => { + const fieldA = createField({ order: 2, name: 'B' }); + const fieldB = createField({ order: 1 }); + const fieldC = createField({ order: 2, name: 'A' }); + + expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldB, fieldC, fieldA]); + }); + + it('sorts by `name` when fields have no `order`', () => { + const fieldA = createField({ order: undefined, name: 'B' }); + const fieldB = createField({ order: undefined, name: 'A' }); + const fieldC = createField({ order: 1 }); + + expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldC, fieldB, fieldA]); + }); +}); diff --git a/src/plugins/advanced_settings/public/management_app/lib/sort_fields.ts b/src/plugins/advanced_settings/public/management_app/lib/sort_fields.ts new file mode 100644 index 0000000000000..90bfa18d2198e --- /dev/null +++ b/src/plugins/advanced_settings/public/management_app/lib/sort_fields.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Comparators } from '@elastic/eui'; +import { FieldSetting } from '../types'; + +const cmp = Comparators.default('asc'); + +export const fieldSorter = (a: FieldSetting, b: FieldSetting): number => { + const aOrder = a.order !== undefined; + const bOrder = b.order !== undefined; + + if (aOrder && bOrder) { + if (a.order === b.order) { + return cmp(a.name, b.name); + } + return cmp(a.order, b.order); + } + if (aOrder) { + return -1; + } + if (bOrder) { + return 1; + } + return cmp(a.name, b.name); +}; diff --git a/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts b/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts index b2b7f1c1016cd..49abe3b279a28 100644 --- a/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts +++ b/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts @@ -12,6 +12,7 @@ import { StringValidationRegexString, SavedObjectAttribute, } from 'src/core/public'; +import { FieldSetting } from '../types'; import { getValType } from './get_val_type'; import { getAriaName } from './get_aria_name'; import { DEFAULT_CATEGORY } from './default_category'; @@ -41,7 +42,7 @@ export function toEditableConfig({ const validationTyped = def.validation as StringValidationRegexString; - const conf = { + const conf: FieldSetting = { name, displayName: def.name || name, ariaName: def.name || getAriaName(name), @@ -49,7 +50,7 @@ export function toEditableConfig({ category: def.category && def.category.length ? def.category : [DEFAULT_CATEGORY], isCustom, isOverridden, - readonly: !!def.readonly, + readOnly: !!def.readonly, defVal: def.value, type: getValType(def, value), description: def.description, @@ -63,6 +64,7 @@ export function toEditableConfig({ : def.validation, options: def.options, optionLabels: def.optionLabels, + order: def.order, requiresPageReload: !!def.requiresPageReload, metric: def.metric, }; diff --git a/src/plugins/advanced_settings/public/management_app/types.ts b/src/plugins/advanced_settings/public/management_app/types.ts index 0563fa310bc77..50b39114d2143 100644 --- a/src/plugins/advanced_settings/public/management_app/types.ts +++ b/src/plugins/advanced_settings/public/management_app/types.ts @@ -25,6 +25,7 @@ export interface FieldSetting { isCustom: boolean; validation?: StringValidation | ImageValidation; readOnly?: boolean; + order?: number; deprecation?: { message: string; docLinksKey: string; diff --git a/src/plugins/discover/public/application/components/discover.scss b/src/plugins/discover/public/application/components/discover.scss index 90bfd84c4d54e..02e60700d49d8 100644 --- a/src/plugins/discover/public/application/components/discover.scss +++ b/src/plugins/discover/public/application/components/discover.scss @@ -1,10 +1,12 @@ +@import '../../../../../core/public/mixins'; + discover-app { flex-grow: 1; } .dscPage { @include euiBreakpoint('m', 'l', 'xl') { - height: calc(100vh - #{($euiHeaderHeightCompensation * 2)}); + @include kibanaFullBodyHeight(); } flex-direction: column; diff --git a/src/plugins/home/public/application/components/_home.scss b/src/plugins/home/public/application/components/_home.scss index 5ff0d0f21b985..913e1511a6314 100644 --- a/src/plugins/home/public/application/components/_home.scss +++ b/src/plugins/home/public/application/components/_home.scss @@ -1,8 +1,10 @@ +@import '../../../../../core/public/mixins'; + .homWrapper { + @include kibanaFullBodyMinHeight(); background-color: $euiColorEmptyShade; display: flex; flex-direction: column; - min-height: calc(100vh - #{$euiHeaderHeightCompensation}); } .homContent { diff --git a/src/plugins/home/public/application/components/home.js b/src/plugins/home/public/application/components/home.js index cec815a1a9bc6..3c1ba8eea22ca 100644 --- a/src/plugins/home/public/application/components/home.js +++ b/src/plugins/home/public/application/components/home.js @@ -51,6 +51,9 @@ export class Home extends Component { componentWillUnmount() { this._isMounted = false; + + const body = document.querySelector('body'); + body.classList.remove('isHomPage'); } componentDidMount() { diff --git a/src/plugins/kibana_overview/public/components/_overview.scss b/src/plugins/kibana_overview/public/components/_overview.scss index 5b750202310fb..94555013d0a77 100644 --- a/src/plugins/kibana_overview/public/components/_overview.scss +++ b/src/plugins/kibana_overview/public/components/_overview.scss @@ -1,8 +1,10 @@ +@import '../../../../core/public/mixins'; + .kbnOverviewWrapper { + @include kibanaFullBodyMinHeight(); background-color: $euiColorEmptyShade; display: flex; flex-direction: column; - min-height: calc(100vh - #{$euiHeaderHeightCompensation}); } .kbnOverviewContent { diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 86a14a4289ecd..a6ad4d522f8ee 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -58,7 +58,8 @@ "xpack.uptime": ["plugins/uptime"], "xpack.urlDrilldown": "plugins/drilldowns/url_drilldown", "xpack.watcher": "plugins/watcher", - "xpack.observability": "plugins/observability" + "xpack.observability": "plugins/observability", + "xpack.banners": "plugins/banners" }, "exclude": ["examples"], "translations": [ diff --git a/x-pack/plugins/banners/README.md b/x-pack/plugins/banners/README.md new file mode 100644 index 0000000000000..890c194e1bcb0 --- /dev/null +++ b/x-pack/plugins/banners/README.md @@ -0,0 +1,38 @@ +# Kibana banners plugin + +Allow to add a header banner that will be displayed on every page of the Kibana application + +## Configuration + +The plugin's configuration prefix is `xpack.banners` + +The options are + +- `placement` + +The placement of the banner. The allowed values are: + - `disabled` - The banner will be disabled + - `header` - The banner will be displayed in the header + +- `textContent` + +The text content that will be displayed inside the banner, either plain text or markdown + +- `textColor` + +The color of the banner's text. Must be a valid hex color + +- `backgroundColor` + +The color for the banner's background. Must be a valid hex color + +### Configuration example + +`kibana.yml` +```yaml +xpack.banners: + placement: 'header' + textContent: 'Production environment - Proceed with **special levels** of caution' + textColor: '#FF0000' + backgroundColor: '#CC2211' +``` \ No newline at end of file diff --git a/x-pack/plugins/banners/common/index.ts b/x-pack/plugins/banners/common/index.ts new file mode 100644 index 0000000000000..a4c38a58ab572 --- /dev/null +++ b/x-pack/plugins/banners/common/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { BannerInfoResponse, BannerPlacement, BannerConfiguration } from './types'; diff --git a/x-pack/plugins/banners/common/types.ts b/x-pack/plugins/banners/common/types.ts new file mode 100644 index 0000000000000..0c785f516ddb3 --- /dev/null +++ b/x-pack/plugins/banners/common/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface BannerInfoResponse { + allowed: boolean; + banner: BannerConfiguration; +} + +export type BannerPlacement = 'disabled' | 'header'; + +export interface BannerConfiguration { + placement: BannerPlacement; + textContent: string; + textColor: string; + backgroundColor: string; +} diff --git a/x-pack/plugins/banners/jest.config.js b/x-pack/plugins/banners/jest.config.js new file mode 100644 index 0000000000000..e2d103c8e4a28 --- /dev/null +++ b/x-pack/plugins/banners/jest.config.js @@ -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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/banners'], +}; diff --git a/x-pack/plugins/banners/kibana.json b/x-pack/plugins/banners/kibana.json new file mode 100644 index 0000000000000..3e9441aaa2726 --- /dev/null +++ b/x-pack/plugins/banners/kibana.json @@ -0,0 +1,11 @@ +{ + "id": "banners", + "version": "8.0.0", + "kibanaVersion": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["licensing"], + "optionalPlugins": [], + "requiredBundles": ["kibanaReact"], + "configPath": ["xpack", "banners"] +} diff --git a/x-pack/plugins/banners/public/components/banner.scss b/x-pack/plugins/banners/public/components/banner.scss new file mode 100644 index 0000000000000..586605becb45a --- /dev/null +++ b/x-pack/plugins/banners/public/components/banner.scss @@ -0,0 +1,7 @@ +.kbnUserBanner__container { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: $euiFontSizeS; +} diff --git a/x-pack/plugins/banners/public/components/banner.tsx b/x-pack/plugins/banners/public/components/banner.tsx new file mode 100644 index 0000000000000..ea30e46881d0c --- /dev/null +++ b/x-pack/plugins/banners/public/components/banner.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { Markdown } from '../../../../../src/plugins/kibana_react/public'; +import { BannerConfiguration } from '../../common'; + +import './banner.scss'; + +interface BannerProps { + bannerConfig: BannerConfiguration; +} + +export const Banner: FC = ({ bannerConfig }) => { + const { textContent, textColor, backgroundColor } = bannerConfig; + return ( +
+
+ +
+
+ ); +}; diff --git a/x-pack/plugins/banners/public/components/index.ts b/x-pack/plugins/banners/public/components/index.ts new file mode 100644 index 0000000000000..c23c24fd9c163 --- /dev/null +++ b/x-pack/plugins/banners/public/components/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { Banner } from './banner'; diff --git a/x-pack/plugins/banners/public/get_banner_info.test.ts b/x-pack/plugins/banners/public/get_banner_info.test.ts new file mode 100644 index 0000000000000..cfb9bc26db47b --- /dev/null +++ b/x-pack/plugins/banners/public/get_banner_info.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '../../../../src/core/public/mocks'; +import { getBannerInfo } from './get_banner_info'; + +describe('getBannerInfo', () => { + let http: ReturnType; + + beforeEach(() => { + http = httpServiceMock.createStartContract(); + }); + + it('calls `http.get` with the correct parameters', async () => { + await getBannerInfo(http); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(http.get).toHaveBeenCalledWith('/api/banners/info'); + }); + + it('returns the value from the service', async () => { + const expected = { + allowed: true, + }; + http.get.mockResolvedValue(expected); + + const response = await getBannerInfo(http); + + expect(response).toEqual(expected); + }); +}); diff --git a/x-pack/plugins/banners/public/get_banner_info.ts b/x-pack/plugins/banners/public/get_banner_info.ts new file mode 100644 index 0000000000000..56b32b26bef7c --- /dev/null +++ b/x-pack/plugins/banners/public/get_banner_info.ts @@ -0,0 +1,13 @@ +/* + * 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 { HttpStart } from 'src/core/public'; +import { BannerInfoResponse } from '../common'; + +export const getBannerInfo = async (http: HttpStart): Promise => { + return await http.get('/api/banners/info'); +}; diff --git a/x-pack/plugins/banners/public/index.ts b/x-pack/plugins/banners/public/index.ts new file mode 100644 index 0000000000000..d38a4d4785e09 --- /dev/null +++ b/x-pack/plugins/banners/public/index.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 { PluginInitializer } from 'src/core/public'; +import { BannersPlugin } from './plugin'; + +export const plugin: PluginInitializer<{}, {}, {}, {}> = (contextInitializer) => + new BannersPlugin(contextInitializer); diff --git a/x-pack/plugins/banners/public/plugin.test.mocks.ts b/x-pack/plugins/banners/public/plugin.test.mocks.ts new file mode 100644 index 0000000000000..cadd10dc96f94 --- /dev/null +++ b/x-pack/plugins/banners/public/plugin.test.mocks.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getBannerInfoMock = jest.fn(); +jest.doMock('./get_banner_info', () => ({ + getBannerInfo: getBannerInfoMock, +})); diff --git a/x-pack/plugins/banners/public/plugin.test.tsx b/x-pack/plugins/banners/public/plugin.test.tsx new file mode 100644 index 0000000000000..036ad17e2598e --- /dev/null +++ b/x-pack/plugins/banners/public/plugin.test.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getBannerInfoMock } from './plugin.test.mocks'; +import { coreMock } from '../../../../src/core/public/mocks'; +import { BannersPlugin } from './plugin'; +import { BannerClientConfig } from './types'; + +const nextTick = async () => await new Promise((resolve) => resolve()); + +describe('BannersPlugin', () => { + let plugin: BannersPlugin; + let pluginInitContext: ReturnType; + let coreSetup: ReturnType; + let coreStart: ReturnType; + + beforeEach(() => { + pluginInitContext = coreMock.createPluginInitializerContext(); + coreSetup = coreMock.createSetup(); + coreStart = coreMock.createStart(); + + getBannerInfoMock.mockResolvedValue({ + allowed: false, + }); + }); + + const startPlugin = async (config: BannerClientConfig) => { + pluginInitContext = coreMock.createPluginInitializerContext(config); + plugin = new BannersPlugin(pluginInitContext); + plugin.setup(coreSetup); + plugin.start(coreStart); + // await for the `getBannerInfo` promise to resolve + await nextTick(); + }; + + afterEach(() => { + getBannerInfoMock.mockReset(); + }); + + it('calls `getBannerInfo` if `config.placement !== disabled`', async () => { + await startPlugin({ + placement: 'header', + }); + + expect(getBannerInfoMock).toHaveBeenCalledTimes(1); + }); + + it('does not call `getBannerInfo` if `config.placement === disabled`', async () => { + await startPlugin({ + placement: 'disabled', + }); + + expect(getBannerInfoMock).not.toHaveBeenCalled(); + }); + + it('registers the header banner if `getBannerInfo` return `allowed=true`', async () => { + getBannerInfoMock.mockResolvedValue({ + allowed: true, + }); + + await startPlugin({ + placement: 'header', + }); + + expect(coreStart.chrome.setHeaderBanner).toHaveBeenCalledTimes(1); + expect(coreStart.chrome.setHeaderBanner).toHaveBeenCalledWith({ + content: expect.any(Function), + }); + }); + + it('does not register the header banner if `getBannerInfo` return `allowed=false`', async () => { + getBannerInfoMock.mockResolvedValue({ + allowed: false, + }); + + await startPlugin({ + placement: 'header', + }); + + expect(coreStart.chrome.setHeaderBanner).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/banners/public/plugin.tsx b/x-pack/plugins/banners/public/plugin.tsx new file mode 100644 index 0000000000000..dca99a816a25b --- /dev/null +++ b/x-pack/plugins/banners/public/plugin.tsx @@ -0,0 +1,44 @@ +/* + * 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 { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; +import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; +import { Banner } from './components'; +import { BannerClientConfig } from './types'; +import { getBannerInfo } from './get_banner_info'; + +export class BannersPlugin implements Plugin<{}, {}, {}, {}> { + private readonly config: BannerClientConfig; + + constructor(context: PluginInitializerContext) { + this.config = context.config.get(); + } + + setup({}: CoreSetup<{}, {}>) { + return {}; + } + + start({ chrome, uiSettings, http }: CoreStart) { + if (this.config.placement !== 'disabled') { + getBannerInfo(http).then( + ({ allowed, banner }) => { + if (allowed) { + chrome.setHeaderBanner({ + content: toMountPoint(), + }); + } + }, + () => { + chrome.setHeaderBanner(undefined); + } + ); + } + + return {}; + } +} diff --git a/x-pack/plugins/banners/public/types.ts b/x-pack/plugins/banners/public/types.ts new file mode 100644 index 0000000000000..1f0ce524a785e --- /dev/null +++ b/x-pack/plugins/banners/public/types.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { BannerPlacement } from '../common'; + +export interface BannerClientConfig { + placement: BannerPlacement; +} diff --git a/x-pack/plugins/banners/server/config.ts b/x-pack/plugins/banners/server/config.ts new file mode 100644 index 0000000000000..9a8cc9680c296 --- /dev/null +++ b/x-pack/plugins/banners/server/config.ts @@ -0,0 +1,42 @@ +/* + * 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 { PluginConfigDescriptor } from 'kibana/server'; +import { isHexColor } from './utils'; + +const configSchema = schema.object({ + placement: schema.oneOf([schema.literal('disabled'), schema.literal('header')], { + defaultValue: 'disabled', + }), + textContent: schema.string({ defaultValue: '' }), + textColor: schema.string({ + validate: (color) => { + if (!isHexColor(color)) { + return `must be an hex color`; + } + }, + defaultValue: '#8A6A0A', + }), + backgroundColor: schema.string({ + validate: (color) => { + if (!isHexColor(color)) { + return `must be an hex color`; + } + }, + defaultValue: '#FFF9E8', + }), +}); + +export type BannersConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: { + placement: true, + }, +}; diff --git a/x-pack/plugins/banners/server/index.ts b/x-pack/plugins/banners/server/index.ts new file mode 100644 index 0000000000000..2036eda7e6502 --- /dev/null +++ b/x-pack/plugins/banners/server/index.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 { PluginInitializer } from 'src/core/server'; +import { BannersPlugin } from './plugin'; + +export { config } from './config'; +export const plugin: PluginInitializer<{}, {}, {}, {}> = (context) => new BannersPlugin(context); diff --git a/x-pack/plugins/banners/server/plugin.ts b/x-pack/plugins/banners/server/plugin.ts new file mode 100644 index 0000000000000..66cd083189975 --- /dev/null +++ b/x-pack/plugins/banners/server/plugin.ts @@ -0,0 +1,33 @@ +/* + * 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 { CoreSetup, Plugin, PluginInitializerContext } from 'src/core/server'; +import { BannerConfiguration } from '../common'; +import { BannersConfigType } from './config'; +import { BannersRequestHandlerContext } from './types'; +import { registerRoutes } from './routes'; + +export class BannersPlugin implements Plugin<{}, {}, {}, {}> { + private readonly config: BannerConfiguration; + + constructor(context: PluginInitializerContext) { + this.config = convertConfig(context.config.get()); + } + + setup({ uiSettings, getStartServices, http }: CoreSetup<{}, {}>) { + const router = http.createRouter(); + registerRoutes(router, this.config); + + return {}; + } + + start() { + return {}; + } +} + +const convertConfig = (raw: BannersConfigType): BannerConfiguration => raw; diff --git a/x-pack/plugins/banners/server/routes/index.ts b/x-pack/plugins/banners/server/routes/index.ts new file mode 100644 index 0000000000000..a4eedc3234c86 --- /dev/null +++ b/x-pack/plugins/banners/server/routes/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { BannerConfiguration } from '../../common'; +import { BannersRouter } from '../types'; +import { registerInfoRoute } from './info'; + +export const registerRoutes = (router: BannersRouter, config: BannerConfiguration) => { + registerInfoRoute(router, config); +}; diff --git a/x-pack/plugins/banners/server/routes/info.ts b/x-pack/plugins/banners/server/routes/info.ts new file mode 100644 index 0000000000000..e0db842028c37 --- /dev/null +++ b/x-pack/plugins/banners/server/routes/info.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ILicense } from '../../../licensing/server'; +import { BannerInfoResponse, BannerConfiguration } from '../../common'; +import { BannersRouter } from '../types'; + +export const registerInfoRoute = (router: BannersRouter, config: BannerConfiguration) => { + router.get( + { + path: '/api/banners/info', + validate: false, + options: { + authRequired: false, + }, + }, + (ctx, req, res) => { + const allowed = isValidLicense(ctx.licensing.license); + + return res.ok({ + body: { + allowed, + banner: config, + } as BannerInfoResponse, + }); + } + ); +}; + +const isValidLicense = (license: ILicense): boolean => { + return license.hasAtLeast('gold'); +}; diff --git a/x-pack/plugins/banners/server/types.ts b/x-pack/plugins/banners/server/types.ts new file mode 100644 index 0000000000000..96f7224e62c22 --- /dev/null +++ b/x-pack/plugins/banners/server/types.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 { RequestHandlerContext, IRouter } from 'src/core/server'; +import { LicensingApiRequestHandlerContext } from '../../licensing/server'; + +export interface BannersRequestHandlerContext extends RequestHandlerContext { + licensing: LicensingApiRequestHandlerContext; +} + +export type BannersRouter = IRouter; diff --git a/x-pack/plugins/banners/server/utils.test.ts b/x-pack/plugins/banners/server/utils.test.ts new file mode 100644 index 0000000000000..57b7a3ede0f8f --- /dev/null +++ b/x-pack/plugins/banners/server/utils.test.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 { isHexColor } from './utils'; + +describe('isHexColor', () => { + it('returns true for valid 3-length hex colors', () => { + expect(isHexColor('#FEC')).toBe(true); + expect(isHexColor('#0a4')).toBe(true); + }); + + it('returns true for valid 6-length hex colors', () => { + expect(isHexColor('#FF00CC')).toBe(true); + expect(isHexColor('#fab47e')).toBe(true); + }); + + it('returns false for other strings', () => { + expect(isHexColor('#FAZ')).toBe(false); + expect(isHexColor('#FFAAUU')).toBe(false); + expect(isHexColor('foobar')).toBe(false); + }); +}); diff --git a/x-pack/plugins/banners/server/utils.ts b/x-pack/plugins/banners/server/utils.ts new file mode 100644 index 0000000000000..1597b3a2ace3c --- /dev/null +++ b/x-pack/plugins/banners/server/utils.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. + */ + +const hexColorRegexp = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i; + +export const isHexColor = (color: string) => { + return hexColorRegexp.test(color); +}; diff --git a/x-pack/plugins/banners/tsconfig.json b/x-pack/plugins/banners/tsconfig.json new file mode 100644 index 0000000000000..85608a8a78ad5 --- /dev/null +++ b/x-pack/plugins/banners/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "public/**/*", + "server/**/*", + "common/**/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" } + ] +} + diff --git a/x-pack/plugins/maps/public/_main.scss b/x-pack/plugins/maps/public/_main.scss index 5ce3bf4e2b998..61de65dd4bf6f 100644 --- a/x-pack/plugins/maps/public/_main.scss +++ b/x-pack/plugins/maps/public/_main.scss @@ -1,19 +1,15 @@ -@import '../../../../src/core/public/variables'; +@import '../../../../src/core/public/mixins'; // sass-lint:disable no-ids #maps-plugin { + @include kibanaFullBodyHeight(); + display: flex; flex-direction: column; - height: calc(100vh - #{$kbnHeaderOffset}); width: 100%; overflow: hidden; } -.mapFullScreen { - // sass-lint:disable no-important - height: 100vh !important; -} - #react-maps-root { flex-grow: 1; display: flex; diff --git a/x-pack/plugins/painless_lab/public/styles/_index.scss b/x-pack/plugins/painless_lab/public/styles/_index.scss index e5ed8f38a31ee..00197e744e95c 100644 --- a/x-pack/plugins/painless_lab/public/styles/_index.scss +++ b/x-pack/plugins/painless_lab/public/styles/_index.scss @@ -1,4 +1,5 @@ @import '@elastic/eui/src/global_styling/variables/header'; +@import '../../../../../src/core/public/mixins'; /** * This is a very brittle way of preventing the editor and other content from disappearing @@ -39,11 +40,11 @@ $bottomBarHeight: $euiSize * 3; line-height: 0; } -// This value is calculated to static value using SCSS because calc in calc has issues in IE11 -$headerOffset: $euiHeaderHeightCompensation * 3; +// adding dev tool top bar + bottom bar height to the body offset +$bodyOffset: $euiHeaderHeightCompensation + $bottomBarHeight; .painlessLabMainContainer { - height: calc(100vh - #{$headerOffset} - #{$bottomBarHeight}); + @include kibanaFullBodyHeight($bodyOffset); } .painlessLabPanelsContainer { diff --git a/x-pack/plugins/searchprofiler/public/application/_app.scss b/x-pack/plugins/searchprofiler/public/application/_app.scss index 6a2d1eb5e2189..3c163fa8fefec 100644 --- a/x-pack/plugins/searchprofiler/public/application/_app.scss +++ b/x-pack/plugins/searchprofiler/public/application/_app.scss @@ -1,3 +1,5 @@ +@import '../../../../../src/core/public/mixins'; + .prfDevTool__page { flex: 1 1 auto; @@ -28,11 +30,11 @@ } } -// This value is calculated to static value using SCSS because calc in calc has issues in IE11 -$headerHeightOffset: $euiHeaderHeightCompensation * 3; +// adding dev tool top bar to the body offset +$bodyOffset: $euiHeaderHeightCompensation; .appRoot { - height: calc(100vh - #{$headerHeightOffset}); + @include kibanaFullBodyHeight($bodyOffset); overflow: hidden; flex-shrink: 1; } diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 6209503e75610..4b56ebc83d989 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -40,6 +40,7 @@ { "path": "../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../plugins/actions/tsconfig.json" }, { "path": "../plugins/alerts/tsconfig.json" }, + { "path": "../plugins/banners/tsconfig.json" }, { "path": "../plugins/beats_management/tsconfig.json" }, { "path": "../plugins/cloud/tsconfig.json" }, { "path": "../plugins/code/tsconfig.json" }, diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 5589c62010db1..6b874f6253843 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -7,6 +7,7 @@ "plugins/apm/e2e/cypress/**/*", "plugins/apm/ftr_e2e/**/*", "plugins/apm/scripts/**/*", + "plugins/banners/**/*", "plugins/canvas/**/*", "plugins/console_extensions/**/*", "plugins/code/**/*", From 06883ad3c6c68f1bb7907fad4dc1ff3ec4d3e78e Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 11 Feb 2021 12:42:29 +0100 Subject: [PATCH 05/53] [Search Session] Control "Kibana / Search Sessions" management section by privileges (#90818) (#91096) --- .../kibana-plugin-plugins-data-public.md | 1 + ...ta-public.search_sessions_management_id.md | 11 ++ src/plugins/data/public/index.ts | 1 + src/plugins/data/public/public.api.md | 37 +++--- src/plugins/data/public/search/index.ts | 1 + .../public/search/search_interceptor.test.ts | 46 +++++--- .../data/public/search/search_interceptor.ts | 4 +- .../data/public/search/session/constants.ts | 9 ++ .../data/public/search/session/index.ts | 1 + .../data/public/search/session/mocks.ts | 1 + .../search/session/session_service.test.ts | 38 +++++- .../public/search/session/session_service.ts | 34 +++++- .../helpers/timelion_request_handler.ts | 5 +- .../public/request_handler.ts | 5 +- .../public/search/sessions_mgmt/index.ts | 3 +- ...onnected_search_session_indicator.test.tsx | 51 +++++--- .../connected_search_session_indicator.tsx | 17 +++ .../search_session_indicator.tsx | 28 +++-- .../__snapshots__/oss_features.test.ts.snap | 24 +++- .../plugins/features/server/oss_features.ts | 12 ++ .../apps/management/search_sessions/index.ts | 1 + .../sessions_management_permissions.ts | 111 ++++++++++++++++++ 22 files changed, 370 insertions(+), 71 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search_sessions_management_id.md create mode 100644 src/plugins/data/public/search/session/constants.ts create mode 100644 x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management_permissions.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index f576d795b93a5..d2e7ef9db05e8 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -126,6 +126,7 @@ | [noSearchSessionStorageCapabilityMessage](./kibana-plugin-plugins-data-public.nosearchsessionstoragecapabilitymessage.md) | Message to display in case storing session session is disabled due to turned off capability | | [parseSearchSourceJSON](./kibana-plugin-plugins-data-public.parsesearchsourcejson.md) | | | [QueryStringInput](./kibana-plugin-plugins-data-public.querystringinput.md) | | +| [SEARCH\_SESSIONS\_MANAGEMENT\_ID](./kibana-plugin-plugins-data-public.search_sessions_management_id.md) | | | [search](./kibana-plugin-plugins-data-public.search.md) | | | [SearchBar](./kibana-plugin-plugins-data-public.searchbar.md) | | | [syncQueryStateWithUrl](./kibana-plugin-plugins-data-public.syncquerystatewithurl.md) | Helper to setup syncing of global data with the URL | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search_sessions_management_id.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search_sessions_management_id.md new file mode 100644 index 0000000000000..ad16d21403a98 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search_sessions_management_id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SEARCH\_SESSIONS\_MANAGEMENT\_ID](./kibana-plugin-plugins-data-public.search_sessions_management_id.md) + +## SEARCH\_SESSIONS\_MANAGEMENT\_ID variable + +Signature: + +```typescript +SEARCH_SESSIONS_MANAGEMENT_ID = "search_sessions" +``` diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 17533eec0a0fa..83a248ee2c3de 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -381,6 +381,7 @@ export { TimeoutErrorMode, PainlessError, noSearchSessionStorageCapabilityMessage, + SEARCH_SESSIONS_MANAGEMENT_ID, } from './search'; export type { diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 798f595a7f60a..03ae893ab526d 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -2246,6 +2246,11 @@ export const search: { tabifyGetColumns: typeof tabifyGetColumns; }; +// Warning: (ae-missing-release-tag) "SEARCH_SESSIONS_MANAGEMENT_ID" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const SEARCH_SESSIONS_MANAGEMENT_ID = "search_sessions"; + // Warning: (ae-missing-release-tag) "SearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2609,23 +2614,23 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:408:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:415:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:419:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:423:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:424:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:34:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/search/session/session_service.ts:41:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/search/session/session_service.ts:42:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index 31a94d69ddf02..b1e0bc490823a 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -38,6 +38,7 @@ export { SessionsClient, ISessionsClient, noSearchSessionStorageCapabilityMessage, + SEARCH_SESSIONS_MANAGEMENT_ID, } from './session'; export { getEsPreference } from './es_search'; diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts index 02d5a19c31743..f890fdc3e30a3 100644 --- a/src/plugins/data/public/search/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor.test.ts @@ -95,21 +95,23 @@ describe('SearchInterceptor', () => { }); describe('Search session', () => { - const setup = ({ - isRestore = false, - isStored = false, - sessionId, - }: { - isRestore?: boolean; - isStored?: boolean; - sessionId: string; - }) => { + const setup = ( + opts: { + isRestore?: boolean; + isStored?: boolean; + sessionId: string; + } | null + ) => { const sessionServiceMock = searchMock.session as jest.Mocked; - sessionServiceMock.getSearchOptions.mockImplementation(() => ({ - sessionId, - isRestore, - isStored, - })); + sessionServiceMock.getSearchOptions.mockImplementation(() => + opts + ? { + sessionId: opts.sessionId, + isRestore: opts.isRestore ?? false, + isStored: opts.isStored ?? false, + } + : null + ); fetchMock.mockResolvedValue({ result: 200 }); }; @@ -142,6 +144,22 @@ describe('SearchInterceptor', () => { (searchMock.session as jest.Mocked).getSearchOptions ).toHaveBeenCalledWith(sessionId); }); + + test("doesn't forward sessionId if search options return null", async () => { + const sessionId = 'sid'; + setup(null); + + await searchInterceptor.search(mockRequest, { sessionId }).toPromise(); + expect(fetchMock.mock.calls[0][0]).toEqual( + expect.not.objectContaining({ + options: { sessionId }, + }) + ); + + expect( + (searchMock.session as jest.Mocked).getSearchOptions + ).toHaveBeenCalledWith(sessionId); + }); }); describe('Should throw typed errors', () => { diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index fde8ac9f25d6e..f33740cc45bf9 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -126,14 +126,14 @@ export class SearchInterceptor { request: IKibanaSearchRequest, options?: ISearchOptions ): Promise { - const { abortSignal, ...requestOptions } = options || {}; + const { abortSignal, sessionId, ...requestOptions } = options || {}; return this.batchedFetch( { request, options: { ...requestOptions, - ...(options?.sessionId && this.deps.session.getSearchOptions(options.sessionId)), + ...this.deps.session.getSearchOptions(sessionId), }, }, abortSignal diff --git a/src/plugins/data/public/search/session/constants.ts b/src/plugins/data/public/search/session/constants.ts new file mode 100644 index 0000000000000..5496a541bfd45 --- /dev/null +++ b/src/plugins/data/public/search/session/constants.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 const SEARCH_SESSIONS_MANAGEMENT_ID = 'search_sessions'; diff --git a/src/plugins/data/public/search/session/index.ts b/src/plugins/data/public/search/session/index.ts index 82ba1e703a6d6..15410400a33e6 100644 --- a/src/plugins/data/public/search/session/index.ts +++ b/src/plugins/data/public/search/session/index.ts @@ -10,3 +10,4 @@ export { SessionService, ISessionService, SearchSessionInfoProvider } from './se export { SearchSessionState } from './search_session_state'; export { SessionsClient, ISessionsClient } from './sessions_client'; export { noSearchSessionStorageCapabilityMessage } from './i18n'; +export { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants'; diff --git a/src/plugins/data/public/search/session/mocks.ts b/src/plugins/data/public/search/session/mocks.ts index f6a70d157b5a0..c615be641078b 100644 --- a/src/plugins/data/public/search/session/mocks.ts +++ b/src/plugins/data/public/search/session/mocks.ts @@ -41,5 +41,6 @@ export function getSessionServiceMock(): jest.Mocked { enableStorage: jest.fn(), isSessionStorageReady: jest.fn(() => true), getSearchSessionIndicatorUiConfig: jest.fn(() => ({ isDisabled: () => ({ disabled: false }) })), + hasAccess: jest.fn(() => true), }; } diff --git a/src/plugins/data/public/search/session/session_service.test.ts b/src/plugins/data/public/search/session/session_service.test.ts index 54c402f51ec70..3d49c91fea44e 100644 --- a/src/plugins/data/public/search/session/session_service.test.ts +++ b/src/plugins/data/public/search/session/session_service.test.ts @@ -14,11 +14,13 @@ import { BehaviorSubject } from 'rxjs'; import { SearchSessionState } from './search_session_state'; import { createNowProviderMock } from '../../now_provider/mocks'; import { NowProviderInternalContract } from '../../now_provider'; +import { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants'; describe('Session service', () => { let sessionService: ISessionService; let state$: BehaviorSubject; let nowProvider: jest.Mocked; + let userHasAccessToSearchSessions = true; beforeEach(() => { const initializerContext = coreMock.createPluginInitializerContext(); @@ -30,7 +32,18 @@ describe('Session service', () => { startService().then(([coreStart, ...rest]) => [ { ...coreStart, - application: { ...coreStart.application, currentAppId$: new BehaviorSubject('app') }, + application: { + ...coreStart.application, + currentAppId$: new BehaviorSubject('app'), + capabilities: { + ...coreStart.application.capabilities, + management: { + kibana: { + [SEARCH_SESSIONS_MANAGEMENT_ID]: userHasAccessToSearchSessions, + }, + }, + }, + }, }, ...rest, ]), @@ -146,6 +159,8 @@ describe('Session service', () => { isRestore: true, sessionId, }); + + expect(sessionService.getSearchOptions(undefined)).toBeNull(); }); test('isCurrentSession', () => { expect(sessionService.isCurrentSession()).toBeFalsy(); @@ -214,4 +229,25 @@ describe('Session service', () => { sessionService.start(); await expect(() => sessionService.save()).rejects.toMatchInlineSnapshot(`[Error: Haha]`); }); + + describe("user doesn't have access to search session", () => { + beforeAll(() => { + userHasAccessToSearchSessions = false; + }); + afterAll(() => { + userHasAccessToSearchSessions = true; + }); + + test("getSearchOptions doesn't return sessionId", () => { + const sessionId = sessionService.start(); + expect(sessionService.getSearchOptions(sessionId)).toBeNull(); + }); + + test('save() throws', async () => { + sessionService.start(); + await expect(() => sessionService.save()).rejects.toThrowErrorMatchingInlineSnapshot( + `"No access to search sessions"` + ); + }); + }); }); diff --git a/src/plugins/data/public/search/session/session_service.ts b/src/plugins/data/public/search/session/session_service.ts index 79ae64c5846a5..4286edf27cd40 100644 --- a/src/plugins/data/public/search/session/session_service.ts +++ b/src/plugins/data/public/search/session/session_service.ts @@ -20,6 +20,7 @@ import { import { ISessionsClient } from './sessions_client'; import { ISearchOptions } from '../../../common'; import { NowProviderInternalContract } from '../../now_provider'; +import { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants'; export type ISessionService = PublicContract; @@ -68,6 +69,7 @@ export class SessionService { private searchSessionIndicatorUiConfig?: Partial; private subscription = new Subscription(); private curApp?: string; + private hasAccessToSearchSessions: boolean = false; constructor( initializerContext: PluginInitializerContext, @@ -94,6 +96,10 @@ export class SessionService { ); getStartServices().then(([coreStart]) => { + // using management?.kibana? we infer if any of the apps allows current user to store sessions + this.hasAccessToSearchSessions = + coreStart.application.capabilities.management?.kibana?.[SEARCH_SESSIONS_MANAGEMENT_ID]; + // Apps required to clean up their sessions before unmounting // Make sure that apps don't leave sessions open. this.subscription.add( @@ -117,6 +123,15 @@ export class SessionService { }); } + /** + * If user has access to search sessions + * This resolves to `true` in case at least one app allows user to create search session + * In this case search session management is available + */ + public hasAccess() { + return this.hasAccessToSearchSessions; + } + /** * Used to track pending searches within current session * @@ -215,6 +230,7 @@ export class SessionService { const sessionId = this.getSessionId(); if (!sessionId) throw new Error('No current session'); if (!this.curApp) throw new Error('No current app id'); + if (!this.hasAccess()) throw new Error('No access to search sessions'); const currentSessionInfoProvider = this.searchSessionInfoProvider; if (!currentSessionInfoProvider) throw new Error('No info provider for current session'); const [name, { initialState, restoreState, urlGeneratorId }] = await Promise.all([ @@ -247,11 +263,25 @@ export class SessionService { /** * Infers search session options for sessionId using current session state + * + * In case user doesn't has access to `search-session` SO returns null, + * meaning that sessionId and other session parameters shouldn't be used when doing searches + * * @param sessionId */ public getSearchOptions( - sessionId: string - ): Required> { + sessionId?: string + ): Required> | null { + if (!sessionId) { + return null; + } + + // in case user doesn't have permissions to search session, do not forward sessionId to the server + // because user most likely also doesn't have access to `search-session` SO + if (!this.hasAccessToSearchSessions) { + return null; + } + const isCurrentSession = this.isCurrentSession(sessionId); return { sessionId, diff --git a/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts b/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts index c07fd0a278197..7e8f28bd32b2f 100644 --- a/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts +++ b/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts @@ -94,6 +94,7 @@ export function getTimelionRequestHandler({ }); try { + const searchSessionOptions = dataSearch.session.getSearchOptions(searchSessionId); return await http.post('/api/timelion/run', { body: JSON.stringify({ sheet: [expression], @@ -108,8 +109,8 @@ export function getTimelionRequestHandler({ interval: visParams.interval, timezone, }, - ...(searchSessionId && { - searchSession: dataSearch.session.getSearchOptions(searchSessionId), + ...(searchSessionOptions && { + searchSession: searchSessionOptions, }), }), }); diff --git a/src/plugins/vis_type_timeseries/public/request_handler.ts b/src/plugins/vis_type_timeseries/public/request_handler.ts index c7beccbceca1a..d0526f7e1d886 100644 --- a/src/plugins/vis_type_timeseries/public/request_handler.ts +++ b/src/plugins/vis_type_timeseries/public/request_handler.ts @@ -48,6 +48,7 @@ export const metricsRequestHandler = async ({ }); try { + const searchSessionOptions = dataSearch.session.getSearchOptions(searchSessionId); return await getCoreStart().http.post(ROUTES.VIS_DATA, { body: JSON.stringify({ timerange: { @@ -58,8 +59,8 @@ export const metricsRequestHandler = async ({ filters: input?.filters, panels: [visParams], state: uiStateObj, - ...(searchSessionId && { - searchSession: dataSearch.session.getSearchOptions(searchSessionId), + ...(searchSessionOptions && { + searchSession: searchSessionOptions, }), }), }); diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts index 332b30809077c..e13cd06f52a4d 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts @@ -15,6 +15,7 @@ import type { ConfigSchema } from '../../../config'; import type { DataEnhancedStartDependencies } from '../../plugin'; import type { SearchSessionsMgmtAPI } from './lib/api'; import type { AsyncSearchIntroDocumentation } from './lib/documentation'; +import { SEARCH_SESSIONS_MANAGEMENT_ID } from '../../../../../../src/plugins/data/public'; export interface IManagementSectionsPluginsSetup { management: ManagementSetup; @@ -38,7 +39,7 @@ export interface AppDependencies { } export const APP = { - id: 'search_sessions', + id: SEARCH_SESSIONS_MANAGEMENT_ID, getI18nName: (): string => i18n.translate('xpack.data.mgmt.searchSessions.appTitle', { defaultMessage: 'Search Sessions', diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx index 3437920ed7c98..aacb86f269727 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx @@ -24,6 +24,7 @@ import userEvent from '@testing-library/user-event'; import { IntlProvider } from 'react-intl'; const coreStart = coreMock.createStart(); +const application = coreStart.application; const dataStart = dataPluginMock.createStartContract(); const sessionService = dataStart.search.session as jest.Mocked; let storage: Storage; @@ -52,7 +53,7 @@ beforeEach(() => { test("shouldn't show indicator in case no active search session", async () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -79,7 +80,7 @@ test("shouldn't show indicator in case no active search session", async () => { test("shouldn't show indicator in case app hasn't opt-in", async () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -108,7 +109,7 @@ test('should show indicator in case there is an active search session', async () const state$ = new BehaviorSubject(SearchSessionState.Loading); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -124,12 +125,6 @@ test('should show indicator in case there is an active search session', async () test('should be disabled in case uiConfig says so ', async () => { const state$ = new BehaviorSubject(SearchSessionState.Loading); - coreStart.application.currentAppId$ = new BehaviorSubject('discover'); - (coreStart.application.capabilities as any) = { - discover: { - storeSearchSession: false, - }, - }; sessionService.getSearchSessionIndicatorUiConfig.mockImplementation(() => ({ isDisabled: () => ({ disabled: true, @@ -138,7 +133,7 @@ test('should be disabled in case uiConfig says so ', async () => { })); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -157,12 +152,36 @@ test('should be disabled in case uiConfig says so ', async () => { expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled(); }); +test('should be disabled in case not enough permissions', async () => { + const state$ = new BehaviorSubject(SearchSessionState.Completed); + const SearchSessionIndicator = createConnectedSearchSessionIndicator({ + sessionService: { ...sessionService, state$, hasAccess: () => false }, + application, + timeFilter, + storage, + disableSaveAfterSessionCompletesTimeout, + }); + + render( + + + + ); + + await waitFor(() => screen.getByTestId('searchSessionIndicator')); + + await userEvent.click(screen.getByLabelText('Search session complete')); + + expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Manage sessions' })).toBeDisabled(); +}); + test('should be disabled during auto-refresh', async () => { const state$ = new BehaviorSubject(SearchSessionState.Loading); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -199,7 +218,7 @@ describe('Completed inactivity', () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -257,7 +276,7 @@ describe('tour steps', () => { const state$ = new BehaviorSubject(SearchSessionState.Loading); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -294,7 +313,7 @@ describe('tour steps', () => { const state$ = new BehaviorSubject(SearchSessionState.Loading); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -325,7 +344,7 @@ describe('tour steps', () => { const state$ = new BehaviorSubject(SearchSessionState.Restored); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -347,7 +366,7 @@ describe('tour steps', () => { const state$ = new BehaviorSubject(SearchSessionState.Completed); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx index 3935b5bb2814b..81769e5a25544 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx @@ -79,6 +79,9 @@ export const createConnectedSearchSessionIndicator = ({ let saveDisabled = false; let saveDisabledReasonText: string = ''; + let managementDisabled = false; + let managementDisabledReasonText: string = ''; + if (autoRefreshEnabled) { saveDisabled = true; saveDisabledReasonText = i18n.translate( @@ -104,6 +107,18 @@ export const createConnectedSearchSessionIndicator = ({ saveDisabledReasonText = isSaveDisabledByApp.reasonText; } + // check if user doesn't have access to search_sessions and search_sessions mgtm + // this happens in case there is no app that allows current user to use search session + if (!sessionService.hasAccess()) { + managementDisabled = saveDisabled = true; + managementDisabledReasonText = saveDisabledReasonText = i18n.translate( + 'xpack.data.searchSessionIndicator.disabledDueToDisabledGloballyMessage', + { + defaultMessage: "You don't have permissions to manage search sessions", + } + ); + } + const { markOpenedDone, markRestoredDone } = useSearchSessionTour( storage, searchSessionIndicator, @@ -143,6 +158,8 @@ export const createConnectedSearchSessionIndicator = ({ state={state} saveDisabled={saveDisabled} saveDisabledReasonText={saveDisabledReasonText} + managementDisabled={managementDisabled} + managementDisabledReasonText={managementDisabledReasonText} onContinueInBackground={onContinueInBackground} onSaveResults={onSaveResults} onCancel={onCancel} diff --git a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx index eb58039ff58f7..0d31ce0c98f19 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx @@ -31,7 +31,8 @@ export interface SearchSessionIndicatorProps { onCancel?: () => void; viewSearchSessionsLink?: string; onSaveResults?: () => void; - + managementDisabled?: boolean; + managementDisabledReasonText?: string; saveDisabled?: boolean; saveDisabledReasonText?: string; @@ -78,17 +79,22 @@ const ContinueInBackgroundButton = ({ const ViewAllSearchSessionsButton = ({ viewSearchSessionsLink = 'management/kibana/search_sessions', buttonProps = {}, + managementDisabled, + managementDisabledReasonText, }: ActionButtonProps) => ( - - - + + + + + ); const SaveButton = ({ diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index 8432fdac93a9a..c941badcad223 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -67,7 +67,11 @@ Array [ "catalogue": Array [ "dashboard", ], - "management": Object {}, + "management": Object { + "kibana": Array [ + "search_sessions", + ], + }, "savedObject": Object { "all": Array [ "dashboard", @@ -200,7 +204,11 @@ Array [ "catalogue": Array [ "discover", ], - "management": Object {}, + "management": Object { + "kibana": Array [ + "search_sessions", + ], + }, "savedObject": Object { "all": Array [ "search", @@ -553,7 +561,11 @@ Array [ "catalogue": Array [ "dashboard", ], - "management": Object {}, + "management": Object { + "kibana": Array [ + "search_sessions", + ], + }, "savedObject": Object { "all": Array [ "dashboard", @@ -686,7 +698,11 @@ Array [ "catalogue": Array [ "discover", ], - "management": Object {}, + "management": Object { + "kibana": Array [ + "search_sessions", + ], + }, "savedObject": Object { "all": Array [ "search", diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index 2d9e01427a277..6c599461f438a 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -21,6 +21,9 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.discoverFeatureName', { defaultMessage: 'Discover', }), + management: { + kibana: ['search_sessions'], + }, order: 100, category: DEFAULT_APP_CATEGORIES.kibana, app: ['discover', 'kibana'], @@ -95,6 +98,9 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS read: [], }, ui: ['storeSearchSession'], + management: { + kibana: ['search_sessions'], + }, }, ], }, @@ -166,6 +172,9 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.dashboardFeatureName', { defaultMessage: 'Dashboard', }), + management: { + kibana: ['search_sessions'], + }, order: 200, category: DEFAULT_APP_CATEGORIES.kibana, app: ['dashboards', 'kibana'], @@ -260,6 +269,9 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS read: [], }, ui: ['storeSearchSession'], + management: { + kibana: ['search_sessions'], + }, }, ], }, diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/index.ts b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/index.ts index 994d91ae4a27b..0798a25a2e982 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/index.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/index.ts @@ -22,5 +22,6 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { }); loadTestFile(require.resolve('./sessions_management')); + loadTestFile(require.resolve('./sessions_management_permissions')); }); } diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management_permissions.ts b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management_permissions.ts new file mode 100644 index 0000000000000..48f4156afbe82 --- /dev/null +++ b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management_permissions.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const security = getService('security'); + const PageObjects = getPageObjects([ + 'common', + 'header', + 'dashboard', + 'visChart', + 'searchSessionsManagement', + 'security', + ]); + + const appsMenu = getService('appsMenu'); + const managementMenu = getService('managementMenu'); + + describe('Search sessions Management UI permissions', () => { + describe('Sessions management is not available if non of apps enable search sessions', () => { + before(async () => { + await security.role.create('data_analyst', { + elasticsearch: {}, + kibana: [ + { + feature: { + dashboard: ['read'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('analyst', { + password: 'analyst-password', + roles: ['data_analyst'], + full_name: 'test user', + }); + + await PageObjects.security.forceLogout(); + + await PageObjects.security.login('analyst', 'analyst-password', { + expectSpaceSelector: false, + }); + }); + + after(async () => { + await security.role.delete('data_analyst'); + await security.user.delete('analyst'); + await PageObjects.security.forceLogout(); + }); + + it('Sessions management is not available if non of apps enable search sessions', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.not.contain('Stack Management'); + }); + }); + + describe('Sessions management is available if one of apps enables search sessions', () => { + before(async () => { + await security.role.create('data_analyst', { + elasticsearch: {}, + kibana: [ + { + feature: { + dashboard: ['read', 'store_search_session'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('analyst', { + password: 'analyst-password', + roles: ['data_analyst'], + full_name: 'test user', + }); + + await PageObjects.security.forceLogout(); + + await PageObjects.security.login('analyst', 'analyst-password', { + expectSpaceSelector: false, + }); + }); + + after(async () => { + await security.role.delete('data_analyst'); + await security.user.delete('analyst'); + await PageObjects.security.forceLogout(); + }); + + it('Sessions management is available if one of apps enables search sessions', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(1); + expect(sections[0]).to.eql({ + sectionId: 'kibana', + sectionLinks: ['search_sessions'], + }); + }); + }); + }); +} From 457374bd78704fdfd439c3cbacca04fcec499e1f Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 11 Feb 2021 13:36:51 +0100 Subject: [PATCH 06/53] [7.x] [Lens] Median as default function (#90952) (#91090) --- .../indexpattern_suggestions.test.tsx | 16 ++++++++-------- .../operations/definitions/metrics.tsx | 1 + .../operations/operations.test.ts | 18 +++++++++--------- .../apps/discover/visualize_field.ts | 2 +- .../test/functional/apps/lens/drag_and_drop.ts | 4 ++-- 5 files changed, 21 insertions(+), 20 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 66e8f29fa1587..1e928f1c0b2bf 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 @@ -319,7 +319,7 @@ describe('IndexPattern Data Source suggestions', () => { sourceField: 'timestamp', }), id2: expect.objectContaining({ - operationType: 'avg', + operationType: 'median', sourceField: 'bytes', }), }, @@ -400,7 +400,7 @@ describe('IndexPattern Data Source suggestions', () => { columnOrder: ['id1'], columns: { id1: expect.objectContaining({ - operationType: 'avg', + operationType: 'median', sourceField: 'bytes', }), }, @@ -542,7 +542,7 @@ describe('IndexPattern Data Source suggestions', () => { sourceField: 'timestamp', }), id1: expect.objectContaining({ - operationType: 'avg', + operationType: 'median', sourceField: 'bytes', }), }, @@ -624,7 +624,7 @@ describe('IndexPattern Data Source suggestions', () => { columnOrder: ['id1'], columns: { id1: expect.objectContaining({ - operationType: 'avg', + operationType: 'median', sourceField: 'bytes', }), }, @@ -914,7 +914,7 @@ describe('IndexPattern Data Source suggestions', () => { columns: { cola: initialState.layers.currentLayer.columns.cola, colb: expect.objectContaining({ - operationType: 'avg', + operationType: 'median', sourceField: 'memory', }), }, @@ -934,7 +934,7 @@ describe('IndexPattern Data Source suggestions', () => { cola: initialState.layers.currentLayer.columns.cola, colb: initialState.layers.currentLayer.columns.colb, newid: expect.objectContaining({ - operationType: 'avg', + operationType: 'median', sourceField: 'memory', }), }, @@ -979,7 +979,7 @@ describe('IndexPattern Data Source suggestions', () => { columns: { ...modifiedState.layers.currentLayer.columns, newid: expect.objectContaining({ - operationType: 'avg', + operationType: 'median', sourceField: 'memory', }), }, @@ -2039,7 +2039,7 @@ describe('IndexPattern Data Source suggestions', () => { table: expect.objectContaining({ columns: [ expect.objectContaining({ - operation: expect.objectContaining({ label: 'Sum of field1' }), + operation: expect.objectContaining({ label: 'Median of field1' }), }), ], }), diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx index 21fc11693daba..e11ee580deb9b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx @@ -181,6 +181,7 @@ export const sumOperation = buildMetricOperation({ export const medianOperation = buildMetricOperation({ type: 'median', + priority: 3, displayName: i18n.translate('xpack.lens.indexPattern.median', { defaultMessage: 'Median', }), diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index 360e1697ae58d..8c5dee8bbb28f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -86,12 +86,12 @@ describe('getOperationTypesForField', () => { ).toEqual([ 'range', 'terms', + 'median', 'avg', 'sum', 'min', 'max', 'cardinality', - 'median', 'percentile', 'last_value', ]); @@ -109,7 +109,7 @@ describe('getOperationTypesForField', () => { }, (op) => !op.isBucketed ) - ).toEqual(['avg', 'sum', 'min', 'max', 'cardinality', 'median', 'percentile', 'last_value']); + ).toEqual(['median', 'avg', 'sum', 'min', 'max', 'cardinality', 'percentile', 'last_value']); }); it('should return operations on dates', () => { @@ -197,14 +197,14 @@ describe('getOperationTypesForField', () => { }); describe('getAvailableOperationsByMetaData', () => { - it('should put the average operation first', () => { + it('should put the median operation first', () => { const numberOperation = getAvailableOperationsByMetadata(expectedIndexPatterns[1]).find( ({ operationMetaData }) => !operationMetaData.isBucketed && operationMetaData.dataType === 'number' )!; expect(numberOperation.operations[0]).toEqual( expect.objectContaining({ - operationType: 'avg', + operationType: 'median', }) ); }); @@ -279,6 +279,11 @@ describe('getOperationTypesForField', () => { "scale": "ratio", }, "operations": Array [ + Object { + "field": "bytes", + "operationType": "median", + "type": "field", + }, Object { "field": "bytes", "operationType": "avg", @@ -330,11 +335,6 @@ describe('getOperationTypesForField', () => { "operationType": "cardinality", "type": "field", }, - Object { - "field": "bytes", - "operationType": "median", - "type": "field", - }, Object { "field": "bytes", "operationType": "percentile", diff --git a/x-pack/test/functional/apps/discover/visualize_field.ts b/x-pack/test/functional/apps/discover/visualize_field.ts index f9312f453e8dd..d0d7c25c205e5 100644 --- a/x-pack/test/functional/apps/discover/visualize_field.ts +++ b/x-pack/test/functional/apps/discover/visualize_field.ts @@ -52,7 +52,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await retry.try(async () => { const dimensions = await testSubjects.findAll('lns-dimensionTrigger'); expect(dimensions).to.have.length(2); - expect(await dimensions[1].getVisibleText()).to.be('Average of bytes'); + expect(await dimensions[1].getVisibleText()).to.be('Median of bytes'); }); }); diff --git a/x-pack/test/functional/apps/lens/drag_and_drop.ts b/x-pack/test/functional/apps/lens/drag_and_drop.ts index a272b67de1b0a..0e4d428c26029 100644 --- a/x-pack/test/functional/apps/lens/drag_and_drop.ts +++ b/x-pack/test/functional/apps/lens/drag_and_drop.ts @@ -149,7 +149,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { await PageObjects.lens.dragFieldWithKeyboard('bytes', 4); expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([ 'Count of records', - 'Average of bytes', + 'Median of bytes', ]); await PageObjects.lens.dragFieldWithKeyboard('@message.raw', 1, true); expect( @@ -169,7 +169,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { await PageObjects.lens.dimensionKeyboardDragDrop('lnsXY_yDimensionPanel', 0, 1); expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([ 'Count of records', - 'Average of bytes', + 'Median of bytes', 'Count of records [1]', ]); From 240415ef7a90c2657f214702ace961adaeb5992b Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Thu, 11 Feb 2021 13:00:29 +0000 Subject: [PATCH 07/53] [Alerting][Docs] adds documentation on NTP based synchronization (#90747) (#91101) Adds docs on usage of NTP to sync nodes in a prod setting for alerting. --- .../alerting/alerting-production-considerations.asciidoc | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/user/alerting/alerting-production-considerations.asciidoc b/docs/user/alerting/alerting-production-considerations.asciidoc index 3a68e81879e24..cc7adc87b150e 100644 --- a/docs/user/alerting/alerting-production-considerations.asciidoc +++ b/docs/user/alerting/alerting-production-considerations.asciidoc @@ -27,4 +27,9 @@ Because by default tasks are polled at 3 second intervals and only 10 tasks can For details on the settings that can influence the performance and throughput of Task Manager, see {task-manager-settings}. -============================================== \ No newline at end of file +============================================== + +[float] +=== Deployment considerations + +{es} and {kib} instances use the system clock to determine the current time. To ensure schedules are triggered when expected, you should synchronize the clocks of all nodes in the cluster using a time service such as http://www.ntp.org/[Network Time Protocol]. \ No newline at end of file From 7247e9fcb5abb1f15797b5d6bfb4e59cdad01241 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Thu, 11 Feb 2021 16:35:47 +0300 Subject: [PATCH 08/53] [Timelion] Communicate the index pattern to the dashboard (#90623) (#91112) * [Timelion] Communicate the index pattern to the dashboard Closes #86418 * update types / limits.yml * Update timelion_vis_type.tsx * fix typo * remove extra await Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> # Conflicts: # packages/kbn-optimizer/limits.yml --- .eslintignore | 2 +- packages/kbn-optimizer/limits.yml | 2 +- .../{public => common}/_generated_/chain.js | 0 .../vis_type_timelion/common/parser.ts | 50 +++++++++++++++++++ .../timelion_expression_input_helpers.ts | 31 ++++++------ .../public/helpers/arg_value_suggestions.ts | 43 ++++------------ .../public/timelion_vis_type.tsx | 20 +++++++- .../server/handlers/lib/parse_sheet.js | 11 ++-- tasks/config/peg.js | 4 +- 9 files changed, 102 insertions(+), 61 deletions(-) rename src/plugins/vis_type_timelion/{public => common}/_generated_/chain.js (100%) create mode 100644 src/plugins/vis_type_timelion/common/parser.ts diff --git a/.eslintignore b/.eslintignore index 5513ad1320232..ea8ab55ad7726 100644 --- a/.eslintignore +++ b/.eslintignore @@ -22,7 +22,7 @@ snapshots.js /src/core/lib/kbn_internal_native_observable /src/legacy/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/broken /src/plugins/data/common/es_query/kuery/ast/_generated_/** -/src/plugins/vis_type_timelion/public/_generated_/** +/src/plugins/vis_type_timelion/common/_generated_/** /x-pack/legacy/plugins/**/__tests__/fixtures/** /x-pack/plugins/apm/e2e/tmp/* /x-pack/plugins/canvas/canvas_plugin diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 5c399a052485f..657aabca1e86d 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -91,7 +91,7 @@ pageLoadAssetSize: visTypeMetric: 42790 visTypeTable: 95078 visTypeTagcloud: 37575 - visTypeTimelion: 51933 + visTypeTimelion: 68883 visTypeTimeseries: 155347 visTypeVega: 153861 visTypeVislib: 242982 diff --git a/src/plugins/vis_type_timelion/public/_generated_/chain.js b/src/plugins/vis_type_timelion/common/_generated_/chain.js similarity index 100% rename from src/plugins/vis_type_timelion/public/_generated_/chain.js rename to src/plugins/vis_type_timelion/common/_generated_/chain.js diff --git a/src/plugins/vis_type_timelion/common/parser.ts b/src/plugins/vis_type_timelion/common/parser.ts new file mode 100644 index 0000000000000..b6c16a6f7b4ed --- /dev/null +++ b/src/plugins/vis_type_timelion/common/parser.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +// @ts-ignore +import { parse } from './_generated_/chain'; + +export interface ExpressionLocation { + min: number; + max: number; +} + +interface ExpressionItem { + name: string; + function: string; + location: ExpressionLocation; + text: string; + type: string; +} + +export interface TimelionExpressionArgument extends ExpressionItem { + value: { + location: ExpressionLocation; + type: string; + value: string; + text: string; + }; +} + +export interface TimelionExpressionFunction extends ExpressionItem { + arguments: TimelionExpressionArgument[]; +} + +export interface TimelionExpressionChain { + chain: TimelionExpressionFunction[]; + type: 'chain'; +} + +export interface ParsedExpression { + args: TimelionExpressionArgument[]; + functions: TimelionExpressionFunction[]; + tree: TimelionExpressionChain[]; + variables: Record; +} + +export const parseTimelionExpression = (input: string): ParsedExpression => parse(input); diff --git a/src/plugins/vis_type_timelion/public/components/timelion_expression_input_helpers.ts b/src/plugins/vis_type_timelion/public/components/timelion_expression_input_helpers.ts index a428bc946364b..8f62abf7fe9be 100644 --- a/src/plugins/vis_type_timelion/public/components/timelion_expression_input_helpers.ts +++ b/src/plugins/vis_type_timelion/public/components/timelion_expression_input_helpers.ts @@ -6,16 +6,17 @@ * Side Public License, v 1. */ -import { get, startsWith } from 'lodash'; +import { startsWith } from 'lodash'; import { i18n } from '@kbn/i18n'; import { monaco } from '@kbn/monaco'; +import { + parseTimelionExpression, + ParsedExpression, + TimelionExpressionArgument, + ExpressionLocation, +} from '../../common/parser'; -import { Parser } from 'pegjs'; - -// @ts-ignore -import { parse } from '../_generated_/chain'; - -import { ArgValueSuggestions, FunctionArg, Location } from '../helpers/arg_value_suggestions'; +import { ArgValueSuggestions } from '../helpers/arg_value_suggestions'; import { ITimelionFunction, TimelionFunctionArgs } from '../../common/types'; export enum SUGGESTION_TYPE { @@ -24,13 +25,13 @@ export enum SUGGESTION_TYPE { FUNCTIONS = 'functions', } -function inLocation(cursorPosition: number, location: Location) { +function inLocation(cursorPosition: number, location: ExpressionLocation) { return cursorPosition >= location.min && cursorPosition <= location.max; } function getArgumentsHelp( functionHelp: ITimelionFunction | undefined, - functionArgs: FunctionArg[] = [] + functionArgs: TimelionExpressionArgument[] = [] ) { if (!functionHelp) { return []; @@ -45,14 +46,12 @@ function getArgumentsHelp( } async function extractSuggestionsFromParsedResult( - result: ReturnType, + result: ParsedExpression, cursorPosition: number, functionList: ITimelionFunction[], argValueSuggestions: ArgValueSuggestions ) { - const activeFunc = result.functions.find(({ location }: { location: Location }) => - inLocation(cursorPosition, location) - ); + const activeFunc = result.functions.find(({ location }) => inLocation(cursorPosition, location)); if (!activeFunc) { return; @@ -72,7 +71,7 @@ async function extractSuggestionsFromParsedResult( } // return argument value suggestions when cursor is inside argument value - const activeArg = activeFunc.arguments.find((argument: FunctionArg) => { + const activeArg = activeFunc.arguments.find((argument) => { return inLocation(cursorPosition, argument.location); }); if ( @@ -112,7 +111,7 @@ async function extractSuggestionsFromParsedResult( // return argument suggestions const argsHelp = getArgumentsHelp(functionHelp, activeFunc.arguments); const argumentSuggestions = argsHelp.filter((arg) => { - if (get(activeArg, 'type') === 'namedArg') { + if (activeArg?.type === 'namedArg') { return startsWith(arg.name, activeArg.name); } else if (activeArg) { return startsWith(arg.name, activeArg.text); @@ -129,7 +128,7 @@ export async function suggest( argValueSuggestions: ArgValueSuggestions ) { try { - const result = await parse(expression); + const result = parseTimelionExpression(expression); return await extractSuggestionsFromParsedResult( result, diff --git a/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts b/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts index 2fbf42f4be19b..0a989858706df 100644 --- a/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts +++ b/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts @@ -9,37 +9,19 @@ import { get } from 'lodash'; import { getIndexPatterns } from './plugin_services'; import { TimelionFunctionArgs } from '../../common/types'; +import { TimelionExpressionFunction, TimelionExpressionArgument } from '../../common/parser'; import { IndexPatternField, indexPatterns as indexPatternsUtils, KBN_FIELD_TYPES, } from '../../../data/public'; -export interface Location { - min: number; - max: number; -} - -export interface FunctionArg { - function: string; - location: Location; - name: string; - text: string; - type: string; - value: { - location: Location; - text: string; - type: string; - value: string; - }; -} - const isRuntimeField = (field: IndexPatternField) => Boolean(field.runtimeField); export function getArgValueSuggestions() { const indexPatterns = getIndexPatterns(); - async function getIndexPattern(functionArgs: FunctionArg[]) { + async function getIndexPattern(functionArgs: TimelionExpressionFunction[]) { const indexPatternArg = functionArgs.find(({ name }) => name === 'index'); if (!indexPatternArg) { // index argument not provided @@ -61,7 +43,7 @@ export function getArgValueSuggestions() { // Argument value suggestion handlers requiring custom client side code // Could not put with function definition since functions are defined on server - const customHandlers = { + const customHandlers: Record = { es: { async index(partial: string) { const search = partial ? `${partial}*` : '*'; @@ -71,7 +53,7 @@ export function getArgValueSuggestions() { name: title, })); }, - async metric(partial: string, functionArgs: FunctionArg[]) { + async metric(partial: string, functionArgs: TimelionExpressionFunction[]) { if (!partial || !partial.includes(':')) { return [ { name: 'avg:' }, @@ -101,7 +83,7 @@ export function getArgValueSuggestions() { ) .map((field) => ({ name: `${valueSplit[0]}:${field.name}`, help: field.type })); }, - async split(partial: string, functionArgs: FunctionArg[]) { + async split(partial: string, functionArgs: TimelionExpressionFunction[]) { const indexPattern = await getIndexPattern(functionArgs); if (!indexPattern) { return []; @@ -125,7 +107,7 @@ export function getArgValueSuggestions() { ) .map((field) => ({ name: field.name, help: field.type })); }, - async timefield(partial: string, functionArgs: FunctionArg[]) { + async timefield(partial: string, functionArgs: TimelionExpressionFunction[]) { const indexPattern = await getIndexPattern(functionArgs); if (!indexPattern) { return []; @@ -150,10 +132,7 @@ export function getArgValueSuggestions() { * @param {string} argName - user provided argument name * @return {boolean} true when dynamic suggestion handler provided for function argument */ - hasDynamicSuggestionsForArgument: ( - functionName: T, - argName: keyof typeof customHandlers[T] - ) => { + hasDynamicSuggestionsForArgument: (functionName: string, argName: string) => { return customHandlers[functionName] && customHandlers[functionName][argName]; }, @@ -164,10 +143,10 @@ export function getArgValueSuggestions() { * @param {string} partial - user provided argument value * @return {array} array of dynamic suggestions matching partial */ - getDynamicSuggestionsForArgument: async ( - functionName: T, - argName: keyof typeof customHandlers[T], - functionArgs: FunctionArg[], + getDynamicSuggestionsForArgument: async ( + functionName: string, + argName: string, + functionArgs: TimelionExpressionArgument[], partialInput = '' ) => { // @ts-ignore diff --git a/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx b/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx index b41bea96de302..2f6f3dd58f61f 100644 --- a/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx +++ b/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx @@ -13,8 +13,11 @@ import { DefaultEditorSize } from '../../vis_default_editor/public'; import { TimelionOptionsProps } from './timelion_options'; import { TimelionVisDependencies } from './plugin'; import { toExpressionAst } from './to_ast'; +import { getIndexPatterns } from './helpers/plugin_services'; -import { VIS_EVENT_TO_TRIGGER } from '../../visualizations/public'; +import { parseTimelionExpression } from '../common/parser'; + +import { VIS_EVENT_TO_TRIGGER, VisParams } from '../../visualizations/public'; const TimelionOptions = lazy(() => import('./timelion_options')); @@ -47,6 +50,21 @@ export function getTimelionVisDefinition(dependencies: TimelionVisDependencies) getSupportedTriggers: () => { return [VIS_EVENT_TO_TRIGGER.applyFilter]; }, + getUsedIndexPattern: (params: VisParams) => { + try { + const args = parseTimelionExpression(params.expression)?.args ?? []; + const indexArg = args.find( + ({ type, name, function: fn }) => type === 'namedArg' && fn === 'es' && name === 'index' + ); + + if (indexArg?.value.text) { + return getIndexPatterns().find(indexArg.value.text); + } + } catch { + // timelion expression is invalid + } + return []; + }, options: { showIndexSelection: false, showQueryBar: false, diff --git a/src/plugins/vis_type_timelion/server/handlers/lib/parse_sheet.js b/src/plugins/vis_type_timelion/server/handlers/lib/parse_sheet.js index 67bde9d7e6daa..d1965c422e509 100644 --- a/src/plugins/vis_type_timelion/server/handlers/lib/parse_sheet.js +++ b/src/plugins/vis_type_timelion/server/handlers/lib/parse_sheet.js @@ -7,17 +7,12 @@ */ import { i18n } from '@kbn/i18n'; -import fs from 'fs'; -import path from 'path'; -import _ from 'lodash'; -const grammar = fs.readFileSync(path.resolve(__dirname, '../../../common/chain.peg'), 'utf8'); -import PEG from 'pegjs'; -const Parser = PEG.generate(grammar); +import { parseTimelionExpression } from '../../../common/parser'; export default function parseSheet(sheet) { - return _.map(sheet, function (plot) { + return sheet.map(function (plot) { try { - return Parser.parse(plot).tree; + return parseTimelionExpression(plot).tree; } catch (e) { if (e.expected) { throw new Error( diff --git a/tasks/config/peg.js b/tasks/config/peg.js index 117af5909f23e..09da1ed81c222 100644 --- a/tasks/config/peg.js +++ b/tasks/config/peg.js @@ -15,7 +15,7 @@ module.exports = { }, }, timelion_chain: { - src: 'src/plugins/vis_type_timelion/public/chain.peg', - dest: 'src/plugins/vis_type_timelion/public/_generated_/chain.js', + src: 'src/plugins/vis_type_timelion/common/chain.peg', + dest: 'src/plugins/vis_type_timelion/common/_generated_/chain.js', }, }; From 25ce2d6fffa0e624e841d4ae7e42d005aba7aeeb Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Thu, 11 Feb 2021 14:36:23 +0100 Subject: [PATCH 09/53] [7.x] [Lens] Fix UI regression on toolbar buttons (#90953) (#91116) --- .../__snapshots__/toolbar_button.test.tsx.snap | 6 +++--- .../kibana_react/public/toolbar_button/toolbar_button.tsx | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/plugins/kibana_react/public/toolbar_button/__snapshots__/toolbar_button.test.tsx.snap b/src/plugins/kibana_react/public/toolbar_button/__snapshots__/toolbar_button.test.tsx.snap index 294be46398e8a..753dd8d6fe81f 100644 --- a/src/plugins/kibana_react/public/toolbar_button/__snapshots__/toolbar_button.test.tsx.snap +++ b/src/plugins/kibana_react/public/toolbar_button/__snapshots__/toolbar_button.test.tsx.snap @@ -68,7 +68,7 @@ exports[`hasArrow is rendered 1`] = ` exports[`positions center is applied 1`] = ` = ({ [`kbnToolbarButton--${fontWeight}`, `kbnToolbarButton--${size}`], className ); + return ( Date: Thu, 11 Feb 2021 16:27:28 +0200 Subject: [PATCH 10/53] [7.x] [Security Solution][Case] ServiceNow ITSM: Add category & subcategory fields (#90547) (#91109) --- x-pack/plugins/actions/README.md | 2 + .../servicenow/api.test.ts | 20 +++ .../builtin_action_types/servicenow/mocks.ts | 2 + .../builtin_action_types/servicenow/schema.ts | 6 +- .../case/common/api/connectors/jira.ts | 1 + .../case/common/api/connectors/resilient.ts | 1 + .../common/api/connectors/servicenow_itsm.ts | 3 + .../common/api/connectors/servicenow_sir.ts | 1 + .../case/server/connectors/case/index.test.ts | 24 ++- .../case/server/connectors/case/schema.ts | 2 + .../connectors/servicenow/itsm_formatter.ts | 4 +- .../servicenow/itsm_formmater.test.ts | 12 +- .../security_solution/cypress/objects/case.ts | 12 ++ .../cypress/tasks/create_new_case.ts | 2 +- .../cases/components/connectors/mock.ts | 12 ++ .../connectors/servicenow/helpers.ts | 12 ++ .../servicenow_itsm_case_fields.test.tsx | 67 +++++++- .../servicenow_itsm_case_fields.tsx | 122 ++++++++++++--- .../servicenow_sir_case_fields.test.tsx | 25 ++- .../servicenow/servicenow_sir_case_fields.tsx | 20 +-- .../components/connectors/servicenow/types.ts | 3 - .../components/create/connector.test.tsx | 8 +- .../components/create/form_context.test.tsx | 144 +++++++++++++++++- .../public/cases/components/create/mock.ts | 6 + .../public/cases/containers/configure/mock.ts | 9 ++ .../servicenow/helpers.ts | 12 ++ .../servicenow_itsm_params.test.tsx | 106 +++++++++---- .../servicenow/servicenow_itsm_params.tsx | 91 +++++++++-- .../servicenow/servicenow_sir_params.tsx | 16 +- .../builtin_action_types/servicenow/types.ts | 2 - .../uptime/public/state/api/alert_actions.ts | 2 + .../server/servicenow_simulation.ts | 2 + .../builtin_action_types/servicenow.ts | 2 + .../basic/tests/cases/push_case.ts | 32 +++- .../user_actions/get_all_user_actions.ts | 8 +- .../basic/tests/connectors/case.ts | 2 + 36 files changed, 666 insertions(+), 129 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/helpers.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 1d50bc7e05807..9d48e618b76dc 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -595,6 +595,8 @@ The following table describes the properties of the `incident` object. | severity | The name of the severity in ServiceNow. | string _(optional)_ | | urgency | The name of the urgency in ServiceNow. | string _(optional)_ | | impact | The name of the impact in ServiceNow. | string _(optional)_ | +| category | The name of the category in ServiceNow. | string _(optional)_ | +| subcategory | The name of the subcategory in ServiceNow. | string _(optional)_ | #### `subActionParams (getFields)` diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts index 662b1ce46a07b..8d24e48d4d515 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts @@ -88,6 +88,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', caller_id: 'elastic', description: 'Incident description', short_description: 'Incident title', @@ -111,6 +113,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', comments: 'A comment', description: 'Incident description', short_description: 'Incident title', @@ -123,6 +127,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', comments: 'Another comment', description: 'Incident description', short_description: 'Incident title', @@ -146,6 +152,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', work_notes: 'A comment', description: 'Incident description', short_description: 'Incident title', @@ -158,6 +166,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', work_notes: 'Another comment', description: 'Incident description', short_description: 'Incident title', @@ -229,6 +239,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', description: 'Incident description', short_description: 'Incident title', }, @@ -251,6 +263,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', description: 'Incident description', short_description: 'Incident title', }, @@ -262,6 +276,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', comments: 'A comment', description: 'Incident description', short_description: 'Incident title', @@ -285,6 +301,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', description: 'Incident description', short_description: 'Incident title', }, @@ -296,6 +314,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', work_notes: 'A comment', description: 'Incident description', short_description: 'Incident title', diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts index 8a689bffb3408..909200472be33 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts @@ -112,6 +112,8 @@ const executorParams: ExecutorSubActionPushParams = { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', }, comments: [ { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index b89d53ee2c66e..59b0803d189cd 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -45,6 +45,8 @@ const CommonAttributes = { short_description: schema.string(), description: schema.nullable(schema.string()), externalId: schema.nullable(schema.string()), + category: schema.nullable(schema.string()), + subcategory: schema.nullable(schema.string()), }; // Schema for ServiceNow Incident Management (ITSM) @@ -62,13 +64,11 @@ export const ExecutorSubActionPushParamsSchemaITSM = schema.object({ export const ExecutorSubActionPushParamsSchemaSIR = schema.object({ incident: schema.object({ ...CommonAttributes, - category: schema.nullable(schema.string()), dest_ip: schema.nullable(schema.string()), malware_hash: schema.nullable(schema.string()), malware_url: schema.nullable(schema.string()), - priority: schema.nullable(schema.string()), source_ip: schema.nullable(schema.string()), - subcategory: schema.nullable(schema.string()), + priority: schema.nullable(schema.string()), }), comments: CommentsSchema, }); diff --git a/x-pack/plugins/case/common/api/connectors/jira.ts b/x-pack/plugins/case/common/api/connectors/jira.ts index 15a6768b07561..d61f4ba91575e 100644 --- a/x-pack/plugins/case/common/api/connectors/jira.ts +++ b/x-pack/plugins/case/common/api/connectors/jira.ts @@ -7,6 +7,7 @@ import * as rt from 'io-ts'; +// New fields should also be added at: x-pack/plugins/case/server/connectors/case/schema.ts export const JiraFieldsRT = rt.type({ issueType: rt.union([rt.string, rt.null]), priority: rt.union([rt.string, rt.null]), diff --git a/x-pack/plugins/case/common/api/connectors/resilient.ts b/x-pack/plugins/case/common/api/connectors/resilient.ts index d19aa5b21fb52..dc59588d1e6ed 100644 --- a/x-pack/plugins/case/common/api/connectors/resilient.ts +++ b/x-pack/plugins/case/common/api/connectors/resilient.ts @@ -7,6 +7,7 @@ import * as rt from 'io-ts'; +// New fields should also be added at: x-pack/plugins/case/server/connectors/case/schema.ts export const ResilientFieldsRT = rt.type({ incidentTypes: rt.union([rt.array(rt.string), rt.null]), severityCode: rt.union([rt.string, rt.null]), diff --git a/x-pack/plugins/case/common/api/connectors/servicenow_itsm.ts b/x-pack/plugins/case/common/api/connectors/servicenow_itsm.ts index 2e86a26971aaa..9eedbcb44907a 100644 --- a/x-pack/plugins/case/common/api/connectors/servicenow_itsm.ts +++ b/x-pack/plugins/case/common/api/connectors/servicenow_itsm.ts @@ -7,10 +7,13 @@ import * as rt from 'io-ts'; +// New fields should also be added at: x-pack/plugins/case/server/connectors/case/schema.ts export const ServiceNowITSMFieldsRT = rt.type({ impact: rt.union([rt.string, rt.null]), severity: rt.union([rt.string, rt.null]), urgency: rt.union([rt.string, rt.null]), + category: rt.union([rt.string, rt.null]), + subcategory: rt.union([rt.string, rt.null]), }); export type ServiceNowITSMFieldsType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/connectors/servicenow_sir.ts b/x-pack/plugins/case/common/api/connectors/servicenow_sir.ts index 749abdea87437..b8d33f259ade7 100644 --- a/x-pack/plugins/case/common/api/connectors/servicenow_sir.ts +++ b/x-pack/plugins/case/common/api/connectors/servicenow_sir.ts @@ -7,6 +7,7 @@ import * as rt from 'io-ts'; +// New fields should also be added at: x-pack/plugins/case/server/connectors/case/schema.ts export const ServiceNowSIRFieldsRT = rt.type({ category: rt.union([rt.string, rt.null]), destIp: rt.union([rt.boolean, rt.null]), diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts index 236927967d0c5..4a025fd980fe2 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -153,6 +153,8 @@ describe('case connector', () => { impact: 'Medium', severity: 'Medium', urgency: 'Medium', + category: 'software', + subcategory: 'os', }, }, settings: { @@ -218,7 +220,13 @@ describe('case connector', () => { id: 'servicenow', name: 'Servicenow', type: '.servicenow', - fields: { impact: null, severity: null, urgency: null }, + fields: { + impact: null, + severity: null, + urgency: null, + category: null, + subcategory: null, + }, }, settings: { syncAlerts: true, @@ -293,6 +301,8 @@ describe('case connector', () => { impact: 'Medium', severity: 'Medium', urgency: 'Medium', + category: 'software', + subcategory: 'os', excess: null, }, }, @@ -470,6 +480,8 @@ describe('case connector', () => { impact: 'Medium', severity: 'Medium', urgency: 'Medium', + category: 'software', + subcategory: 'os', }, }, }, @@ -517,7 +529,13 @@ describe('case connector', () => { id: 'servicenow', name: 'Servicenow', type: '.servicenow', - fields: { impact: null, severity: null, urgency: null }, + fields: { + impact: null, + severity: null, + urgency: null, + category: null, + subcategory: null, + }, }, }, }); @@ -590,6 +608,8 @@ describe('case connector', () => { impact: 'Medium', severity: 'Medium', urgency: 'Medium', + category: 'software', + subcategory: 'os', excess: null, }, }, diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index ba82190367b12..8d52a344308e1 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -53,6 +53,8 @@ const ServiceNowFieldsSchema = schema.object({ impact: schema.nullable(schema.string()), severity: schema.nullable(schema.string()), urgency: schema.nullable(schema.string()), + category: schema.nullable(schema.string()), + subcategory: schema.nullable(schema.string()), }); const NoneFieldsSchema = schema.nullable(schema.object({})); diff --git a/x-pack/plugins/case/server/connectors/servicenow/itsm_formatter.ts b/x-pack/plugins/case/server/connectors/servicenow/itsm_formatter.ts index 60faa82a9e3fa..b49eed6a4ad26 100644 --- a/x-pack/plugins/case/server/connectors/servicenow/itsm_formatter.ts +++ b/x-pack/plugins/case/server/connectors/servicenow/itsm_formatter.ts @@ -9,9 +9,9 @@ import { ServiceNowITSMFieldsType, ConnectorServiceNowITSMTypeFields } from '../ import { ExternalServiceFormatter } from '../types'; const format: ExternalServiceFormatter['format'] = (theCase) => { - const { severity = null, urgency = null, impact = null } = + const { severity = null, urgency = null, impact = null, category = null, subcategory = null } = (theCase.connector.fields as ConnectorServiceNowITSMTypeFields['fields']) ?? {}; - return { severity, urgency, impact }; + return { severity, urgency, impact, category, subcategory }; }; export const serviceNowITSMExternalServiceFormatter: ExternalServiceFormatter = { diff --git a/x-pack/plugins/case/server/connectors/servicenow/itsm_formmater.test.ts b/x-pack/plugins/case/server/connectors/servicenow/itsm_formmater.test.ts index 033f184c7e751..ea3a4e41e17b8 100644 --- a/x-pack/plugins/case/server/connectors/servicenow/itsm_formmater.test.ts +++ b/x-pack/plugins/case/server/connectors/servicenow/itsm_formmater.test.ts @@ -10,7 +10,9 @@ import { serviceNowITSMExternalServiceFormatter } from './itsm_formatter'; describe('ITSM formatter', () => { const theCase = { - connector: { fields: { severity: '2', urgency: '2', impact: '2' } }, + connector: { + fields: { severity: '2', urgency: '2', impact: '2', category: 'software', subcategory: 'os' }, + }, } as CaseResponse; it('it formats correctly', async () => { @@ -21,6 +23,12 @@ describe('ITSM formatter', () => { it('it formats correctly when fields do not exist ', async () => { const invalidFields = { connector: { fields: null } } as CaseResponse; const res = await serviceNowITSMExternalServiceFormatter.format(invalidFields, []); - expect(res).toEqual({ severity: null, urgency: null, impact: null }); + expect(res).toEqual({ + severity: null, + urgency: null, + impact: null, + category: null, + subcategory: null, + }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/objects/case.ts b/x-pack/plugins/security_solution/cypress/objects/case.ts index 7a3ce2cb00dfa..a0135431c6543 100644 --- a/x-pack/plugins/security_solution/cypress/objects/case.ts +++ b/x-pack/plugins/security_solution/cypress/objects/case.ts @@ -153,6 +153,18 @@ export const executeResponses = { value: 'inbound_ddos', element: 'subcategory', }, + { + dependent_value: '', + label: 'Software', + value: 'software', + element: 'category', + }, + { + dependent_value: 'software', + label: 'Operation System', + value: 'os', + element: 'subcategory', + }, ...['severity', 'urgency', 'impact', 'priority'] .map((element) => [ { diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts index 15dddd48f0aca..e67cee4f38734 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts @@ -92,6 +92,6 @@ export const fillIbmResilientConnectorOptions = ( ibmResilientConnector.incidentTypes.forEach((incidentType) => { cy.get(SELECT_INCIDENT_TYPE).type(`${incidentType}{enter}`, { force: true }); }); - cy.get(CONNECTOR_RESILIENT).click(); + cy.get(CONNECTOR_RESILIENT).click({ force: true }); cy.get(SELECT_SEVERITY).select(ibmResilientConnector.severity); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts index 04e7338025258..f5429fa2396aa 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts @@ -58,6 +58,18 @@ export const choices = [ value: 'inbound_ddos', element: 'subcategory', }, + { + dependent_value: '', + label: 'Software', + value: 'software', + element: 'category', + }, + { + dependent_value: 'software', + label: 'Operation System', + value: 'os', + element: 'subcategory', + }, ...['severity', 'urgency', 'impact', 'priority'] .map((element) => [ { diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/helpers.ts new file mode 100644 index 0000000000000..314d224491128 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/helpers.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 { EuiSelectOption } from '@elastic/eui'; +import { Choice } from './types'; + +export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => + choices.map((choice) => ({ value: choice.value, text: choice.label })); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx index 555ed0dcbb161..6e2bdec360fdf 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx @@ -20,12 +20,18 @@ jest.mock('../../../../common/lib/kibana'); jest.mock('./use_get_choices', () => ({ useGetChoices: (args: { onSuccess: () => void }) => { onChoicesSuccess = args.onSuccess; - return { isLoading: false, mockChoices }; + return { isLoading: false, choices: mockChoices }; }, })); describe('ServiceNowITSM Fields', () => { - const fields = { severity: '1', urgency: '2', impact: '3' }; + const fields = { + severity: '1', + urgency: '2', + impact: '3', + category: 'software', + subcategory: 'os', + }; const onChange = jest.fn(); beforeEach(() => { @@ -37,6 +43,8 @@ describe('ServiceNowITSM Fields', () => { expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="urgencySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="impactSelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy(); }); it('all params fields are rendered - isEdit: false', () => { @@ -58,6 +66,42 @@ describe('ServiceNowITSM Fields', () => { ); }); + test('it transforms the categories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="categorySelect"]').first().prop('options')).toEqual([ + { value: 'Priviledge Escalation', text: 'Priviledge Escalation' }, + { + value: 'Criminal activity/investigation', + text: 'Criminal activity/investigation', + }, + { value: 'Denial of Service', text: 'Denial of Service' }, + { + value: 'software', + text: 'Software', + }, + ]); + }); + + test('it transforms the subcategories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').first().prop('options')).toEqual([ + { + text: 'Operation System', + value: 'os', + }, + ]); + }); + it('it transforms the options correctly', async () => { const wrapper = mount(); act(() => { @@ -81,7 +125,7 @@ describe('ServiceNowITSM Fields', () => { expect(onChange).toHaveBeenCalledWith(fields); - const testers = ['severity', 'urgency', 'impact']; + const testers = ['severity', 'urgency', 'impact', 'subcategory']; testers.forEach((subj) => test(`${subj.toUpperCase()}`, async () => { await waitFor(() => { @@ -99,5 +143,22 @@ describe('ServiceNowITSM Fields', () => { }); }) ); + + test('it should set subcategory to null when changing category', async () => { + await waitFor(() => { + const select = wrapper.find(EuiSelect).filter(`[data-test-subj="categorySelect"]`)!; + select.prop('onChange')!({ + target: { + value: 'network', + }, + } as React.ChangeEvent); + }); + wrapper.update(); + expect(onChange).toHaveBeenCalledWith({ + ...fields, + subcategory: null, + category: 'network', + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index e278492b57148..1fe592cfdebc4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -17,22 +17,39 @@ import { import { useKibana } from '../../../../common/lib/kibana'; import { ConnectorCard } from '../card'; import { useGetChoices } from './use_get_choices'; -import { Options, Choice } from './types'; +import { Fields, Choice } from './types'; +import { choicesToEuiOptions } from './helpers'; -const useGetChoicesFields = ['urgency', 'severity', 'impact']; -const defaultOptions: Options = { +const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; +const defaultFields: Fields = { urgency: [], severity: [], impact: [], + category: [], + subcategory: [], }; const ServiceNowITSMFieldsComponent: React.FunctionComponent< ConnectorFieldsProps > = ({ isEdit = true, fields, connector, onChange }) => { const init = useRef(true); - const { severity = null, urgency = null, impact = null } = fields ?? {}; + const { severity = null, urgency = null, impact = null, category = null, subcategory = null } = + fields ?? {}; const { http, notifications } = useKibana().services; - const [options, setOptions] = useState(defaultOptions); + const [choices, setChoices] = useState(defaultFields); + + const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); + const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]); + const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]); + const impactOptions = useMemo(() => choicesToEuiOptions(choices.impact), [choices.impact]); + + const subcategoryOptions = useMemo( + () => + choicesToEuiOptions( + choices.subcategory.filter((choice) => choice.dependent_value === category) + ), + [choices.subcategory, category] + ); const listItems = useMemo( () => [ @@ -40,7 +57,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< ? [ { title: i18n.URGENCY, - description: options.urgency.find((option) => `${option.value}` === urgency)?.text, + description: urgencyOptions.find((option) => `${option.value}` === urgency)?.text, }, ] : []), @@ -48,7 +65,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< ? [ { title: i18n.SEVERITY, - description: options.severity.find((option) => `${option.value}` === severity)?.text, + description: severityOptions.find((option) => `${option.value}` === severity)?.text, }, ] : []), @@ -56,27 +73,53 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< ? [ { title: i18n.IMPACT, - description: options.impact.find((option) => `${option.value}` === impact)?.text, + description: impactOptions.find((option) => `${option.value}` === impact)?.text, + }, + ] + : []), + ...(category != null && category.length > 0 + ? [ + { + title: i18n.CATEGORY, + description: categoryOptions.find((option) => `${option.value}` === category)?.text, + }, + ] + : []), + ...(subcategory != null && subcategory.length > 0 + ? [ + { + title: i18n.SUBCATEGORY, + description: subcategoryOptions.find((option) => `${option.value}` === subcategory) + ?.text, }, ] : []), ], - [urgency, options.urgency, options.severity, options.impact, severity, impact] + [ + category, + categoryOptions, + impact, + impactOptions, + severity, + severityOptions, + subcategory, + subcategoryOptions, + urgency, + urgencyOptions, + ] ); - const onChoicesSuccess = (choices: Choice[]) => - setOptions( - choices.reduce( - (acc, choice) => ({ + const onChoicesSuccess = (values: Choice[]) => { + setChoices( + values.reduce( + (acc, value) => ({ ...acc, - [choice.element]: [ - ...(acc[choice.element] != null ? acc[choice.element] : []), - { value: choice.value, text: choice.label }, - ], + [value.element]: [...(acc[value.element] != null ? acc[value.element] : []), value], }), - defaultOptions + defaultFields ) ); + }; const { isLoading: isLoadingChoices } = useGetChoices({ http, @@ -100,17 +143,17 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< useEffect(() => { if (init.current) { init.current = false; - onChange({ urgency, severity, impact }); + onChange({ urgency, severity, impact, category, subcategory }); } - }, [impact, onChange, severity, urgency]); + }, [category, impact, onChange, severity, subcategory, urgency]); return isEdit ? ( -
+
+ + + + onChange({ ...fields, category: e.target.value, subcategory: null })} + /> + + + + + onChangeCb('subcategory', e.target.value)} + /> + + +
) : ( { text: 'Criminal activity/investigation', }, { value: 'Denial of Service', text: 'Denial of Service' }, + { + text: 'Software', + value: 'software', + }, ]); }); @@ -176,7 +180,7 @@ describe('ServiceNowSIR Fields', () => { }) ); - const testers = ['priority', 'category', 'subcategory']; + const testers = ['priority', 'subcategory']; testers.forEach((subj) => test(`${subj.toUpperCase()}`, async () => { await waitFor(() => { @@ -194,5 +198,24 @@ describe('ServiceNowSIR Fields', () => { }); }) ); + + test('it should set subcategory to null when changing category', async () => { + const select = wrapper.find(EuiSelect).filter(`[data-test-subj="categorySelect"]`)!; + select.prop('onChange')!({ + target: { + value: 'network', + }, + } as React.ChangeEvent); + + wrapper.update(); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith({ + ...fields, + subcategory: null, + category: 'network', + }); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx index 96db43fe261ac..68cb4f867b334 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -6,14 +6,7 @@ */ import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; -import { - EuiFormRow, - EuiSelect, - EuiFlexGroup, - EuiFlexItem, - EuiSelectOption, - EuiCheckbox, -} from '@elastic/eui'; +import { EuiFormRow, EuiSelect, EuiFlexGroup, EuiFlexItem, EuiCheckbox } from '@elastic/eui'; import { ConnectorTypes, @@ -24,6 +17,7 @@ import { ConnectorFieldsProps } from '../types'; import { ConnectorCard } from '../card'; import { useGetChoices } from './use_get_choices'; import { Choice, Fields } from './types'; +import { choicesToEuiOptions } from './helpers'; import * as i18n from './translations'; @@ -34,9 +28,6 @@ const defaultFields: Fields = { priority: [], }; -const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => - choices.map((choice) => ({ value: choice.value, text: choice.label })); - const ServiceNowSIRFieldsComponent: React.FunctionComponent< ConnectorFieldsProps > = ({ isEdit = true, fields, connector, onChange }) => { @@ -179,7 +170,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< }, [category, destIp, malwareHash, malwareUrl, onChange, priority, sourceIp, subcategory]); return isEdit ? ( -
+
@@ -259,7 +250,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< isLoading={isLoadingChoices} disabled={isLoadingChoices} hasNoInitialSelection - onChange={(e) => onChangeCb('category', e.target.value)} + onChange={(e) => onChange({ ...fields, category: e.target.value, subcategory: null })} /> @@ -269,7 +260,8 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< fullWidth data-test-subj="subcategorySelect" options={subcategoryOptions} - value={subcategory ?? undefined} + // Needs an empty string instead of undefined to select the blank option when changing categories + value={subcategory ?? ''} isLoading={isLoadingChoices} disabled={isLoadingChoices} hasNoInitialSelection diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts index deceeed29482b..fd1af62f7bb2a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { EuiSelectOption } from '@elastic/eui'; - export interface Choice { value: string; label: string; @@ -15,4 +13,3 @@ export interface Choice { } export type Fields = Record; -export type Options = Record; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx index 236c13e5afc08..9c5a4a0784af1 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx @@ -96,10 +96,10 @@ describe('Connector', () => { ); }); - // await waitFor(() => { - // wrapper.update(); - // expect(wrapper.find(`[data-test-subj="connector-fields-sn"]`).exists()).toBeTruthy(); - // }); + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeTruthy(); + }); }); it('it is loading when fetching connectors', async () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx index 87658a78ac6f7..8236ab7b19d27 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx @@ -20,6 +20,7 @@ import { connectorsMock } from '../../containers/configure/mock'; import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; import { useGetSeverity } from '../connectors/resilient/use_get_severity'; import { useGetIssueTypes } from '../connectors/jira/use_get_issue_types'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { useGetFieldsByIssueType } from '../connectors/jira/use_get_fields_by_issue_type'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; import { @@ -30,6 +31,7 @@ import { useGetSeverityResponse, useGetIssueTypesResponse, useGetFieldsByIssueTypeResponse, + useGetChoicesResponse, } from './mock'; import { FormContext } from './form_context'; import { CreateCaseForm } from './form'; @@ -49,6 +51,7 @@ jest.mock('../connectors/jira/use_get_issue_types'); jest.mock('../connectors/jira/use_get_fields_by_issue_type'); jest.mock('../connectors/jira/use_get_single_issue'); jest.mock('../connectors/jira/use_get_issues'); +jest.mock('../connectors/servicenow/use_get_choices'); const useConnectorsMock = useConnectors as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; @@ -58,6 +61,7 @@ const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; +const useGetChoicesMock = useGetChoices as jest.Mock; const postCase = jest.fn(); const pushCaseToExternalService = jest.fn(); @@ -109,6 +113,7 @@ describe('Create case', () => { useGetSeverityMock.mockReturnValue(useGetSeverityResponse); useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse); + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); (useGetTags as jest.Mock).mockImplementation(() => ({ tags: sampleTags, @@ -219,6 +224,8 @@ describe('Create case', () => { impact: null, severity: null, urgency: null, + category: null, + subcategory: null, }, id: 'servicenow-1', name: 'My Connector', @@ -399,7 +406,7 @@ describe('Create case', () => { }); }); - it(`it should submit and push to servicenow connector`, async () => { + it(`it should submit and push to servicenow itsm connector`, async () => { useConnectorsMock.mockReturnValue({ ...sampleConnectorData, connectors: connectorsMock, @@ -415,10 +422,14 @@ describe('Create case', () => { ); fillForm(wrapper); - expect(wrapper.find(`[data-test-subj="connector-fields-sn"]`).exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeFalsy(); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.find(`button[data-test-subj="dropdown-connector-servicenow-1"]`).simulate('click'); - expect(wrapper.find(`[data-test-subj="connector-fields-sn"]`).exists()).toBeTruthy(); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeTruthy(); + }); ['severitySelect', 'urgencySelect', 'impactSelect'].forEach((subj) => { wrapper @@ -429,6 +440,20 @@ describe('Create case', () => { }); }); + wrapper + .find('select[data-test-subj="categorySelect"]') + .first() + .simulate('change', { + target: { value: 'software' }, + }); + + wrapper + .find('select[data-test-subj="subcategorySelect"]') + .first() + .simulate('change', { + target: { value: 'os' }, + }); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); await waitFor(() => { @@ -438,7 +463,13 @@ describe('Create case', () => { id: 'servicenow-1', name: 'My Connector', type: '.servicenow', - fields: { impact: '2', severity: '2', urgency: '2' }, + fields: { + impact: '2', + severity: '2', + urgency: '2', + category: 'software', + subcategory: 'os', + }, }, }); @@ -448,7 +479,110 @@ describe('Create case', () => { id: 'servicenow-1', name: 'My Connector', type: '.servicenow', - fields: { impact: '2', severity: '2', urgency: '2' }, + fields: { + impact: '2', + severity: '2', + urgency: '2', + category: 'software', + subcategory: 'os', + }, + }, + }); + + expect(onFormSubmitSuccess).toHaveBeenCalledWith({ + id: sampleId, + ...sampleData, + }); + }); + }); + + it(`it should submit and push to servicenow sir connector`, async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + + + + + + + ); + + fillForm(wrapper); + expect(wrapper.find(`[data-test-subj="connector-fields-sn-sir"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-servicenow-sir"]`).simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-sn-sir"]`).exists()).toBeTruthy(); + }); + + wrapper + .find('[data-test-subj="destIpCheckbox"] input') + .first() + .simulate('change', { target: { checked: false } }); + + wrapper + .find('select[data-test-subj="prioritySelect"]') + .first() + .simulate('change', { + target: { value: '1' }, + }); + + wrapper + .find('select[data-test-subj="categorySelect"]') + .first() + .simulate('change', { + target: { value: 'Denial of Service' }, + }); + + wrapper + .find('select[data-test-subj="subcategorySelect"]') + .first() + .simulate('change', { + target: { value: '26' }, + }); + + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + + await waitFor(() => { + expect(postCase).toBeCalledWith({ + ...sampleData, + connector: { + id: 'servicenow-sir', + name: 'My Connector SIR', + type: '.servicenow-sir', + fields: { + destIp: false, + sourceIp: true, + malwareHash: true, + malwareUrl: true, + priority: '1', + category: 'Denial of Service', + subcategory: '26', + }, + }, + }); + + expect(pushCaseToExternalService).toHaveBeenCalledWith({ + caseId: sampleId, + connector: { + id: 'servicenow-sir', + name: 'My Connector SIR', + type: '.servicenow-sir', + fields: { + destIp: false, + sourceIp: true, + malwareHash: true, + malwareUrl: true, + priority: '1', + category: 'Denial of Service', + subcategory: '26', + }, }, }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/mock.ts b/x-pack/plugins/security_solution/public/cases/components/create/mock.ts index 5044b859702fa..909b49940e189 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/components/create/mock.ts @@ -7,6 +7,7 @@ import { CasePostRequest } from '../../../../../case/common/api'; import { ConnectorTypes } from '../../../../../case/common/api/connectors'; +import { choices } from '../connectors/mock'; export const sampleTags = ['coke', 'pepsi']; export const sampleData: CasePostRequest = { @@ -93,3 +94,8 @@ export const useGetFieldsByIssueTypeResponse = { }, }, }; + +export const useGetChoicesResponse = { + isLoading: false, + choices, +}; diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts index 283e55f3759c7..c4ae60c7d1a73 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts @@ -61,6 +61,15 @@ export const connectorsMock: ActionConnector[] = [ }, isPreconfigured: false, }, + { + id: 'servicenow-sir', + actionTypeId: '.servicenow-sir', + name: 'My Connector SIR', + config: { + apiUrl: 'https://instance1.service-now.com', + }, + isPreconfigured: false, + }, ]; export const actionTypesMock: ActionTypeConnector[] = [ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts new file mode 100644 index 0000000000000..314d224491128 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.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 { EuiSelectOption } from '@elastic/eui'; +import { Choice } from './types'; + +export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => + choices.map((choice) => ({ value: choice.value, text: choice.label })); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx index bfc32ef67e46f..e864a8d3fd114 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx @@ -28,6 +28,8 @@ const actionParams = { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', externalId: null, }, comments: [], @@ -55,34 +57,48 @@ const defaultProps = { const useGetChoicesResponse = { isLoading: false, - choices: ['severity', 'urgency', 'impact'] - .map((element) => [ - { - dependent_value: '', - label: '1 - Critical', - value: '1', - element, - }, - { - dependent_value: '', - label: '2 - High', - value: '2', - element, - }, - { - dependent_value: '', - label: '3 - Moderate', - value: '3', - element, - }, - { - dependent_value: '', - label: '4 - Low', - value: '4', - element, - }, - ]) - .flat(), + choices: [ + { + dependent_value: '', + label: 'Software', + value: 'software', + element: 'category', + }, + { + dependent_value: 'software', + label: 'Operation System', + value: 'os', + element: 'subcategory', + }, + ...['severity', 'urgency', 'impact'] + .map((element) => [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + element, + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + element, + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + element, + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + element, + }, + ]) + .flat(), + ], }; describe('ServiceNowITSMParamsFields renders', () => { @@ -101,6 +117,8 @@ describe('ServiceNowITSMParamsFields renders', () => { expect(wrapper.find('[data-test-subj="urgencySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="impactSelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="short_descriptionInput"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="commentsTextArea"]').exists()).toBeTruthy(); @@ -153,6 +171,36 @@ describe('ServiceNowITSMParamsFields renders', () => { }); }); + test('it transforms the categories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoices(useGetChoicesResponse.choices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="categorySelect"]').first().prop('options')).toEqual([ + { + value: 'software', + text: 'Software', + }, + ]); + }); + + test('it transforms the subcategories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoices(useGetChoicesResponse.choices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').first().prop('options')).toEqual([ + { + text: 'Operation System', + value: 'os', + }, + ]); + }); + test('it transforms the options correctly', async () => { const wrapper = mount(); act(() => { @@ -179,6 +227,8 @@ describe('ServiceNowITSMParamsFields renders', () => { { dataTestSubj: '[data-test-subj="urgencySelect"]', key: 'urgency' }, { dataTestSubj: '[data-test-subj="severitySelect"]', key: 'severity' }, { dataTestSubj: '[data-test-subj="impactSelect"]', key: 'impact' }, + { dataTestSubj: '[data-test-subj="categorySelect"]', key: 'category' }, + { dataTestSubj: '[data-test-subj="subcategorySelect"]', key: 'subcategory' }, ]; simpleFields.forEach((field) => diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx index 3befa232e5b52..84326a7ae9be8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx @@ -16,17 +16,22 @@ import { } from '@elastic/eui'; import { useKibana } from '../../../../common/lib/kibana'; import { ActionParamsProps } from '../../../../types'; -import { ServiceNowITSMActionParams, Choice, Options } from './types'; +import { ServiceNowITSMActionParams, Choice, Fields } from './types'; import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; import { useGetChoices } from './use_get_choices'; +import { choicesToEuiOptions } from './helpers'; + import * as i18n from './translations'; -const useGetChoicesFields = ['urgency', 'severity', 'impact']; -const defaultOptions: Options = { +const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; +const defaultFields: Fields = { + category: [], + subcategory: [], urgency: [], severity: [], impact: [], + priority: [], }; const ServiceNowParamsFields: React.FunctionComponent< @@ -48,7 +53,7 @@ const ServiceNowParamsFields: React.FunctionComponent< [actionParams.subActionParams] ); - const [options, setOptions] = useState(defaultOptions); + const [choices, setChoices] = useState(defaultFields); const editSubActionProperty = useCallback( (key: string, value: any) => { @@ -73,19 +78,32 @@ const ServiceNowParamsFields: React.FunctionComponent< [editSubActionProperty] ); - const onChoicesSuccess = (choices: Choice[]) => - setOptions( - choices.reduce( - (acc, choice) => ({ + const onChoicesSuccess = useCallback((values: Choice[]) => { + setChoices( + values.reduce( + (acc, value) => ({ ...acc, - [choice.element]: [ - ...(acc[choice.element] != null ? acc[choice.element] : []), - { value: choice.value, text: choice.label }, - ], + [value.element]: [...(acc[value.element] != null ? acc[value.element] : []), value], }), - defaultOptions + defaultFields ) ); + }, []); + + const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); + const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]); + const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]); + const impactOptions = useMemo(() => choicesToEuiOptions(choices.impact), [choices.impact]); + + const subcategoryOptions = useMemo( + () => + choicesToEuiOptions( + choices.subcategory.filter( + (subcategory) => subcategory.dependent_value === incident.category + ) + ), + [choices.subcategory, incident.category] + ); const { isLoading: isLoadingChoices } = useGetChoices({ http, @@ -140,7 +158,7 @@ const ServiceNowParamsFields: React.FunctionComponent< hasNoInitialSelection isLoading={isLoadingChoices} disabled={isLoadingChoices} - options={options.urgency} + options={urgencyOptions} value={incident.urgency ?? ''} onChange={(e) => editSubActionProperty('urgency', e.target.value)} /> @@ -155,7 +173,7 @@ const ServiceNowParamsFields: React.FunctionComponent< hasNoInitialSelection isLoading={isLoadingChoices} disabled={isLoadingChoices} - options={options.severity} + options={severityOptions} value={incident.severity ?? ''} onChange={(e) => editSubActionProperty('severity', e.target.value)} /> @@ -169,7 +187,7 @@ const ServiceNowParamsFields: React.FunctionComponent< hasNoInitialSelection isLoading={isLoadingChoices} disabled={isLoadingChoices} - options={options.impact} + options={impactOptions} value={incident.impact ?? ''} onChange={(e) => editSubActionProperty('impact', e.target.value)} /> @@ -177,6 +195,47 @@ const ServiceNowParamsFields: React.FunctionComponent< + + + + { + editAction( + 'subActionParams', + { + incident: { ...incident, category: e.target.value, subcategory: null }, + comments, + }, + index + ); + }} + /> + + + + + editSubActionProperty('subcategory', e.target.value)} + /> + + + + - choices.map((choice) => ({ value: choice.value, text: choice.label })); - const ServiceNowSIRParamsFields: React.FunctionComponent< ActionParamsProps > = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => { @@ -218,16 +215,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< disabled={isLoadingChoices} options={priorityOptions} value={incident.priority ?? undefined} - onChange={(e) => { - editAction( - 'subActionParams', - { - incident: { ...incident, priority: e.target.value }, - comments, - }, - index - ); - }} + onChange={(e) => editSubActionProperty('priority', e.target.value)} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts index 09f27c92e8082..f252f4648e670 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { EuiSelectOption } from '@elastic/eui'; import { UserConfiguredActionConnector } from '../../../../types'; import { ExecutorSubActionPushParamsITSM, @@ -45,4 +44,3 @@ export interface Choice { } export type Fields = Record; -export type Options = Record; diff --git a/x-pack/plugins/uptime/public/state/api/alert_actions.ts b/x-pack/plugins/uptime/public/state/api/alert_actions.ts index 3fce0499c4501..17b3354b666c4 100644 --- a/x-pack/plugins/uptime/public/state/api/alert_actions.ts +++ b/x-pack/plugins/uptime/public/state/api/alert_actions.ts @@ -119,6 +119,8 @@ function getServiceNowActionParams(): ServiceNowActionParams { impact: '2', severity: '2', urgency: '2', + category: null, + subcategory: null, externalId: null, }, comments: [], diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts index e2cbd3628d5fa..8b8eb46989787 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts @@ -30,6 +30,8 @@ export function initPlugin(router: IRouter, path: string) { severity: schema.string({ defaultValue: '1' }), urgency: schema.string({ defaultValue: '1' }), impact: schema.string({ defaultValue: '1' }), + category: schema.maybe(schema.string()), + subcategory: schema.maybe(schema.string()), }), }, }, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index 2d49c409a18fc..2d584f764e5e4 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -40,6 +40,8 @@ export default function servicenowTest({ getService }: FtrProviderContext) { severity: '1', short_description: 'a title', urgency: '1', + category: 'software', + subcategory: 'software', }, comments: [ { diff --git a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts index ef7c57b3b4749..735c079c7b850 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts @@ -80,7 +80,13 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: '2', impact: '2', severity: '2' }, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, }).connector, }) .expect(200); @@ -143,7 +149,13 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: '2', impact: '2', severity: '2' }, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, }).connector, }) .expect(200); @@ -196,7 +208,13 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: '2', impact: '2', severity: '2' }, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, }).connector, }) .expect(200); @@ -268,7 +286,13 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: '2', impact: '2', severity: '2' }, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, }).connector, }) .expect(200); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts index d83d87da1e7af..1cbf79cb3326c 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts @@ -359,7 +359,13 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: '2', impact: '2', severity: '2' }, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, }).connector, }) .expect(200); diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts index bb94c31c220d6..302c3a0423bed 100644 --- a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts +++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts @@ -476,6 +476,8 @@ export default ({ getService }: FtrProviderContext): void => { impact: null, severity: null, urgency: null, + category: null, + subcategory: null, }, }, created_by: { From c4227d9a1f3b623f6773d277b42c13729363a43c Mon Sep 17 00:00:00 2001 From: Robert Oskamp Date: Thu, 11 Feb 2021 15:47:31 +0100 Subject: [PATCH 11/53] [ML] Transform functional tests - re-enable feature controls suite (#91095) (#91123) This PR re-enables the transform feature controls test suite. --- .../apps/transform/feature_controls/transform_security.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts b/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts index b8d6b88e4ed9a..46e0c01afcc38 100644 --- a/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts +++ b/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts @@ -15,8 +15,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const appsMenu = getService('appsMenu'); const managementMenu = getService('managementMenu'); - // FLAKY: https://github.com/elastic/kibana/issues/90576 - describe.skip('security', () => { + describe('security', () => { before(async () => { await esArchiver.load('empty_kibana'); await PageObjects.security.forceLogout(); From 51fbc3d6e373c202b903c721c0c3b2b0eee3f980 Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Thu, 11 Feb 2021 09:51:42 -0500 Subject: [PATCH 12/53] [App Search] Relevance Tuning logic listeners (#89461) (#91122) --- .../components/relevance_tuning/constants.ts | 26 + .../relevance_tuning_logic.test.ts | 913 +++++++++++++++++- .../relevance_tuning_logic.ts | 377 +++++++- .../components/relevance_tuning/types.ts | 22 +- .../components/relevance_tuning/utils.test.ts | 147 +++ .../components/relevance_tuning/utils.ts | 63 ++ 6 files changed, 1512 insertions(+), 36 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/utils.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/utils.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/constants.ts index 3655c60bde3bf..211995b2a7d18 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/constants.ts @@ -11,3 +11,29 @@ export const RELEVANCE_TUNING_TITLE = i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.title', { defaultMessage: 'Relevance Tuning' } ); + +export const UPDATE_SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.relevanceTuning.messages.updateSuccess', + { + defaultMessage: 'Relevance successfully tuned. The changes will impact your results shortly.', + } +); +export const DELETE_SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.relevanceTuning.messages.deleteSuccess', + { + defaultMessage: + 'Relevance has been reset to default values. The change will impact your results shortly.', + } +); +export const RESET_CONFIRMATION_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.relevanceTuning.messages.resetConfirmation', + { + defaultMessage: 'Are you sure you want to restore relevance defaults?', + } +); +export const DELETE_CONFIRMATION_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.relevanceTuning.messages.deleteConfirmation', + { + defaultMessage: 'Are you sure you want to delete this boost?', + } +); 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 7f7bce1b7ba95..194848bcfc86c 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 @@ -5,12 +5,18 @@ * 2.0. */ -import { LogicMounter } from '../../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__'; -import { BoostType } from './types'; +import { nextTick } from '@kbn/test/jest'; + +import { Boost, BoostType } from './types'; import { RelevanceTuningLogic } from './'; +jest.mock('../engine', () => ({ + EngineLogic: { values: { engineName: 'test-engine' } }, +})); + describe('RelevanceTuningLogic', () => { const { mount } = new LogicMounter(RelevanceTuningLogic); @@ -32,13 +38,27 @@ describe('RelevanceTuningLogic', () => { schema, schemaConflicts, }; - const searchResults = [{}, {}]; + const searchResults = [ + { + id: { + raw: '1', + }, + _meta: { + id: '1', + score: 100, + engine: 'my-engine', + }, + }, + ]; const DEFAULT_VALUES = { dataLoading: true, schema: {}, schemaConflicts: {}, - searchSettings: {}, + searchSettings: { + boosts: {}, + search_fields: {}, + }, unsavedChanges: false, filterInputValue: '', query: '', @@ -188,6 +208,873 @@ describe('RelevanceTuningLogic', () => { }); }); }); + + describe('setSearchSettingsResponse', () => { + it('should set searchSettings state and unsavedChanges to false', () => { + mount({ + unsavedChanges: true, + }); + RelevanceTuningLogic.actions.setSearchSettingsResponse(searchSettings); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + searchSettings, + unsavedChanges: false, + }); + }); + }); + }); + + describe('listeners', () => { + const { http } = mockHttpValues; + const { flashAPIErrors, setSuccessMessage } = mockFlashMessageHelpers; + let scrollToSpy: jest.SpyInstance; + let confirmSpy: jest.SpyInstance; + + const searchSettingsWithBoost = (boost: Boost) => ({ + ...searchSettings, + boosts: { + foo: [ + { + factor: 1, + type: 'functional', + }, + boost, + ], + }, + }); + + beforeAll(() => { + scrollToSpy = jest.spyOn(window, 'scrollTo').mockImplementation(() => true); + confirmSpy = jest.spyOn(window, 'confirm'); + }); + + afterAll(() => { + scrollToSpy.mockRestore(); + confirmSpy.mockRestore(); + }); + + describe('initializeRelevanceTuning', () => { + it('should make an API call and set state based on the normalized response', async () => { + mount(); + http.get.mockReturnValueOnce( + Promise.resolve({ + ...relevanceTuningProps, + searchSettings: { + ...relevanceTuningProps.searchSettings, + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + value: 5, + }, + ], + }, + }, + }) + ); + jest.spyOn(RelevanceTuningLogic.actions, 'onInitializeRelevanceTuning'); + + RelevanceTuningLogic.actions.initializeRelevanceTuning(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/search_settings/details' + ); + expect(RelevanceTuningLogic.actions.onInitializeRelevanceTuning).toHaveBeenCalledWith({ + ...relevanceTuningProps, + searchSettings: { + ...relevanceTuningProps.searchSettings, + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + value: ['5'], + }, + ], + }, + }, + }); + }); + + it('handles errors', async () => { + mount(); + http.get.mockReturnValueOnce(Promise.reject('error')); + + RelevanceTuningLogic.actions.initializeRelevanceTuning(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + + describe('getSearchResults', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should make an API call and set state based on the response', async () => { + const searchSettingsWithNewBoostProp = { + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + newBoost: true, // This should be deleted before sent to the server + }, + ], + }, + }; + + const searchSettingsWithoutNewBoostProp = { + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + }, + ], + }, + }; + + mount({ + query: 'foo', + searchSettings: searchSettingsWithNewBoostProp, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchResults'); + jest.spyOn(RelevanceTuningLogic.actions, 'setResultsLoading'); + http.post.mockReturnValueOnce( + Promise.resolve({ + results: searchResults, + }) + ); + + RelevanceTuningLogic.actions.getSearchResults(); + jest.runAllTimers(); + await nextTick(); + + expect(RelevanceTuningLogic.actions.setResultsLoading).toHaveBeenCalledWith(true); + expect(http.post).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/search_settings_search', + { + body: JSON.stringify(searchSettingsWithoutNewBoostProp), + query: { + query: 'foo', + }, + } + ); + expect(RelevanceTuningLogic.actions.setSearchResults).toHaveBeenCalledWith(searchResults); + }); + + it("won't send boosts or search_fields on the API call if there are none", async () => { + mount({ + query: 'foo', + searchSettings: { + searchField: {}, + boosts: {}, + }, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchResults'); + http.post.mockReturnValueOnce( + Promise.resolve({ + results: searchResults, + }) + ); + + RelevanceTuningLogic.actions.getSearchResults(); + + jest.runAllTimers(); + await nextTick(); + + expect(http.post).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/search_settings_search', + { + body: '{}', + query: { + query: 'foo', + }, + } + ); + }); + + it('will call clearSearchResults if there is no query', async () => { + mount({ + query: '', + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchResults'); + jest.spyOn(RelevanceTuningLogic.actions, 'setResultsLoading'); + jest.spyOn(RelevanceTuningLogic.actions, 'clearSearchResults'); + + RelevanceTuningLogic.actions.getSearchResults(); + jest.runAllTimers(); + await nextTick(); + + expect(RelevanceTuningLogic.actions.clearSearchResults).toHaveBeenCalled(); + expect(RelevanceTuningLogic.actions.setSearchResults).not.toHaveBeenCalled(); + expect(RelevanceTuningLogic.actions.setResultsLoading).not.toHaveBeenCalled(); + }); + + it('handles errors', async () => { + mount({ + query: 'foo', + }); + http.post.mockReturnValueOnce(Promise.reject('error')); + RelevanceTuningLogic.actions.getSearchResults(); + + jest.runAllTimers(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + + describe('setSearchSettings', () => { + it('updates search results whenever search settings are changed', () => { + mount(); + jest.spyOn(RelevanceTuningLogic.actions, 'getSearchResults'); + + RelevanceTuningLogic.actions.setSearchSettings(searchSettings); + + expect(RelevanceTuningLogic.actions.getSearchResults).toHaveBeenCalled(); + }); + }); + + describe('onSearchSettingsSuccess', () => { + it('should save the response, trigger a new search, and then scroll to the top', () => { + mount(); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettingsResponse'); + jest.spyOn(RelevanceTuningLogic.actions, 'getSearchResults'); + + RelevanceTuningLogic.actions.onSearchSettingsSuccess(searchSettings); + + expect(RelevanceTuningLogic.actions.setSearchSettingsResponse).toHaveBeenCalledWith( + searchSettings + ); + expect(RelevanceTuningLogic.actions.getSearchResults).toHaveBeenCalled(); + expect(scrollToSpy).toHaveBeenCalledWith(0, 0); + }); + }); + + describe('onSearchSettingsError', () => { + it('scrolls to the top', () => { + mount(); + RelevanceTuningLogic.actions.onSearchSettingsError(); + expect(scrollToSpy).toHaveBeenCalledWith(0, 0); + }); + }); + + describe('updateSearchSettings', () => { + it('calls an API endpoint and handles success response', async () => { + const searchSettingsWithNewBoostProp = { + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + newBoost: true, // This should be deleted before sent to the server + }, + ], + }, + }; + + const searchSettingsWithoutNewBoostProp = { + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + }, + ], + }, + }; + mount({ + searchSettings: searchSettingsWithNewBoostProp, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'onSearchSettingsSuccess'); + http.put.mockReturnValueOnce(Promise.resolve(searchSettingsWithoutNewBoostProp)); + + RelevanceTuningLogic.actions.updateSearchSettings(); + await nextTick(); + + expect(http.put).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/search_settings', + { + body: JSON.stringify(searchSettingsWithoutNewBoostProp), + } + ); + expect(setSuccessMessage).toHaveBeenCalledWith( + 'Relevance successfully tuned. The changes will impact your results shortly.' + ); + expect(RelevanceTuningLogic.actions.onSearchSettingsSuccess).toHaveBeenCalledWith( + searchSettingsWithoutNewBoostProp + ); + }); + + it('handles errors', async () => { + mount(); + jest.spyOn(RelevanceTuningLogic.actions, 'onSearchSettingsError'); + http.put.mockReturnValueOnce(Promise.reject('error')); + + RelevanceTuningLogic.actions.updateSearchSettings(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + expect(RelevanceTuningLogic.actions.onSearchSettingsError).toHaveBeenCalled(); + }); + }); + + describe('resetSearchSettings', () => { + it('calls and API endpoint, shows a success message, and saves the response', async () => { + mount(); + jest.spyOn(RelevanceTuningLogic.actions, 'onSearchSettingsSuccess'); + confirmSpy.mockImplementation(() => true); + http.post.mockReturnValueOnce(Promise.resolve(searchSettings)); + + RelevanceTuningLogic.actions.resetSearchSettings(); + await nextTick(); + + expect(http.post).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/search_settings/reset' + ); + expect(setSuccessMessage).toHaveBeenCalledWith( + 'Relevance has been reset to default values. The change will impact your results shortly.' + ); + expect(RelevanceTuningLogic.actions.onSearchSettingsSuccess).toHaveBeenCalledWith( + searchSettings + ); + }); + + it('does nothing if the user does not confirm', async () => { + mount(); + confirmSpy.mockImplementation(() => false); + + RelevanceTuningLogic.actions.resetSearchSettings(); + + expect(http.post).not.toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/search_settings/reset' + ); + }); + + it('handles errors', async () => { + mount(); + jest.spyOn(RelevanceTuningLogic.actions, 'onSearchSettingsError'); + confirmSpy.mockImplementation(() => true); + http.post.mockReturnValueOnce(Promise.reject('error')); + + RelevanceTuningLogic.actions.resetSearchSettings(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + expect(RelevanceTuningLogic.actions.onSearchSettingsError).toHaveBeenCalled(); + }); + }); + + describe('toggleSearchField', () => { + it('updates search weight to 1 in search fields when enabling', () => { + mount({ + searchSettings: { + ...searchSettings, + search_fields: { + bar: { + weight: 1, + }, + }, + }, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.toggleSearchField('foo', false); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith({ + ...searchSettings, + search_fields: { + bar: { + weight: 1, + }, + foo: { + weight: 1, + }, + }, + }); + }); + + it('removes fields from search fields when disabling', () => { + mount({ + searchSettings: { + ...searchSettings, + search_fields: { + bar: { + weight: 1, + }, + }, + }, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.toggleSearchField('bar', true); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith({ + ...searchSettings, + }); + }); + }); + + describe('updateFieldWeight', () => { + it('updates the search weight in search fields', () => { + mount({ + searchSettings, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateFieldWeight('foo', 3); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith({ + ...searchSettings, + search_fields: { + foo: { + weight: 3, + }, + }, + }); + }); + + it('will round decimal numbers', () => { + mount({ + searchSettings, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateFieldWeight('foo', 3.9393939); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith({ + ...searchSettings, + search_fields: { + foo: { + weight: 3.9, + }, + }, + }); + }); + }); + + describe('addBoost', () => { + it('adds a boost of given type for the given field', () => { + mount({ + searchSettings: { + ...searchSettings, + boosts: { + foo: [ + { + factor: 2, + type: 'value', + }, + ], + }, + }, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.addBoost('foo', 'functional'); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith({ + ...searchSettings, + boosts: { + foo: [ + { + factor: 2, + type: 'value', + }, + { + factor: 1, + newBoost: true, + type: 'functional', + }, + ], + }, + }); + }); + + it('works even if there are no boosts yet', () => { + mount({ + searchSettings: { + ...searchSettings, + boosts: {}, + }, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.addBoost('foo', 'functional'); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith({ + ...searchSettings, + boosts: { + foo: [ + { + factor: 1, + newBoost: true, + type: 'functional', + }, + ], + }, + }); + }); + }); + + describe('deleteBoost', () => { + it('deletes the boost with the given name and index', () => { + mount({ + searchSettings: { + ...searchSettings, + boosts: { + foo: [ + { + factor: 1, + type: 'functional', + }, + { + factor: 2, + type: 'value', + }, + ], + }, + }, + }); + confirmSpy.mockImplementation(() => true); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.deleteBoost('foo', 1); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith({ + ...searchSettings, + boosts: { + foo: [ + { + factor: 1, + type: 'functional', + }, + ], + }, + }); + }); + + it('will delete they field key in boosts if this is the last boost or that field', () => { + mount({ + searchSettings: { + ...searchSettings, + boosts: { + foo: [ + { + factor: 1, + type: 'functional', + }, + ], + }, + }, + }); + confirmSpy.mockImplementation(() => true); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.deleteBoost('foo', 0); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith({ + ...searchSettings, + boosts: {}, + }); + }); + + it('will do nothing if the user does not confirm', () => { + mount({ + searchSettings: { + ...searchSettings, + boosts: { + foo: [ + { + factor: 1, + type: 'functional', + }, + ], + }, + }, + }); + confirmSpy.mockImplementation(() => false); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.deleteBoost('foo', 0); + + expect(RelevanceTuningLogic.actions.setSearchSettings).not.toHaveBeenCalled(); + }); + }); + + describe('updateBoostFactor', () => { + it('updates the boost factor of the target boost', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateBoostFactor('foo', 1, 5); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 5, + type: 'functional', + }) + ); + }); + + it('will round decimal numbers', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateBoostFactor('foo', 1, 5.293191); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 5.3, + type: 'functional', + }) + ); + }); + }); + + describe('updateBoostValue', () => { + it('will update the boost value and update search reuslts', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['a', 'b', 'c'], + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateBoostValue('foo', 1, 1, 'a'); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['a', 'a', 'c'], + }) + ); + }); + + it('will create a new array if no array exists yet for value', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateBoostValue('foo', 1, 0, 'a'); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['a'], + }) + ); + }); + }); + + describe('updateBoostCenter', () => { + it('will parse the provided provided value and set the center to that parsed value', () => { + mount({ + schema: { + foo: 'number', + }, + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'proximity', + center: 1, + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateBoostCenter('foo', 1, '4'); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'proximity', + center: 4, + }) + ); + }); + }); + + describe('addBoostValue', () => { + it('will add an empty boost value', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['a'], + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.addBoostValue('foo', 1); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['a', ''], + }) + ); + }); + + it('will add two empty boost values if none exist yet', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.addBoostValue('foo', 1); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['', ''], + }) + ); + }); + + it('will still work if the boost index is out of range', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['a', ''], + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.addBoostValue('foo', 10); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['a', ''], + }) + ); + }); + }); + + describe('removeBoostValue', () => { + it('will remove a boost value', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: '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: 'functional', + value: ['a', 'c'], + }) + ); + }); + + it('will do nothing if boost values do not exist', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: '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({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateBoostSelectOption('foo', 1, 'function', 'exponential'); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'functional', + function: 'exponential', + }) + ); + }); + + it('can also update operation', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateBoostSelectOption('foo', 1, 'operation', 'add'); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'functional', + operation: 'add', + }) + ); + }); + }); + + describe('updateSearchValue', () => { + it('should update the query then update search results', () => { + mount(); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchQuery'); + jest.spyOn(RelevanceTuningLogic.actions, 'getSearchResults'); + + RelevanceTuningLogic.actions.updateSearchValue('foo'); + + expect(RelevanceTuningLogic.actions.setSearchQuery).toHaveBeenCalledWith('foo'); + expect(RelevanceTuningLogic.actions.getSearchResults).toHaveBeenCalled(); + }); + }); }); describe('selectors', () => { @@ -253,24 +1140,6 @@ describe('RelevanceTuningLogic', () => { }); expect(RelevanceTuningLogic.values.filteredSchemaFields).toEqual(['bar', 'baz']); }); - - it('should return all schema fields if there is no filter applied', () => { - mount({ - filterTerm: '', - schema: { - id: 'string', - foo: 'string', - bar: 'string', - baz: 'string', - }, - }); - expect(RelevanceTuningLogic.values.filteredSchemaFields).toEqual([ - 'id', - 'foo', - 'bar', - 'baz', - ]); - }); }); describe('filteredSchemaFieldsWithConflicts', () => { 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 d4ec5e37f6ce5..cd3d8b5686cc0 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 @@ -6,10 +6,28 @@ */ import { kea, MakeLogicType } from 'kea'; +import { omit, cloneDeep, isEmpty } from 'lodash'; +import { setSuccessMessage, flashAPIErrors } from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; import { Schema, SchemaConflicts } from '../../../shared/types'; -import { SearchSettings } from './types'; +import { EngineLogic } from '../engine'; +import { Result } from '../result/types'; + +import { + UPDATE_SUCCESS_MESSAGE, + RESET_CONFIRMATION_MESSAGE, + DELETE_SUCCESS_MESSAGE, + DELETE_CONFIRMATION_MESSAGE, +} from './constants'; +import { BaseBoost, Boost, BoostType, SearchSettings } from './types'; +import { + filterIfTerm, + parseBoostCenter, + removeBoostStateProps, + normalizeBoostValues, +} from './utils'; interface RelevanceTuningProps { searchSettings: SearchSettings; @@ -22,15 +40,60 @@ interface RelevanceTuningActions { setSearchSettings(searchSettings: SearchSettings): { searchSettings: SearchSettings }; setFilterValue(value: string): string; setSearchQuery(value: string): string; - setSearchResults(searchResults: object[]): object[]; + setSearchResults(searchResults: Result[]): Result[]; setResultsLoading(resultsLoading: boolean): boolean; clearSearchResults(): void; resetSearchSettingsState(): void; dismissSchemaConflictCallout(): void; + initializeRelevanceTuning(): void; + getSearchResults(): void; + setSearchSettingsResponse(searchSettings: SearchSettings): { searchSettings: SearchSettings }; + onSearchSettingsSuccess(searchSettings: SearchSettings): { searchSettings: SearchSettings }; + onSearchSettingsError(): void; + updateSearchSettings(): void; + resetSearchSettings(): void; + toggleSearchField(name: string, disableField: boolean): { name: string; disableField: boolean }; + updateFieldWeight(name: string, weight: number): { name: string; weight: number }; + addBoost(name: string, type: BoostType): { name: string; type: BoostType }; + deleteBoost(name: string, index: number): { name: string; index: number }; + updateBoostFactor( + name: string, + index: number, + factor: number + ): { name: string; index: number; factor: number }; + updateBoostValue( + name: string, + boostIndex: number, + valueIndex: number, + value: string + ): { name: string; boostIndex: number; valueIndex: number; value: 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, + optionType: keyof BaseBoost, + value: string + ): { + name: string; + boostIndex: number; + optionType: keyof BaseBoost; + value: string; + }; + updateSearchValue(query: string): string; } interface RelevanceTuningValues { - searchSettings: Partial; + searchSettings: SearchSettings; schema: Schema; schemaFields: string[]; schemaFieldsWithConflicts: string[]; @@ -43,15 +106,10 @@ interface RelevanceTuningValues { query: string; unsavedChanges: boolean; dataLoading: boolean; - searchResults: object[] | null; + searchResults: Result[] | null; resultsLoading: boolean; } -// If the user hasn't entered a filter, then we can skip filtering the array entirely -const filterIfTerm = (array: string[], filterTerm: string): string[] => { - return filterTerm === '' ? array : array.filter((item) => item.includes(filterTerm)); -}; - export const RelevanceTuningLogic = kea< MakeLogicType >({ @@ -66,13 +124,47 @@ export const RelevanceTuningLogic = kea< clearSearchResults: true, resetSearchSettingsState: true, dismissSchemaConflictCallout: true, + initializeRelevanceTuning: true, + getSearchResults: true, + setSearchSettingsResponse: (searchSettings) => ({ + searchSettings, + }), + onSearchSettingsSuccess: (searchSettings) => ({ searchSettings }), + onSearchSettingsError: () => true, + updateSearchSettings: true, + resetSearchSettings: true, + toggleSearchField: (name, disableField) => ({ name, disableField }), + updateFieldWeight: (name, weight) => ({ name, weight }), + 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, + }), + 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, + optionType, + value, + }), + updateSearchValue: (query) => query, }), reducers: () => ({ searchSettings: [ - {}, + { + search_fields: {}, + boosts: {}, + }, { onInitializeRelevanceTuning: (_, { searchSettings }) => searchSettings, setSearchSettings: (_, { searchSettings }) => searchSettings, + setSearchSettingsResponse: (_, { searchSettings }) => searchSettings, }, ], schema: [ @@ -109,6 +201,7 @@ export const RelevanceTuningLogic = kea< false, { setSearchSettings: () => true, + setSearchSettingsResponse: () => false, }, ], @@ -155,4 +248,268 @@ export const RelevanceTuningLogic = kea< (schema: Schema): boolean => Object.keys(schema).length >= 2, ], }), + listeners: ({ actions, values }) => ({ + initializeRelevanceTuning: async () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + const url = `/api/app_search/engines/${engineName}/search_settings/details`; + + try { + const response = await http.get(url); + actions.onInitializeRelevanceTuning({ + ...response, + searchSettings: { + ...response.searchSettings, + boosts: normalizeBoostValues(response.searchSettings.boosts), + }, + }); + } catch (e) { + flashAPIErrors(e); + } + }, + getSearchResults: async (_, breakpoint) => { + await breakpoint(250); + + const query = values.query; + if (!query) return actions.clearSearchResults(); + + const { engineName } = EngineLogic.values; + const { http } = HttpLogic.values; + const { search_fields: searchFields, boosts } = removeBoostStateProps(values.searchSettings); + const url = `/api/app_search/engines/${engineName}/search_settings_search`; + + actions.setResultsLoading(true); + + try { + const response = await http.post(url, { + query: { + query, + }, + body: JSON.stringify({ + boosts: isEmpty(boosts) ? undefined : boosts, + search_fields: isEmpty(searchFields) ? undefined : searchFields, + }), + }); + + actions.setSearchResults(response.results); + } catch (e) { + flashAPIErrors(e); + } + }, + setSearchSettings: () => { + actions.getSearchResults(); + }, + onSearchSettingsSuccess: ({ searchSettings }) => { + actions.setSearchSettingsResponse(searchSettings); + actions.getSearchResults(); + window.scrollTo(0, 0); + }, + onSearchSettingsError: () => { + window.scrollTo(0, 0); + }, + updateSearchSettings: async () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + const url = `/api/app_search/engines/${engineName}/search_settings`; + + try { + const response = await http.put(url, { + body: JSON.stringify(removeBoostStateProps(values.searchSettings)), + }); + setSuccessMessage(UPDATE_SUCCESS_MESSAGE); + actions.onSearchSettingsSuccess(response); + } catch (e) { + flashAPIErrors(e); + actions.onSearchSettingsError(); + } + }, + resetSearchSettings: async () => { + if (window.confirm(RESET_CONFIRMATION_MESSAGE)) { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + const url = `/api/app_search/engines/${engineName}/search_settings/reset`; + + try { + const response = await http.post(url); + setSuccessMessage(DELETE_SUCCESS_MESSAGE); + actions.onSearchSettingsSuccess(response); + } catch (e) { + flashAPIErrors(e); + actions.onSearchSettingsError(); + } + } + }, + toggleSearchField: ({ name, disableField }) => { + const { searchSettings } = values; + + const searchFields = disableField + ? omit(searchSettings.search_fields, name) + : { ...searchSettings.search_fields, [name]: { weight: 1 } }; + + actions.setSearchSettings({ + ...searchSettings, + search_fields: searchFields, + }); + }, + updateFieldWeight: ({ name, weight }) => { + const { searchSettings } = values; + const { search_fields: searchFields } = searchSettings; + + actions.setSearchSettings({ + ...searchSettings, + search_fields: { + ...searchFields, + [name]: { + ...searchFields[name], + weight: Math.round(weight * 10) / 10, + }, + }, + }); + }, + addBoost: ({ name, type }) => { + const { searchSettings } = values; + const { boosts } = searchSettings; + const emptyBoost = { type, factor: 1, newBoost: true }; + let boostArray; + + if (Array.isArray(boosts[name])) { + boostArray = boosts[name].slice(); + boostArray.push(emptyBoost); + } else { + boostArray = [emptyBoost]; + } + + actions.setSearchSettings({ + ...searchSettings, + boosts: { + ...boosts, + [name]: boostArray, + }, + }); + }, + deleteBoost: ({ name, index }) => { + if (window.confirm(DELETE_CONFIRMATION_MESSAGE)) { + const { searchSettings } = values; + const { boosts } = searchSettings; + const boostsRemoved = boosts[name].slice(); + boostsRemoved.splice(index, 1); + const updatedBoosts = { ...boosts }; + + if (boostsRemoved.length > 0) { + updatedBoosts[name] = boostsRemoved; + } else { + delete updatedBoosts[name]; + } + + actions.setSearchSettings({ + ...searchSettings, + boosts: updatedBoosts, + }); + } + }, + updateBoostFactor: ({ name, index, factor }) => { + const { searchSettings } = values; + const { boosts } = searchSettings; + const updatedBoosts = cloneDeep(boosts[name]); + updatedBoosts[index].factor = Math.round(factor * 10) / 10; + + actions.setSearchSettings({ + ...searchSettings, + boosts: { + ...boosts, + [name]: updatedBoosts, + }, + }); + }, + updateBoostValue: ({ name, boostIndex, valueIndex, value }) => { + 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; + } + + actions.setSearchSettings({ + ...searchSettings, + boosts: { + ...boosts, + [name]: updatedBoosts, + }, + }); + }, + updateBoostCenter: ({ name, boostIndex, value }) => { + const { searchSettings } = values; + const { boosts } = searchSettings; + const updatedBoosts = cloneDeep(boosts[name]); + const fieldType = values.schema[name]; + updatedBoosts[boostIndex].center = parseBoostCenter(fieldType, value); + + actions.setSearchSettings({ + ...searchSettings, + boosts: { + ...boosts, + [name]: updatedBoosts, + }, + }); + }, + 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; + const updatedBoosts = cloneDeep(boosts[name]); + updatedBoosts[boostIndex][optionType] = value; + + actions.setSearchSettings({ + ...searchSettings, + boosts: { + ...boosts, + [name]: updatedBoosts, + }, + }); + }, + updateSearchValue: (query) => { + actions.setSearchQuery(query); + actions.getSearchResults(); + }, + }), }); 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 25187df89d64c..a1ed9797b9f5a 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 @@ -7,17 +7,31 @@ export type BoostType = 'value' | 'functional' | 'proximity'; -export interface Boost { - type: BoostType; +export interface BaseBoost { operation?: string; function?: string; +} + +// A boost that comes from the server, before we normalize it has a much looser schema +export interface RawBoost extends BaseBoost { + type: BoostType; newBoost?: boolean; center?: string | number; - value?: string | number | string[] | number[]; + value?: string | number | boolean | object | Array; factor: number; } +// We normalize raw boosts to make them safer and easier to work with +export interface Boost extends RawBoost { + value?: string[]; +} export interface SearchSettings { boosts: Record; - search_fields: object; + search_fields: Record< + string, + { + weight: number; + } + >; + result_fields?: object; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/utils.test.ts new file mode 100644 index 0000000000000..a6598bf991c13 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/utils.test.ts @@ -0,0 +1,147 @@ +/* + * 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 { BoostType } from './types'; +import { + filterIfTerm, + normalizeBoostValues, + removeBoostStateProps, + parseBoostCenter, +} from './utils'; + +describe('filterIfTerm', () => { + it('will filter a list of strings to a list of strings containing the specified string', () => { + expect(filterIfTerm(['jalepeno', 'no', 'not', 'panorama', 'truck'], 'no')).toEqual([ + 'jalepeno', + 'no', + 'not', + 'panorama', + ]); + }); + + it('will not filter at all if an empty string is provided', () => { + expect(filterIfTerm(['jalepeno', 'no', 'not', 'panorama', 'truck'], '')).toEqual([ + 'jalepeno', + 'no', + 'not', + 'panorama', + 'truck', + ]); + }); +}); + +describe('removeBoostStateProps', () => { + it('will remove the newBoost flag from boosts within the provided searchSettings object', () => { + const searchSettings = { + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + newBoost: true, + }, + ], + }, + search_fields: { + foo: { + weight: 1, + }, + }, + }; + expect(removeBoostStateProps(searchSettings)).toEqual({ + ...searchSettings, + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + }, + ], + }, + }); + }); +}); + +describe('parseBoostCenter', () => { + it('should parse a boost center', () => { + expect(parseBoostCenter('text', 5)).toEqual(5); + expect(parseBoostCenter('text', '4')).toEqual('4'); + expect(parseBoostCenter('number', 5)).toEqual(5); + expect(parseBoostCenter('number', '5')).toEqual(5); + }); +}); + +describe('normalizeBoostValues', () => { + const boosts = { + foo: [ + { + type: 'value' as BoostType, + factor: 9.5, + value: 1, + }, + { + type: 'value' as BoostType, + factor: 9.5, + value: '1', + }, + { + type: 'value' as BoostType, + factor: 9.5, + value: [1], + }, + { + type: 'value' as BoostType, + factor: 9.5, + value: ['1'], + }, + { + type: 'value' as BoostType, + factor: 9.5, + value: [ + '1', + 1, + '2', + 2, + true, + { + b: 'a', + }, + {}, + ], + }, + ], + bar: [ + { + type: 'proximity' as BoostType, + factor: 9.5, + }, + ], + sp_def: [ + { + type: 'functional' as BoostType, + factor: 5, + }, + ], + }; + + it('converts all value types to string for consistency', () => { + expect(normalizeBoostValues(boosts)).toEqual({ + bar: [{ factor: 9.5, type: 'proximity' }], + foo: [ + { factor: 9.5, type: 'value', value: ['1'] }, + { factor: 9.5, type: 'value', value: ['1'] }, + { factor: 9.5, type: 'value', value: ['1'] }, + { factor: 9.5, type: 'value', value: ['1'] }, + { + factor: 9.5, + type: 'value', + value: ['1', '1', '2', '2', 'true', '[object Object]', '[object Object]'], + }, + ], + sp_def: [{ type: 'functional', factor: 5 }], + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/utils.ts new file mode 100644 index 0000000000000..e2fd0f0bbd656 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/utils.ts @@ -0,0 +1,63 @@ +/* + * 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 { cloneDeep, omit } from 'lodash'; + +import { NUMBER } from '../../../shared/constants/field_types'; +import { SchemaTypes } from '../../../shared/types'; + +import { RawBoost, Boost, SearchSettings, BoostType } from './types'; + +// If the user hasn't entered a filter, then we can skip filtering the array entirely +export const filterIfTerm = (array: string[], filterTerm: string): string[] => { + return filterTerm === '' ? array : array.filter((item) => item.includes(filterTerm)); +}; + +export const removeBoostStateProps = (searchSettings: SearchSettings) => { + const updatedSettings = cloneDeep(searchSettings); + const { boosts } = updatedSettings; + const keys = Object.keys(boosts); + keys.forEach((key) => boosts[key].forEach((boost) => delete boost.newBoost)); + + return updatedSettings; +}; + +export const parseBoostCenter = (fieldType: SchemaTypes, value: string | number) => { + // Leave non-numeric fields alone + if (fieldType === NUMBER) { + const floatValue = parseFloat(value as string); + return isNaN(floatValue) ? value : floatValue; + } + return value; +}; + +const toArray = (v: T | T[]): T[] => (Array.isArray(v) ? v : [v]); +const toString = (v1: T) => String(v1); + +const normalizeBoostValue = (boost: RawBoost): Boost => { + if (!boost.hasOwnProperty('value')) { + // Can't simply do `return boost` here as TS can't infer the correct type + return omit(boost, 'value'); + } + + return { + ...boost, + type: boost.type as BoostType, + value: toArray(boost.value).map(toString), + }; +}; + +// Data may have been set to invalid types directly via the public App Search API. Since these are invalid, we don't want to deal +// with them as valid types in the UI. For that reason, we stringify all values here, as the data comes in. +// Additionally, values can be in single values or in arrays. +export const normalizeBoostValues = (boosts: Record): Record => + Object.entries(boosts).reduce((newBoosts, [fieldName, boostList]) => { + return { + ...newBoosts, + [fieldName]: boostList.map(normalizeBoostValue), + }; + }, {}); From 5905ea2cf14e800a499a58baffa89e997738b377 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Thu, 11 Feb 2021 09:29:29 -0600 Subject: [PATCH 13/53] skip grokdebugger tests. #84440 --- x-pack/test/functional/apps/grok_debugger/grok_debugger.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/test/functional/apps/grok_debugger/grok_debugger.js b/x-pack/test/functional/apps/grok_debugger/grok_debugger.js index b2a1c5363fcb6..c21731a2bdc8a 100644 --- a/x-pack/test/functional/apps/grok_debugger/grok_debugger.js +++ b/x-pack/test/functional/apps/grok_debugger/grok_debugger.js @@ -11,8 +11,8 @@ export default function ({ getService, getPageObjects }) { const esArchiver = getService('esArchiver'); const PageObjects = getPageObjects(['grokDebugger']); - - describe('grok debugger app', function () { + // https://github.com/elastic/kibana/issues/84440 + describe.skip('grok debugger app', function () { this.tags('includeFirefox'); before(async () => { await esArchiver.load('empty_kibana'); From a4a9fe50be9f24638bc1b135498a1619ef1b076a Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 11 Feb 2021 16:55:52 +0100 Subject: [PATCH 14/53] pass through correct type (#90574) (#91127) --- .../data/common/search/aggs/agg_type.ts | 9 ++++- .../common/search/aggs/metrics/cardinality.ts | 1 + .../search/tabify/response_writer.test.ts | 36 ++++++++++++++----- .../common/search/tabify/response_writer.ts | 3 +- src/plugins/data/public/public.api.md | 1 + src/plugins/data/server/server.api.md | 1 + 6 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/plugins/data/common/search/aggs/agg_type.ts b/src/plugins/data/common/search/aggs/agg_type.ts index 7931ce1f59577..4583be17478e3 100644 --- a/src/plugins/data/common/search/aggs/agg_type.ts +++ b/src/plugins/data/common/search/aggs/agg_type.ts @@ -10,7 +10,7 @@ import { constant, noop, identity } from 'lodash'; import { i18n } from '@kbn/i18n'; import { ISearchSource } from 'src/plugins/data/public'; -import { SerializedFieldFormat } from 'src/plugins/expressions/common'; +import { DatatableColumnType, SerializedFieldFormat } from 'src/plugins/expressions/common'; import type { RequestAdapter } from 'src/plugins/inspector/common'; import { initParams } from './agg_params'; @@ -33,6 +33,7 @@ export interface AggTypeConfig< ordered?: any; hasNoDsl?: boolean; params?: Array>; + valueType?: DatatableColumnType; getRequestAggs?: ((aggConfig: TAggConfig) => TAggConfig[]) | (() => TAggConfig[] | void); getResponseAggs?: ((aggConfig: TAggConfig) => TAggConfig[]) | (() => TAggConfig[] | void); customLabels?: boolean; @@ -91,6 +92,11 @@ export class AggType< * @type {string} */ title: string; + /** + * The type the values produced by this agg will have in the final data table. + * If not specified, the type of the field is used. + */ + valueType?: DatatableColumnType; /** * a function that will be called when this aggType is assigned to * an aggConfig, and that aggConfig is being rendered (in a form, chart, etc.). @@ -222,6 +228,7 @@ export class AggType< this.dslName = config.dslName || config.name; this.expressionName = config.expressionName; this.title = config.title; + this.valueType = config.valueType; this.makeLabel = config.makeLabel || constant(this.name); this.ordered = config.ordered; this.hasNoDsl = !!config.hasNoDsl; diff --git a/src/plugins/data/common/search/aggs/metrics/cardinality.ts b/src/plugins/data/common/search/aggs/metrics/cardinality.ts index 38dd5893eb8ad..5a18924902fc3 100644 --- a/src/plugins/data/common/search/aggs/metrics/cardinality.ts +++ b/src/plugins/data/common/search/aggs/metrics/cardinality.ts @@ -24,6 +24,7 @@ export interface AggParamsCardinality extends BaseAggParams { export const getCardinalityMetricAgg = () => new MetricAggType({ name: METRIC_TYPES.CARDINALITY, + valueType: 'number', expressionName: aggCardinalityFnName, title: uniqueCountTitle, makeLabel(aggConfig: IMetricAggConfig) { diff --git a/src/plugins/data/common/search/tabify/response_writer.test.ts b/src/plugins/data/common/search/tabify/response_writer.test.ts index 6cec6d431ab70..603ccc0f493c7 100644 --- a/src/plugins/data/common/search/tabify/response_writer.test.ts +++ b/src/plugins/data/common/search/tabify/response_writer.test.ts @@ -7,7 +7,7 @@ */ import { TabbedAggResponseWriter } from './response_writer'; -import { AggConfigs, BUCKET_TYPES } from '../aggs'; +import { AggConfigs, BUCKET_TYPES, METRIC_TYPES } from '../aggs'; import { mockAggTypesRegistry } from '../aggs/test_helpers'; import { TabbedResponseWriterOptions } from './types'; @@ -23,7 +23,12 @@ describe('TabbedAggResponseWriter class', () => { field: 'geo.src', }, }, - { type: 'count' }, + { + type: METRIC_TYPES.CARDINALITY, + params: { + field: 'machine.os.raw', + }, + }, ]; const twoSplitsAggConfig = [ @@ -39,7 +44,12 @@ describe('TabbedAggResponseWriter class', () => { field: 'machine.os.raw', }, }, - { type: 'count' }, + { + type: METRIC_TYPES.CARDINALITY, + params: { + field: 'machine.os.raw', + }, + }, ]; const createResponseWritter = (aggs: any[] = [], opts?: Partial) => { @@ -174,19 +184,23 @@ describe('TabbedAggResponseWriter class', () => { }); expect(response.columns[1]).toHaveProperty('id', 'col-1-2'); - expect(response.columns[1]).toHaveProperty('name', 'Count'); + expect(response.columns[1]).toHaveProperty('name', 'Unique count of machine.os.raw'); expect(response.columns[1]).toHaveProperty('meta', { index: 'logstash-*', + field: 'machine.os.raw', params: { id: 'number', }, source: 'esaggs', sourceParams: { + appliedTimeRange: undefined, enabled: true, id: '2', indexPatternId: '1234', - params: {}, - type: 'count', + params: { + field: 'machine.os.raw', + }, + type: 'cardinality', }, type: 'number', }); @@ -231,19 +245,23 @@ describe('TabbedAggResponseWriter class', () => { }); expect(response.columns[1]).toHaveProperty('id', 'col-1-2'); - expect(response.columns[1]).toHaveProperty('name', 'Count'); + expect(response.columns[1]).toHaveProperty('name', 'Unique count of machine.os.raw'); expect(response.columns[1]).toHaveProperty('meta', { index: 'logstash-*', + field: 'machine.os.raw', params: { id: 'number', }, source: 'esaggs', sourceParams: { + appliedTimeRange: undefined, enabled: true, id: '2', indexPatternId: '1234', - params: {}, - type: 'count', + params: { + field: 'machine.os.raw', + }, + type: 'cardinality', }, type: 'number', }); diff --git a/src/plugins/data/common/search/tabify/response_writer.ts b/src/plugins/data/common/search/tabify/response_writer.ts index 1c312e5cd2200..a0ba07598e53a 100644 --- a/src/plugins/data/common/search/tabify/response_writer.ts +++ b/src/plugins/data/common/search/tabify/response_writer.ts @@ -73,7 +73,8 @@ export class TabbedAggResponseWriter { id: column.id, name: column.name, meta: { - type: column.aggConfig.params.field?.type || 'number', + type: + column.aggConfig.type.valueType || column.aggConfig.params.field?.type || 'number', field: column.aggConfig.params.field?.name, index: column.aggConfig.getIndexPattern()?.title, params: column.aggConfig.toSerializedFieldFormat(), diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 03ae893ab526d..782f6f45eadc7 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -24,6 +24,7 @@ import * as CSS from 'csstype'; import { Datatable as Datatable_2 } from 'src/plugins/expressions'; import { Datatable as Datatable_3 } from 'src/plugins/expressions/common'; import { DatatableColumn as DatatableColumn_2 } from 'src/plugins/expressions'; +import { DatatableColumnType } from 'src/plugins/expressions/common'; import { DetailedPeerCertificate } from 'tls'; import { Ensure } from '@kbn/utility-types'; import { EnvironmentMode } from '@kbn/config'; diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 3951bb0c7ed75..d579663eeca43 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -18,6 +18,7 @@ import { CoreStart as CoreStart_2 } from 'src/core/server'; import { Datatable } from 'src/plugins/expressions'; import { Datatable as Datatable_2 } from 'src/plugins/expressions/common'; import { DatatableColumn } from 'src/plugins/expressions'; +import { DatatableColumnType } from 'src/plugins/expressions/common'; import { Duration } from 'moment'; import { ElasticsearchClient } from 'src/core/server'; import { ElasticsearchClient as ElasticsearchClient_2 } from 'kibana/server'; From cc5f74be0ac13a6d88080e21f0cf4c41ee5028ad Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 11 Feb 2021 11:00:21 -0500 Subject: [PATCH 15/53] [core] fix cleanup logic for rolling file tests (#90797) (#91113) * fix cleanup for rolling file tests * do not swallow errors Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Mikhail Shustov --- .../integration_tests/rolling_file_appender.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/core/server/logging/integration_tests/rolling_file_appender.test.ts b/src/core/server/logging/integration_tests/rolling_file_appender.test.ts index 714485da1654a..fb2a714adb687 100644 --- a/src/core/server/logging/integration_tests/rolling_file_appender.test.ts +++ b/src/core/server/logging/integration_tests/rolling_file_appender.test.ts @@ -48,11 +48,10 @@ describe('RollingFileAppender', () => { }); afterEach(async () => { - try { - await rmdir(testDir); - } catch (e) { - /* trap */ + if (testDir) { + await rmdir(testDir, { recursive: true }); } + if (root) { await root.shutdown(); } From 52497660a970ddc2fa9a2a1245e7884528834849 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Thu, 11 Feb 2021 16:40:33 +0000 Subject: [PATCH 16/53] [Task manager] Adds support for limited concurrency tasks (#90365) (#91141) Adds support for limited concurrency on a Task Type. --- x-pack/plugins/task_manager/README.md | 8 +- .../server/buffered_task_store.test.ts | 10 +- .../server/buffered_task_store.ts | 4 - .../task_manager/server/lib/fill_pool.test.ts | 56 +- .../task_manager/server/lib/fill_pool.ts | 132 +- .../monitoring/task_run_statistics.test.ts | 1 + .../server/monitoring/task_run_statistics.ts | 56 +- .../task_manager/server/plugin.test.ts | 9 + x-pack/plugins/task_manager/server/plugin.ts | 10 +- .../polling/delay_on_claim_conflicts.test.ts | 61 + .../polling/delay_on_claim_conflicts.ts | 12 +- .../server/polling_lifecycle.test.ts | 151 +- .../task_manager/server/polling_lifecycle.ts | 126 +- .../mark_available_tasks_as_claimed.test.ts | 97 +- .../mark_available_tasks_as_claimed.ts | 70 +- .../server/queries/task_claiming.mock.ts | 33 + .../server/queries/task_claiming.test.ts | 1516 +++++++++++++ .../server/queries/task_claiming.ts | 488 +++++ x-pack/plugins/task_manager/server/task.ts | 10 + .../task_manager/server/task_events.ts | 16 +- .../task_manager/server/task_pool.test.ts | 2 + .../plugins/task_manager/server/task_pool.ts | 54 +- .../server/task_running/task_runner.test.ts | 1915 +++++++++-------- .../server/task_running/task_runner.ts | 191 +- .../server/task_scheduling.test.ts | 105 +- .../task_manager/server/task_scheduling.ts | 29 +- .../task_manager/server/task_store.mock.ts | 17 +- .../task_manager/server/task_store.test.ts | 1098 +--------- .../plugins/task_manager/server/task_store.ts | 240 +-- .../server/task_type_dictionary.ts | 4 + .../sample_task_plugin/server/init_routes.ts | 10 +- .../sample_task_plugin/server/plugin.ts | 14 + .../test_suites/task_manager/health_route.ts | 15 +- .../task_manager/task_management.ts | 207 +- 34 files changed, 4163 insertions(+), 2604 deletions(-) create mode 100644 x-pack/plugins/task_manager/server/queries/task_claiming.mock.ts create mode 100644 x-pack/plugins/task_manager/server/queries/task_claiming.test.ts create mode 100644 x-pack/plugins/task_manager/server/queries/task_claiming.ts diff --git a/x-pack/plugins/task_manager/README.md b/x-pack/plugins/task_manager/README.md index 9be3be14ea3fc..c20bc4b29bcc8 100644 --- a/x-pack/plugins/task_manager/README.md +++ b/x-pack/plugins/task_manager/README.md @@ -85,10 +85,10 @@ export class Plugin { // This defaults to what is configured at the task manager level. maxAttempts: 5, - // The clusterMonitoring task occupies 2 workers, so if the system has 10 worker slots, - // 5 clusterMonitoring tasks could run concurrently per Kibana instance. This value is - // overridden by the `override_num_workers` config value, if specified. - numWorkers: 2, + // The maximum number tasks of this type that can be run concurrently per Kibana instance. + // Setting this value will force Task Manager to poll for this task type seperatly from other task types which + // can add significant load to the ES cluster, so please use this configuration only when absolutly necesery. + maxConcurrency: 1, // The createTaskRunner function / method returns an object that is responsible for // performing the work of the task. context: { taskInstance }, is documented below. diff --git a/x-pack/plugins/task_manager/server/buffered_task_store.test.ts b/x-pack/plugins/task_manager/server/buffered_task_store.test.ts index 70d24b235d880..45607713a3128 100644 --- a/x-pack/plugins/task_manager/server/buffered_task_store.test.ts +++ b/x-pack/plugins/task_manager/server/buffered_task_store.test.ts @@ -13,19 +13,17 @@ import { TaskStatus } from './task'; describe('Buffered Task Store', () => { test('proxies the TaskStore for `maxAttempts` and `remove`', async () => { - const taskStore = taskStoreMock.create({ maxAttempts: 10 }); + const taskStore = taskStoreMock.create(); taskStore.bulkUpdate.mockResolvedValue([]); const bufferedStore = new BufferedTaskStore(taskStore, {}); - expect(bufferedStore.maxAttempts).toEqual(10); - bufferedStore.remove('1'); expect(taskStore.remove).toHaveBeenCalledWith('1'); }); describe('update', () => { test("proxies the TaskStore's `bulkUpdate`", async () => { - const taskStore = taskStoreMock.create({ maxAttempts: 10 }); + const taskStore = taskStoreMock.create(); const bufferedStore = new BufferedTaskStore(taskStore, {}); const task = mockTask(); @@ -37,7 +35,7 @@ describe('Buffered Task Store', () => { }); test('handles partially successfull bulkUpdates resolving each call appropriately', async () => { - const taskStore = taskStoreMock.create({ maxAttempts: 10 }); + const taskStore = taskStoreMock.create(); const bufferedStore = new BufferedTaskStore(taskStore, {}); const tasks = [mockTask(), mockTask(), mockTask()]; @@ -61,7 +59,7 @@ describe('Buffered Task Store', () => { }); test('handles multiple items with the same id', async () => { - const taskStore = taskStoreMock.create({ maxAttempts: 10 }); + const taskStore = taskStoreMock.create(); const bufferedStore = new BufferedTaskStore(taskStore, {}); const duplicateIdTask = mockTask(); diff --git a/x-pack/plugins/task_manager/server/buffered_task_store.ts b/x-pack/plugins/task_manager/server/buffered_task_store.ts index 4e4a533303867..ca735dd6f3638 100644 --- a/x-pack/plugins/task_manager/server/buffered_task_store.ts +++ b/x-pack/plugins/task_manager/server/buffered_task_store.ts @@ -26,10 +26,6 @@ export class BufferedTaskStore implements Updatable { ); } - public get maxAttempts(): number { - return this.taskStore.maxAttempts; - } - public async update(doc: ConcreteTaskInstance): Promise { return unwrapPromise(this.bufferedUpdate(doc)); } diff --git a/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts b/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts index 79a0d2f690042..8e0396a453b3d 100644 --- a/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts +++ b/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts @@ -10,27 +10,32 @@ import sinon from 'sinon'; import { fillPool, FillPoolResult } from './fill_pool'; import { TaskPoolRunResult } from '../task_pool'; import { asOk, Result } from './result_type'; -import { ClaimOwnershipResult } from '../task_store'; import { ConcreteTaskInstance, TaskStatus } from '../task'; import { TaskManagerRunner } from '../task_running/task_runner'; +import { from, Observable } from 'rxjs'; +import { ClaimOwnershipResult } from '../queries/task_claiming'; jest.mock('../task_running/task_runner'); describe('fillPool', () => { function mockFetchAvailableTasks( tasksToMock: number[][] - ): () => Promise> { - const tasks: ConcreteTaskInstance[][] = tasksToMock.map((ids) => mockTaskInstances(ids)); - let index = 0; - return async () => - asOk({ - stats: { - tasksUpdated: tasks[index + 1]?.length ?? 0, - tasksConflicted: 0, - tasksClaimed: 0, - }, - docs: tasks[index++] || [], - }); + ): () => Observable> { + const claimCycles: ConcreteTaskInstance[][] = tasksToMock.map((ids) => mockTaskInstances(ids)); + return () => + from( + claimCycles.map((tasks) => + asOk({ + stats: { + tasksUpdated: tasks?.length ?? 0, + tasksConflicted: 0, + tasksClaimed: 0, + tasksRejected: 0, + }, + docs: tasks, + }) + ) + ); } const mockTaskInstances = (ids: number[]): ConcreteTaskInstance[] => @@ -51,7 +56,7 @@ describe('fillPool', () => { ownerId: null, })); - test('stops filling when pool runs all claimed tasks, even if there is more capacity', async () => { + test('fills task pool with all claimed tasks until fetchAvailableTasks stream closes', async () => { const tasks = [ [1, 2, 3], [4, 5], @@ -62,21 +67,7 @@ describe('fillPool', () => { await fillPool(fetchAvailableTasks, converter, run); - expect(_.flattenDeep(run.args)).toEqual(mockTaskInstances([1, 2, 3])); - }); - - test('stops filling when the pool has no more capacity', async () => { - const tasks = [ - [1, 2, 3], - [4, 5], - ]; - const fetchAvailableTasks = mockFetchAvailableTasks(tasks); - const run = sinon.spy(async () => TaskPoolRunResult.RanOutOfCapacity); - const converter = _.identity; - - await fillPool(fetchAvailableTasks, converter, run); - - expect(_.flattenDeep(run.args)).toEqual(mockTaskInstances([1, 2, 3])); + expect(_.flattenDeep(run.args)).toEqual(mockTaskInstances([1, 2, 3, 4, 5])); }); test('calls the converter on the records prior to running', async () => { @@ -91,7 +82,7 @@ describe('fillPool', () => { await fillPool(fetchAvailableTasks, converter, run); - expect(_.flattenDeep(run.args)).toEqual(['1', '2', '3']); + expect(_.flattenDeep(run.args)).toEqual(['1', '2', '3', '4', '5']); }); describe('error handling', () => { @@ -101,7 +92,10 @@ describe('fillPool', () => { (instance.id as unknown) as TaskManagerRunner; try { - const fetchAvailableTasks = async () => Promise.reject('fetch is not working'); + const fetchAvailableTasks = () => + new Observable>((obs) => + obs.error('fetch is not working') + ); await fillPool(fetchAvailableTasks, converter, run); } catch (err) { diff --git a/x-pack/plugins/task_manager/server/lib/fill_pool.ts b/x-pack/plugins/task_manager/server/lib/fill_pool.ts index 45a33081bde51..c9050ebb75d69 100644 --- a/x-pack/plugins/task_manager/server/lib/fill_pool.ts +++ b/x-pack/plugins/task_manager/server/lib/fill_pool.ts @@ -6,12 +6,14 @@ */ import { performance } from 'perf_hooks'; +import { Observable } from 'rxjs'; +import { concatMap, last } from 'rxjs/operators'; +import { ClaimOwnershipResult } from '../queries/task_claiming'; import { ConcreteTaskInstance } from '../task'; import { WithTaskTiming, startTaskTimer } from '../task_events'; import { TaskPoolRunResult } from '../task_pool'; import { TaskManagerRunner } from '../task_running'; -import { ClaimOwnershipResult } from '../task_store'; -import { Result, map } from './result_type'; +import { Result, map as mapResult, asErr, asOk } from './result_type'; export enum FillPoolResult { Failed = 'Failed', @@ -22,6 +24,17 @@ export enum FillPoolResult { PoolFilled = 'PoolFilled', } +type FillPoolAndRunResult = Result< + { + result: TaskPoolRunResult; + stats?: ClaimOwnershipResult['stats']; + }, + { + result: FillPoolResult; + stats?: ClaimOwnershipResult['stats']; + } +>; + export type ClaimAndFillPoolResult = Partial> & { result: FillPoolResult; }; @@ -40,52 +53,81 @@ export type TimedFillPoolResult = WithTaskTiming; * @param converter - a function that converts task records to the appropriate task runner */ export async function fillPool( - fetchAvailableTasks: () => Promise>, + fetchAvailableTasks: () => Observable>, converter: (taskInstance: ConcreteTaskInstance) => TaskManagerRunner, run: (tasks: TaskManagerRunner[]) => Promise ): Promise { performance.mark('fillPool.start'); - const stopTaskTimer = startTaskTimer(); - const augmentTimingTo = ( - result: FillPoolResult, - stats?: ClaimOwnershipResult['stats'] - ): TimedFillPoolResult => ({ - result, - stats, - timing: stopTaskTimer(), - }); - return map>( - await fetchAvailableTasks(), - async ({ docs, stats }) => { - if (!docs.length) { - performance.mark('fillPool.bailNoTasks'); - performance.measure( - 'fillPool.activityDurationUntilNoTasks', - 'fillPool.start', - 'fillPool.bailNoTasks' - ); - return augmentTimingTo(FillPoolResult.NoTasksClaimed, stats); - } - - const tasks = docs.map(converter); - - switch (await run(tasks)) { - case TaskPoolRunResult.RanOutOfCapacity: - performance.mark('fillPool.bailExhaustedCapacity'); - performance.measure( - 'fillPool.activityDurationUntilExhaustedCapacity', - 'fillPool.start', - 'fillPool.bailExhaustedCapacity' + return new Promise((resolve, reject) => { + const stopTaskTimer = startTaskTimer(); + const augmentTimingTo = ( + result: FillPoolResult, + stats?: ClaimOwnershipResult['stats'] + ): TimedFillPoolResult => ({ + result, + stats, + timing: stopTaskTimer(), + }); + fetchAvailableTasks() + .pipe( + // each ClaimOwnershipResult will be sequencially consumed an ran using the `run` handler + concatMap(async (res) => + mapResult>( + res, + async ({ docs, stats }) => { + if (!docs.length) { + performance.mark('fillPool.bailNoTasks'); + performance.measure( + 'fillPool.activityDurationUntilNoTasks', + 'fillPool.start', + 'fillPool.bailNoTasks' + ); + return asOk({ result: TaskPoolRunResult.NoTaskWereRan, stats }); + } + return asOk( + await run(docs.map(converter)).then((runResult) => ({ + result: runResult, + stats, + })) + ); + }, + async (fillPoolResult) => asErr({ result: fillPoolResult }) + ) + ), + // when the final call to `run` completes, we'll complete the stream and emit the + // final accumulated result + last() + ) + .subscribe( + (claimResults) => { + resolve( + mapResult( + claimResults, + ({ result, stats }) => { + switch (result) { + case TaskPoolRunResult.RanOutOfCapacity: + performance.mark('fillPool.bailExhaustedCapacity'); + performance.measure( + 'fillPool.activityDurationUntilExhaustedCapacity', + 'fillPool.start', + 'fillPool.bailExhaustedCapacity' + ); + return augmentTimingTo(FillPoolResult.RanOutOfCapacity, stats); + case TaskPoolRunResult.RunningAtCapacity: + performance.mark('fillPool.cycle'); + return augmentTimingTo(FillPoolResult.RunningAtCapacity, stats); + case TaskPoolRunResult.NoTaskWereRan: + return augmentTimingTo(FillPoolResult.NoTasksClaimed, stats); + default: + performance.mark('fillPool.cycle'); + return augmentTimingTo(FillPoolResult.PoolFilled, stats); + } + }, + ({ result, stats }) => augmentTimingTo(result, stats) + ) ); - return augmentTimingTo(FillPoolResult.RanOutOfCapacity, stats); - case TaskPoolRunResult.RunningAtCapacity: - performance.mark('fillPool.cycle'); - return augmentTimingTo(FillPoolResult.RunningAtCapacity, stats); - default: - performance.mark('fillPool.cycle'); - return augmentTimingTo(FillPoolResult.PoolFilled, stats); - } - }, - async (result) => augmentTimingTo(result) - ); + }, + (err) => reject(err) + ); + }); } diff --git a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts index 5c32c3e7225c4..7040d5acd4eaf 100644 --- a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts @@ -537,6 +537,7 @@ describe('Task Run Statistics', () => { asTaskPollingCycleEvent(asOk({ result: FillPoolResult.NoTasksClaimed, timing })) ); events$.next(asTaskManagerStatEvent('pollingDelay', asOk(0))); + events$.next(asTaskManagerStatEvent('claimDuration', asOk(10))); events$.next( asTaskPollingCycleEvent(asOk({ result: FillPoolResult.NoTasksClaimed, timing })) ); diff --git a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts index 4b7bdf595f1f5..3185d3c449c32 100644 --- a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts +++ b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts @@ -19,6 +19,7 @@ import { RanTask, TaskTiming, isTaskManagerStatEvent, + TaskManagerStat, } from '../task_events'; import { isOk, Ok, unwrap } from '../lib/result_type'; import { ConcreteTaskInstance } from '../task'; @@ -39,6 +40,7 @@ interface FillPoolStat extends JsonObject { last_successful_poll: string; last_polling_delay: string; duration: number[]; + claim_duration: number[]; claim_conflicts: number[]; claim_mismatches: number[]; result_frequency_percent_as_number: FillPoolResult[]; @@ -51,6 +53,7 @@ interface ExecutionStat extends JsonObject { export interface TaskRunStat extends JsonObject { drift: number[]; + drift_by_type: Record; load: number[]; execution: ExecutionStat; polling: Omit & @@ -125,6 +128,7 @@ export function createTaskRunAggregator( const resultFrequencyQueue = createRunningAveragedStat(runningAverageWindowSize); const pollingDurationQueue = createRunningAveragedStat(runningAverageWindowSize); + const claimDurationQueue = createRunningAveragedStat(runningAverageWindowSize); const claimConflictsQueue = createRunningAveragedStat(runningAverageWindowSize); const claimMismatchesQueue = createRunningAveragedStat(runningAverageWindowSize); const taskPollingEvents$: Observable> = combineLatest([ @@ -168,10 +172,26 @@ export function createTaskRunAggregator( ), map(() => new Date().toISOString()) ), + // get duration of task claim stage in polling + taskPollingLifecycle.events.pipe( + filter( + (taskEvent: TaskLifecycleEvent) => + isTaskManagerStatEvent(taskEvent) && + taskEvent.id === 'claimDuration' && + isOk(taskEvent.event) + ), + map((claimDurationEvent) => { + const duration = ((claimDurationEvent as TaskManagerStat).event as Ok).value; + return { + claimDuration: duration ? claimDurationQueue(duration) : claimDurationQueue(), + }; + }) + ), ]).pipe( - map(([{ polling }, pollingDelay]) => ({ + map(([{ polling }, pollingDelay, { claimDuration }]) => ({ polling: { last_polling_delay: pollingDelay, + claim_duration: claimDuration, ...polling, }, })) @@ -179,13 +199,18 @@ export function createTaskRunAggregator( return combineLatest([ taskRunEvents$.pipe( - startWith({ drift: [], execution: { duration: {}, result_frequency_percent_as_number: {} } }) + startWith({ + drift: [], + drift_by_type: {}, + execution: { duration: {}, result_frequency_percent_as_number: {} }, + }) ), taskManagerLoadStatEvents$.pipe(startWith({ load: [] })), taskPollingEvents$.pipe( startWith({ polling: { duration: [], + claim_duration: [], claim_conflicts: [], claim_mismatches: [], result_frequency_percent_as_number: [], @@ -218,6 +243,7 @@ function hasTiming(taskEvent: TaskLifecycleEvent) { function createTaskRunEventToStat(runningAverageWindowSize: number) { const driftQueue = createRunningAveragedStat(runningAverageWindowSize); + const driftByTaskQueue = createMapOfRunningAveragedStats(runningAverageWindowSize); const taskRunDurationQueue = createMapOfRunningAveragedStats(runningAverageWindowSize); const resultFrequencyQueue = createMapOfRunningAveragedStats( runningAverageWindowSize @@ -226,13 +252,17 @@ function createTaskRunEventToStat(runningAverageWindowSize: number) { task: ConcreteTaskInstance, timing: TaskTiming, result: TaskRunResult - ): Omit => ({ - drift: driftQueue(timing!.start - task.runAt.getTime()), - execution: { - duration: taskRunDurationQueue(task.taskType, timing!.stop - timing!.start), - result_frequency_percent_as_number: resultFrequencyQueue(task.taskType, result), - }, - }); + ): Omit => { + const drift = timing!.start - task.runAt.getTime(); + return { + drift: driftQueue(drift), + drift_by_type: driftByTaskQueue(task.taskType, drift), + execution: { + duration: taskRunDurationQueue(task.taskType, timing!.stop - timing!.start), + result_frequency_percent_as_number: resultFrequencyQueue(task.taskType, result), + }, + }; + }; } const DEFAULT_TASK_RUN_FREQUENCIES = { @@ -258,11 +288,15 @@ export function summarizeTaskRunStat( // eslint-disable-next-line @typescript-eslint/naming-convention last_polling_delay, duration: pollingDuration, + // eslint-disable-next-line @typescript-eslint/naming-convention + claim_duration, result_frequency_percent_as_number: pollingResultFrequency, claim_conflicts: claimConflicts, claim_mismatches: claimMismatches, }, drift, + // eslint-disable-next-line @typescript-eslint/naming-convention + drift_by_type, load, execution: { duration, result_frequency_percent_as_number: executionResultFrequency }, }: TaskRunStat, @@ -273,6 +307,9 @@ export function summarizeTaskRunStat( polling: { ...(last_successful_poll ? { last_successful_poll } : {}), ...(last_polling_delay ? { last_polling_delay } : {}), + ...(claim_duration + ? { claim_duration: calculateRunningAverage(claim_duration as number[]) } + : {}), duration: calculateRunningAverage(pollingDuration as number[]), claim_conflicts: calculateRunningAverage(claimConflicts as number[]), claim_mismatches: calculateRunningAverage(claimMismatches as number[]), @@ -282,6 +319,7 @@ export function summarizeTaskRunStat( }, }, drift: calculateRunningAverage(drift), + drift_by_type: mapValues(drift_by_type, (typedDrift) => calculateRunningAverage(typedDrift)), load: calculateRunningAverage(load), execution: { duration: mapValues(duration, (typedDurations) => calculateRunningAverage(typedDurations)), diff --git a/x-pack/plugins/task_manager/server/plugin.test.ts b/x-pack/plugins/task_manager/server/plugin.test.ts index 0a879ce92cba6..45db18a3e8385 100644 --- a/x-pack/plugins/task_manager/server/plugin.test.ts +++ b/x-pack/plugins/task_manager/server/plugin.test.ts @@ -70,6 +70,15 @@ describe('TaskManagerPlugin', () => { const setupApi = await taskManagerPlugin.setup(coreMock.createSetup()); + // we only start a poller if we have task types that we support and we track + // phases (moving from Setup to Start) based on whether the poller is working + setupApi.registerTaskDefinitions({ + setupTimeType: { + title: 'setupTimeType', + createTaskRunner: () => ({ async run() {} }), + }, + }); + await taskManagerPlugin.start(coreMock.createStart()); expect(() => diff --git a/x-pack/plugins/task_manager/server/plugin.ts b/x-pack/plugins/task_manager/server/plugin.ts index 149d111b08f02..507a021214a90 100644 --- a/x-pack/plugins/task_manager/server/plugin.ts +++ b/x-pack/plugins/task_manager/server/plugin.ts @@ -16,13 +16,12 @@ import { ServiceStatusLevels, CoreStatus, } from '../../../../src/core/server'; -import { TaskDefinition } from './task'; import { TaskPollingLifecycle } from './polling_lifecycle'; import { TaskManagerConfig } from './config'; import { createInitialMiddleware, addMiddlewareToChain, Middleware } from './lib/middleware'; import { removeIfExists } from './lib/remove_if_exists'; import { setupSavedObjects } from './saved_objects'; -import { TaskTypeDictionary } from './task_type_dictionary'; +import { TaskDefinitionRegistry, TaskTypeDictionary } from './task_type_dictionary'; import { FetchResult, SearchOpts, TaskStore } from './task_store'; import { createManagedConfiguration } from './lib/create_managed_configuration'; import { TaskScheduling } from './task_scheduling'; @@ -100,7 +99,7 @@ export class TaskManagerPlugin this.assertStillInSetup('add Middleware'); this.middleware = addMiddlewareToChain(this.middleware, middleware); }, - registerTaskDefinitions: (taskDefinition: Record) => { + registerTaskDefinitions: (taskDefinition: TaskDefinitionRegistry) => { this.assertStillInSetup('register task definitions'); this.definitions.registerTaskDefinitions(taskDefinition); }, @@ -110,12 +109,12 @@ export class TaskManagerPlugin public start({ savedObjects, elasticsearch }: CoreStart): TaskManagerStartContract { const savedObjectsRepository = savedObjects.createInternalRepository(['task']); + const serializer = savedObjects.createSerializer(); const taskStore = new TaskStore({ - serializer: savedObjects.createSerializer(), + serializer, savedObjectsRepository, esClient: elasticsearch.createClient('taskManager').asInternalUser, index: this.config!.index, - maxAttempts: this.config!.max_attempts, definitions: this.definitions, taskManagerId: `kibana:${this.taskManagerId!}`, }); @@ -151,6 +150,7 @@ export class TaskManagerPlugin taskStore, middleware: this.middleware, taskPollingLifecycle: this.taskPollingLifecycle, + definitions: this.definitions, }); return { diff --git a/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.test.ts b/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.test.ts index d4617d6549d60..f3af6f50336ea 100644 --- a/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.test.ts +++ b/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.test.ts @@ -64,6 +64,7 @@ describe('delayOnClaimConflicts', () => { tasksUpdated: 0, tasksConflicted: 8, tasksClaimed: 0, + tasksRejected: 0, }, docs: [], }) @@ -79,6 +80,63 @@ describe('delayOnClaimConflicts', () => { }) ); + test( + 'emits delay only once, no mater how many subscribers there are', + fakeSchedulers(async () => { + const taskLifecycleEvents$ = new Subject(); + + const delays$ = delayOnClaimConflicts(of(10), of(100), taskLifecycleEvents$, 80, 2); + + const firstSubscriber$ = delays$.pipe(take(2), bufferCount(2)).toPromise(); + const secondSubscriber$ = delays$.pipe(take(2), bufferCount(2)).toPromise(); + + taskLifecycleEvents$.next( + asTaskPollingCycleEvent( + asOk({ + result: FillPoolResult.PoolFilled, + stats: { + tasksUpdated: 0, + tasksConflicted: 8, + tasksClaimed: 0, + tasksRejected: 0, + }, + docs: [], + }) + ) + ); + + const thirdSubscriber$ = delays$.pipe(take(2), bufferCount(2)).toPromise(); + + taskLifecycleEvents$.next( + asTaskPollingCycleEvent( + asOk({ + result: FillPoolResult.PoolFilled, + stats: { + tasksUpdated: 0, + tasksConflicted: 10, + tasksClaimed: 0, + tasksRejected: 0, + }, + docs: [], + }) + ) + ); + + // should get the initial value of 0 delay + const [initialDelay, firstRandom] = await firstSubscriber$; + // should get the 0 delay (as a replay), which was the last value plus the first random value + const [initialDelayInSecondSub, firstRandomInSecondSub] = await secondSubscriber$; + // should get the first random value (as a replay) and the next random value + const [firstRandomInThirdSub, secondRandomInThirdSub] = await thirdSubscriber$; + + expect(initialDelay).toEqual(0); + expect(initialDelayInSecondSub).toEqual(0); + expect(firstRandom).toEqual(firstRandomInSecondSub); + expect(firstRandomInSecondSub).toEqual(firstRandomInThirdSub); + expect(secondRandomInThirdSub).toBeGreaterThanOrEqual(0); + }) + ); + test( 'doesnt emit a new delay when conflicts have reduced', fakeSchedulers(async () => { @@ -107,6 +165,7 @@ describe('delayOnClaimConflicts', () => { tasksUpdated: 0, tasksConflicted: 8, tasksClaimed: 0, + tasksRejected: 0, }, docs: [], }) @@ -127,6 +186,7 @@ describe('delayOnClaimConflicts', () => { tasksUpdated: 0, tasksConflicted: 7, tasksClaimed: 0, + tasksRejected: 0, }, docs: [], }) @@ -145,6 +205,7 @@ describe('delayOnClaimConflicts', () => { tasksUpdated: 0, tasksConflicted: 9, tasksClaimed: 0, + tasksRejected: 0, }, docs: [], }) diff --git a/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.ts b/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.ts index 73e7052b65a69..6d7cb77625b58 100644 --- a/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.ts +++ b/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.ts @@ -11,7 +11,7 @@ import stats from 'stats-lite'; import { isNumber, random } from 'lodash'; -import { merge, of, Observable, combineLatest } from 'rxjs'; +import { merge, of, Observable, combineLatest, ReplaySubject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import { Option, none, some, isSome, Some } from 'fp-ts/lib/Option'; import { isOk } from '../lib/result_type'; @@ -32,7 +32,9 @@ export function delayOnClaimConflicts( runningAverageWindowSize: number ): Observable { const claimConflictQueue = createRunningAveragedStat(runningAverageWindowSize); - return merge( + // return a subject to allow multicast and replay the last value to new subscribers + const multiCastDelays$ = new ReplaySubject(1); + merge( of(0), combineLatest([ maxWorkersConfiguration$, @@ -70,5 +72,9 @@ export function delayOnClaimConflicts( return random(pollInterval * 0.25, pollInterval * 0.75, false); }) ) - ); + ).subscribe((delay) => { + multiCastDelays$.next(delay); + }); + + return multiCastDelays$; } diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts index 9f79445070237..63d7f6de81801 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts @@ -7,17 +7,30 @@ import _ from 'lodash'; import sinon from 'sinon'; -import { of, Subject } from 'rxjs'; +import { Observable, of, Subject } from 'rxjs'; import { TaskPollingLifecycle, claimAvailableTasks } from './polling_lifecycle'; import { createInitialMiddleware } from './lib/middleware'; import { TaskTypeDictionary } from './task_type_dictionary'; import { taskStoreMock } from './task_store.mock'; import { mockLogger } from './test_utils'; +import { taskClaimingMock } from './queries/task_claiming.mock'; +import { TaskClaiming, ClaimOwnershipResult } from './queries/task_claiming'; +import type { TaskClaiming as TaskClaimingClass } from './queries/task_claiming'; +import { asOk, Err, isErr, isOk, Result } from './lib/result_type'; +import { FillPoolResult } from './lib/fill_pool'; + +let mockTaskClaiming = taskClaimingMock.create({}); +jest.mock('./queries/task_claiming', () => { + return { + TaskClaiming: jest.fn().mockImplementation(() => { + return mockTaskClaiming; + }), + }; +}); describe('TaskPollingLifecycle', () => { let clock: sinon.SinonFakeTimers; - const taskManagerLogger = mockLogger(); const mockTaskStore = taskStoreMock.create({}); const taskManagerOpts = { @@ -50,8 +63,9 @@ describe('TaskPollingLifecycle', () => { }; beforeEach(() => { + mockTaskClaiming = taskClaimingMock.create({}); + (TaskClaiming as jest.Mock).mockClear(); clock = sinon.useFakeTimers(); - taskManagerOpts.definitions = new TaskTypeDictionary(taskManagerLogger); }); afterEach(() => clock.restore()); @@ -60,17 +74,58 @@ describe('TaskPollingLifecycle', () => { test('begins polling once the ES and SavedObjects services are available', () => { const elasticsearchAndSOAvailability$ = new Subject(); new TaskPollingLifecycle({ - elasticsearchAndSOAvailability$, ...taskManagerOpts, + elasticsearchAndSOAvailability$, }); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).not.toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).not.toHaveBeenCalled(); elasticsearchAndSOAvailability$.next(true); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).toHaveBeenCalled(); + }); + + test('provides TaskClaiming with the capacity available', () => { + const elasticsearchAndSOAvailability$ = new Subject(); + const maxWorkers$ = new Subject(); + taskManagerOpts.definitions.registerTaskDefinitions({ + report: { + title: 'report', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + quickReport: { + title: 'quickReport', + maxConcurrency: 5, + createTaskRunner: jest.fn(), + }, + }); + + new TaskPollingLifecycle({ + ...taskManagerOpts, + elasticsearchAndSOAvailability$, + maxWorkersConfiguration$: maxWorkers$, + }); + + const taskClaimingGetCapacity = (TaskClaiming as jest.Mock).mock + .calls[0][0].getCapacity; + + maxWorkers$.next(20); + expect(taskClaimingGetCapacity()).toEqual(20); + expect(taskClaimingGetCapacity('report')).toEqual(1); + expect(taskClaimingGetCapacity('quickReport')).toEqual(5); + + maxWorkers$.next(30); + expect(taskClaimingGetCapacity()).toEqual(30); + expect(taskClaimingGetCapacity('report')).toEqual(1); + expect(taskClaimingGetCapacity('quickReport')).toEqual(5); + + maxWorkers$.next(2); + expect(taskClaimingGetCapacity()).toEqual(2); + expect(taskClaimingGetCapacity('report')).toEqual(1); + expect(taskClaimingGetCapacity('quickReport')).toEqual(2); }); }); @@ -85,13 +140,13 @@ describe('TaskPollingLifecycle', () => { elasticsearchAndSOAvailability$.next(true); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).toHaveBeenCalled(); elasticsearchAndSOAvailability$.next(false); - mockTaskStore.claimAvailableTasks.mockClear(); + mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable.mockClear(); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).not.toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).not.toHaveBeenCalled(); }); test('restarts polling once the ES and SavedObjects services become available again', () => { @@ -104,68 +159,64 @@ describe('TaskPollingLifecycle', () => { elasticsearchAndSOAvailability$.next(true); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).toHaveBeenCalled(); elasticsearchAndSOAvailability$.next(false); - mockTaskStore.claimAvailableTasks.mockClear(); + mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable.mockClear(); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).not.toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).not.toHaveBeenCalled(); elasticsearchAndSOAvailability$.next(true); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).toHaveBeenCalled(); }); }); describe('claimAvailableTasks', () => { - test('should claim Available Tasks when there are available workers', () => { - const logger = mockLogger(); - const claim = jest.fn(() => - Promise.resolve({ - docs: [], - stats: { tasksUpdated: 0, tasksConflicted: 0, tasksClaimed: 0 }, - }) - ); - - const availableWorkers = 1; - - claimAvailableTasks([], claim, availableWorkers, logger); - - expect(claim).toHaveBeenCalledTimes(1); - }); - - test('should not claim Available Tasks when there are no available workers', () => { + test('should claim Available Tasks when there are available workers', async () => { const logger = mockLogger(); - const claim = jest.fn(() => - Promise.resolve({ - docs: [], - stats: { tasksUpdated: 0, tasksConflicted: 0, tasksClaimed: 0 }, - }) + const taskClaiming = taskClaimingMock.create({}); + taskClaiming.claimAvailableTasksIfCapacityIsAvailable.mockImplementation(() => + of( + asOk({ + docs: [], + stats: { tasksUpdated: 0, tasksConflicted: 0, tasksClaimed: 0, tasksRejected: 0 }, + }) + ) ); - const availableWorkers = 0; + expect( + isOk(await getFirstAsPromise(claimAvailableTasks([], taskClaiming, logger))) + ).toBeTruthy(); - claimAvailableTasks([], claim, availableWorkers, logger); - - expect(claim).not.toHaveBeenCalled(); + expect(taskClaiming.claimAvailableTasksIfCapacityIsAvailable).toHaveBeenCalledTimes(1); }); /** * This handles the case in which Elasticsearch has had inline script disabled. * This is achieved by setting the `script.allowed_types` flag on Elasticsearch to `none` */ - test('handles failure due to inline scripts being disabled', () => { + test('handles failure due to inline scripts being disabled', async () => { const logger = mockLogger(); - const claim = jest.fn(() => { - throw Object.assign(new Error(), { - response: - '{"error":{"root_cause":[{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}],"type":"search_phase_execution_exception","reason":"all shards failed","phase":"query","grouped":true,"failed_shards":[{"shard":0,"index":".kibana_task_manager_1","node":"24A4QbjHSK6prvtopAKLKw","reason":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}}],"caused_by":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts","caused_by":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}}},"status":400}', - }); - }); + const taskClaiming = taskClaimingMock.create({}); + taskClaiming.claimAvailableTasksIfCapacityIsAvailable.mockImplementation( + () => + new Observable>((observer) => { + observer.error( + Object.assign(new Error(), { + response: + '{"error":{"root_cause":[{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}],"type":"search_phase_execution_exception","reason":"all shards failed","phase":"query","grouped":true,"failed_shards":[{"shard":0,"index":".kibana_task_manager_1","node":"24A4QbjHSK6prvtopAKLKw","reason":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}}],"caused_by":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts","caused_by":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}}},"status":400}', + }) + ); + }) + ); + + const err = await getFirstAsPromise(claimAvailableTasks([], taskClaiming, logger)); - claimAvailableTasks([], claim, 10, logger); + expect(isErr(err)).toBeTruthy(); + expect((err as Err).error).toEqual(FillPoolResult.Failed); expect(logger.warn).toHaveBeenCalledTimes(1); expect(logger.warn).toHaveBeenCalledWith( @@ -174,3 +225,9 @@ describe('TaskPollingLifecycle', () => { }); }); }); + +function getFirstAsPromise(obs$: Observable): Promise { + return new Promise((resolve, reject) => { + obs$.subscribe(resolve, reject); + }); +} diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.ts index db8eeaaf78dee..260f5ccc70f53 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.ts @@ -6,15 +6,12 @@ */ import { Subject, Observable, Subscription } from 'rxjs'; - -import { performance } from 'perf_hooks'; - import { pipe } from 'fp-ts/lib/pipeable'; import { Option, some, map as mapOptional } from 'fp-ts/lib/Option'; import { tap } from 'rxjs/operators'; import { Logger } from '../../../../src/core/server'; -import { Result, asErr, mapErr, asOk, map } from './lib/result_type'; +import { Result, asErr, mapErr, asOk, map, mapOk } from './lib/result_type'; import { ManagedConfiguration } from './lib/create_managed_configuration'; import { TaskManagerConfig } from './config'; @@ -41,11 +38,12 @@ import { } from './polling'; import { TaskPool } from './task_pool'; import { TaskManagerRunner, TaskRunner } from './task_running'; -import { TaskStore, OwnershipClaimingOpts, ClaimOwnershipResult } from './task_store'; +import { TaskStore } from './task_store'; import { identifyEsError } from './lib/identify_es_error'; import { BufferedTaskStore } from './buffered_task_store'; import { TaskTypeDictionary } from './task_type_dictionary'; import { delayOnClaimConflicts } from './polling'; +import { TaskClaiming, ClaimOwnershipResult } from './queries/task_claiming'; export type TaskPollingLifecycleOpts = { logger: Logger; @@ -71,6 +69,7 @@ export class TaskPollingLifecycle { private definitions: TaskTypeDictionary; private store: TaskStore; + private taskClaiming: TaskClaiming; private bufferedStore: BufferedTaskStore; private logger: Logger; @@ -106,8 +105,6 @@ export class TaskPollingLifecycle { this.store = taskStore; const emitEvent = (event: TaskLifecycleEvent) => this.events$.next(event); - // pipe store events into the lifecycle event stream - this.store.events.subscribe(emitEvent); this.bufferedStore = new BufferedTaskStore(this.store, { bufferMaxOperations: config.max_workers, @@ -120,6 +117,26 @@ export class TaskPollingLifecycle { }); this.pool.load.subscribe(emitEvent); + this.taskClaiming = new TaskClaiming({ + taskStore, + maxAttempts: config.max_attempts, + definitions, + logger: this.logger, + getCapacity: (taskType?: string) => + taskType && this.definitions.get(taskType)?.maxConcurrency + ? Math.max( + Math.min( + this.pool.availableWorkers, + this.definitions.get(taskType)!.maxConcurrency! - + this.pool.getOccupiedWorkersByType(taskType) + ), + 0 + ) + : this.pool.availableWorkers, + }); + // pipe taskClaiming events into the lifecycle event stream + this.taskClaiming.events.subscribe(emitEvent); + const { max_poll_inactivity_cycles: maxPollInactivityCycles, poll_interval: pollInterval, @@ -199,6 +216,7 @@ export class TaskPollingLifecycle { beforeRun: this.middleware.beforeRun, beforeMarkRunning: this.middleware.beforeMarkRunning, onTaskEvent: this.emitEvent, + defaultMaxAttempts: this.taskClaiming.maxAttempts, }); }; @@ -212,9 +230,18 @@ export class TaskPollingLifecycle { () => claimAvailableTasks( tasksToClaim.splice(0, this.pool.availableWorkers), - this.store.claimAvailableTasks, - this.pool.availableWorkers, + this.taskClaiming, this.logger + ).pipe( + tap( + mapOk(({ timing }: ClaimOwnershipResult) => { + if (timing) { + this.emitEvent( + asTaskManagerStatEvent('claimDuration', asOk(timing.stop - timing.start)) + ); + } + }) + ) ), // wrap each task in a Task Runner this.createTaskRunnerForTask, @@ -252,59 +279,40 @@ export class TaskPollingLifecycle { } } -export async function claimAvailableTasks( +export function claimAvailableTasks( claimTasksById: string[], - claim: (opts: OwnershipClaimingOpts) => Promise, - availableWorkers: number, + taskClaiming: TaskClaiming, logger: Logger -): Promise> { - if (availableWorkers > 0) { - performance.mark('claimAvailableTasks_start'); - - try { - const claimResult = await claim({ - size: availableWorkers, +): Observable> { + return new Observable((observer) => { + taskClaiming + .claimAvailableTasksIfCapacityIsAvailable({ claimOwnershipUntil: intervalFromNow('30s')!, claimTasksById, - }); - const { - docs, - stats: { tasksClaimed }, - } = claimResult; - - if (tasksClaimed === 0) { - performance.mark('claimAvailableTasks.noTasks'); - } - performance.mark('claimAvailableTasks_stop'); - performance.measure( - 'claimAvailableTasks', - 'claimAvailableTasks_start', - 'claimAvailableTasks_stop' + }) + .subscribe( + (claimResult) => { + observer.next(claimResult); + }, + (ex) => { + // if the `taskClaiming` stream errors out we want to catch it and see if + // we can identify the reason + // if we can - we emit an FillPoolResult error rather than erroring out the wrapping Observable + // returned by `claimAvailableTasks` + if (identifyEsError(ex).includes('cannot execute [inline] scripts')) { + logger.warn( + `Task Manager cannot operate when inline scripts are disabled in Elasticsearch` + ); + observer.next(asErr(FillPoolResult.Failed)); + observer.complete(); + } else { + // as we could't identify the reason - we'll error out the wrapping Observable too + observer.error(ex); + } + }, + () => { + observer.complete(); + } ); - - if (docs.length !== tasksClaimed) { - logger.warn( - `[Task Ownership error]: ${tasksClaimed} tasks were claimed by Kibana, but ${ - docs.length - } task(s) were fetched (${docs.map((doc) => doc.id).join(', ')})` - ); - } - return asOk(claimResult); - } catch (ex) { - if (identifyEsError(ex).includes('cannot execute [inline] scripts')) { - logger.warn( - `Task Manager cannot operate when inline scripts are disabled in Elasticsearch` - ); - return asErr(FillPoolResult.Failed); - } else { - throw ex; - } - } - } else { - performance.mark('claimAvailableTasks.noAvailableWorkers'); - logger.debug( - `[Task Ownership]: Task Manager has skipped Claiming Ownership of available tasks at it has ran out Available Workers.` - ); - return asErr(FillPoolResult.NoAvailableWorkers); - } + }); } diff --git a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts index 75b9b2cdfa977..57a4ab320367d 100644 --- a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts +++ b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts @@ -52,6 +52,7 @@ describe('mark_available_tasks_as_claimed', () => { fieldUpdates, claimTasksById || [], definitions.getAllTypes(), + [], Array.from(definitions).reduce((accumulator, [type, { maxAttempts }]) => { return { ...accumulator, [type]: maxAttempts || defaultMaxAttempts }; }, {}) @@ -116,18 +117,23 @@ if (doc['task.runAt'].size()!=0) { seq_no_primary_term: true, script: { source: ` - if (params.registeredTaskTypes.contains(ctx._source.task.taskType)) { - if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { + if (params.claimableTaskTypes.contains(ctx._source.task.taskType)) { + if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { + ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) + .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) + .join(' ')} + } else { + ctx._source.task.status = "failed"; + } + } else if (params.skippedTaskTypes.contains(ctx._source.task.taskType) && params.claimTasksById.contains(ctx._id)) { ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) .join(' ')} + } else if (!params.skippedTaskTypes.contains(ctx._source.task.taskType)) { + ctx._source.task.status = "unrecognized"; } else { - ctx._source.task.status = "failed"; - } - } else { - ctx._source.task.status = "unrecognized"; - } - `, + ctx.op = "noop"; + }`, lang: 'painless', params: { fieldUpdates: { @@ -135,7 +141,8 @@ if (doc['task.runAt'].size()!=0) { retryAt: claimOwnershipUntil, }, claimTasksById: [], - registeredTaskTypes: ['sampleTask', 'otherTask'], + claimableTaskTypes: ['sampleTask', 'otherTask'], + skippedTaskTypes: [], taskMaxAttempts: { sampleTask: 5, otherTask: 1, @@ -144,4 +151,76 @@ if (doc['task.runAt'].size()!=0) { }, }); }); + + describe(`script`, () => { + test('it supports claiming specific tasks by id', async () => { + const taskManagerId = '3478fg6-82374f6-83467gf5-384g6f'; + const claimOwnershipUntil = '2019-02-12T21:01:22.479Z'; + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: claimOwnershipUntil, + }; + + const claimTasksById = [ + '33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ]; + + expect( + updateFieldsAndMarkAsFailed(fieldUpdates, claimTasksById, ['foo', 'bar'], [], { + foo: 5, + bar: 2, + }) + ).toMatchObject({ + source: ` + if (params.claimableTaskTypes.contains(ctx._source.task.taskType)) { + if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { + ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) + .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) + .join(' ')} + } else { + ctx._source.task.status = "failed"; + } + } else if (params.skippedTaskTypes.contains(ctx._source.task.taskType) && params.claimTasksById.contains(ctx._id)) { + ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) + .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) + .join(' ')} + } else if (!params.skippedTaskTypes.contains(ctx._source.task.taskType)) { + ctx._source.task.status = "unrecognized"; + } else { + ctx.op = "noop"; + }`, + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [ + '33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + claimableTaskTypes: ['foo', 'bar'], + skippedTaskTypes: [], + taskMaxAttempts: { + foo: 5, + bar: 2, + }, + }, + }); + }); + + test('it marks the update as a noop if the type is skipped', async () => { + const taskManagerId = '3478fg6-82374f6-83467gf5-384g6f'; + const claimOwnershipUntil = '2019-02-12T21:01:22.479Z'; + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: claimOwnershipUntil, + }; + + expect( + updateFieldsAndMarkAsFailed(fieldUpdates, [], ['foo', 'bar'], [], { + foo: 5, + bar: 2, + }).source + ).toMatch(/ctx.op = "noop"/); + }); + }); }); diff --git a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts index 067de5a92adb7..8598980a4e236 100644 --- a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts +++ b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts @@ -14,6 +14,8 @@ import { mustBeAllOf, MustCondition, BoolClauseWithAnyCondition, + ShouldCondition, + FilterCondition, } from './query_clauses'; export const TaskWithSchedule: ExistsFilter = { @@ -39,14 +41,26 @@ export function taskWithLessThanMaxAttempts( }; } -export function tasksClaimedByOwner(taskManagerId: string) { +export function tasksOfType(taskTypes: string[]): ShouldCondition { + return { + bool: { + should: [...taskTypes].map((type) => ({ term: { 'task.taskType': type } })), + }, + }; +} + +export function tasksClaimedByOwner( + taskManagerId: string, + ...taskFilters: Array | ShouldCondition> +) { return mustBeAllOf( { term: { 'task.ownerId': taskManagerId, }, }, - { term: { 'task.status': 'claiming' } } + { term: { 'task.status': 'claiming' } }, + ...taskFilters ); } @@ -107,27 +121,35 @@ export const updateFieldsAndMarkAsFailed = ( [field: string]: string | number | Date; }, claimTasksById: string[], - registeredTaskTypes: string[], + claimableTaskTypes: string[], + skippedTaskTypes: string[], taskMaxAttempts: { [field: string]: number } -): ScriptClause => ({ - source: ` - if (params.registeredTaskTypes.contains(ctx._source.task.taskType)) { - if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { - ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) - .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) - .join(' ')} +): ScriptClause => { + const markAsClaimingScript = `ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) + .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) + .join(' ')}`; + return { + source: ` + if (params.claimableTaskTypes.contains(ctx._source.task.taskType)) { + if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { + ${markAsClaimingScript} + } else { + ctx._source.task.status = "failed"; + } + } else if (params.skippedTaskTypes.contains(ctx._source.task.taskType) && params.claimTasksById.contains(ctx._id)) { + ${markAsClaimingScript} + } else if (!params.skippedTaskTypes.contains(ctx._source.task.taskType)) { + ctx._source.task.status = "unrecognized"; } else { - ctx._source.task.status = "failed"; - } - } else { - ctx._source.task.status = "unrecognized"; - } - `, - lang: 'painless', - params: { - fieldUpdates, - claimTasksById, - registeredTaskTypes, - taskMaxAttempts, - }, -}); + ctx.op = "noop"; + }`, + lang: 'painless', + params: { + fieldUpdates, + claimTasksById, + claimableTaskTypes, + skippedTaskTypes, + taskMaxAttempts, + }, + }; +}; diff --git a/x-pack/plugins/task_manager/server/queries/task_claiming.mock.ts b/x-pack/plugins/task_manager/server/queries/task_claiming.mock.ts new file mode 100644 index 0000000000000..38f02780c485e --- /dev/null +++ b/x-pack/plugins/task_manager/server/queries/task_claiming.mock.ts @@ -0,0 +1,33 @@ +/* + * 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 { Observable, Subject } from 'rxjs'; +import { TaskClaim } from '../task_events'; + +import { TaskClaiming } from './task_claiming'; + +interface TaskClaimingOptions { + maxAttempts?: number; + taskManagerId?: string; + events?: Observable; +} +export const taskClaimingMock = { + create({ + maxAttempts = 0, + taskManagerId = '', + events = new Subject(), + }: TaskClaimingOptions) { + const mocked = ({ + claimAvailableTasks: jest.fn(), + claimAvailableTasksIfCapacityIsAvailable: jest.fn(), + maxAttempts, + taskManagerId, + events, + } as unknown) as jest.Mocked; + return mocked; + }, +}; diff --git a/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts b/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts new file mode 100644 index 0000000000000..bd1171d7fd2f8 --- /dev/null +++ b/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts @@ -0,0 +1,1516 @@ +/* + * 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 _ from 'lodash'; +import uuid from 'uuid'; +import { filter, take, toArray } from 'rxjs/operators'; +import { some, none } from 'fp-ts/lib/Option'; + +import { TaskStatus, ConcreteTaskInstance } from '../task'; +import { SearchOpts, StoreOpts, UpdateByQueryOpts, UpdateByQuerySearchOpts } from '../task_store'; +import { asTaskClaimEvent, ClaimTaskErr, TaskClaimErrorType, TaskEvent } from '../task_events'; +import { asOk, asErr } from '../lib/result_type'; +import { TaskTypeDictionary } from '../task_type_dictionary'; +import { BoolClauseWithAnyCondition, TermFilter } from '../queries/query_clauses'; +import { mockLogger } from '../test_utils'; +import { TaskClaiming, OwnershipClaimingOpts, TaskClaimingOpts } from './task_claiming'; +import { Observable } from 'rxjs'; +import { taskStoreMock } from '../task_store.mock'; + +const taskManagerLogger = mockLogger(); + +beforeEach(() => jest.resetAllMocks()); + +const mockedDate = new Date('2019-02-12T21:01:22.479Z'); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(global as any).Date = class Date { + constructor() { + return mockedDate; + } + static now() { + return mockedDate.getTime(); + } +}; + +const taskDefinitions = new TaskTypeDictionary(taskManagerLogger); +taskDefinitions.registerTaskDefinitions({ + report: { + title: 'report', + createTaskRunner: jest.fn(), + }, + dernstraight: { + title: 'dernstraight', + createTaskRunner: jest.fn(), + }, + yawn: { + title: 'yawn', + createTaskRunner: jest.fn(), + }, +}); + +describe('TaskClaiming', () => { + test(`should log when a certain task type is skipped due to having a zero concurency configuration`, () => { + const definitions = new TaskTypeDictionary(mockLogger()); + definitions.registerTaskDefinitions({ + unlimited: { + title: 'unlimited', + createTaskRunner: jest.fn(), + }, + anotherUnlimited: { + title: 'anotherUnlimited', + createTaskRunner: jest.fn(), + }, + limitedToZero: { + title: 'limitedToZero', + maxConcurrency: 0, + createTaskRunner: jest.fn(), + }, + limitedToOne: { + title: 'limitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + anotherLimitedToZero: { + title: 'anotherLimitedToZero', + maxConcurrency: 0, + createTaskRunner: jest.fn(), + }, + limitedToTwo: { + title: 'limitedToTwo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + + new TaskClaiming({ + logger: taskManagerLogger, + definitions, + taskStore: taskStoreMock.create({ taskManagerId: '' }), + maxAttempts: 2, + getCapacity: () => 10, + }); + + expect(taskManagerLogger.info).toHaveBeenCalledTimes(1); + expect(taskManagerLogger.info.mock.calls[0][0]).toMatchInlineSnapshot( + `"Task Manager will never claim tasks of the following types as their \\"maxConcurrency\\" is set to 0: limitedToZero, anotherLimitedToZero"` + ); + }); + + describe('claimAvailableTasks', () => { + function initialiseTestClaiming({ + storeOpts = {}, + taskClaimingOpts = {}, + hits = [generateFakeTasks(1)], + versionConflicts = 2, + }: { + storeOpts: Partial; + taskClaimingOpts: Partial; + hits?: ConcreteTaskInstance[][]; + versionConflicts?: number; + }) { + const definitions = storeOpts.definitions ?? taskDefinitions; + const store = taskStoreMock.create({ taskManagerId: storeOpts.taskManagerId }); + store.convertToSavedObjectIds.mockImplementation((ids) => ids.map((id) => `task:${id}`)); + + if (hits.length === 1) { + store.fetch.mockResolvedValue({ docs: hits[0] }); + store.updateByQuery.mockResolvedValue({ + updated: hits[0].length, + version_conflicts: versionConflicts, + total: hits[0].length, + }); + } else { + for (const docs of hits) { + store.fetch.mockResolvedValueOnce({ docs }); + store.updateByQuery.mockResolvedValueOnce({ + updated: docs.length, + version_conflicts: versionConflicts, + total: docs.length, + }); + } + } + + const taskClaiming = new TaskClaiming({ + logger: taskManagerLogger, + definitions, + taskStore: store, + maxAttempts: taskClaimingOpts.maxAttempts ?? 2, + getCapacity: taskClaimingOpts.getCapacity ?? (() => 10), + ...taskClaimingOpts, + }); + + return { taskClaiming, store }; + } + + async function testClaimAvailableTasks({ + storeOpts = {}, + taskClaimingOpts = {}, + claimingOpts, + hits = [generateFakeTasks(1)], + versionConflicts = 2, + }: { + storeOpts: Partial; + taskClaimingOpts: Partial; + claimingOpts: Omit; + hits?: ConcreteTaskInstance[][]; + versionConflicts?: number; + }) { + const getCapacity = taskClaimingOpts.getCapacity ?? (() => 10); + const { taskClaiming, store } = initialiseTestClaiming({ + storeOpts, + taskClaimingOpts, + hits, + versionConflicts, + }); + + const results = await getAllAsPromise(taskClaiming.claimAvailableTasks(claimingOpts)); + + expect(store.updateByQuery.mock.calls[0][1]).toMatchObject({ + max_docs: getCapacity(), + }); + expect(store.fetch.mock.calls[0][0]).toMatchObject({ size: getCapacity() }); + return results.map((result, index) => ({ + result, + args: { + search: store.fetch.mock.calls[index][0] as SearchOpts & { + query: BoolClauseWithAnyCondition; + }, + updateByQuery: store.updateByQuery.mock.calls[index] as [ + UpdateByQuerySearchOpts, + UpdateByQueryOpts + ], + }, + })); + } + + test('it filters claimed tasks down by supported types, maxAttempts, status, and runAt', async () => { + const maxAttempts = _.random(2, 43); + const customMaxAttempts = _.random(44, 100); + + const definitions = new TaskTypeDictionary(mockLogger()); + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + createTaskRunner: jest.fn(), + }, + bar: { + title: 'bar', + maxAttempts: customMaxAttempts, + createTaskRunner: jest.fn(), + }, + }); + + const [ + { + args: { + updateByQuery: [{ query, sort }], + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + definitions, + }, + taskClaimingOpts: { + maxAttempts, + }, + claimingOpts: { + claimOwnershipUntil: new Date(), + }, + }); + expect(query).toMatchObject({ + bool: { + must: [ + { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'task.status': 'idle' } }, + { range: { 'task.runAt': { lte: 'now' } } }, + ], + }, + }, + { + bool: { + must: [ + { + bool: { + should: [ + { term: { 'task.status': 'running' } }, + { term: { 'task.status': 'claiming' } }, + ], + }, + }, + { range: { 'task.retryAt': { lte: 'now' } } }, + ], + }, + }, + ], + }, + }, + ], + filter: [ + { + bool: { + must_not: [ + { + bool: { + should: [ + { term: { 'task.status': 'running' } }, + { term: { 'task.status': 'claiming' } }, + ], + must: { range: { 'task.retryAt': { gt: 'now' } } }, + }, + }, + ], + }, + }, + ], + }, + }); + expect(sort).toMatchObject([ + { + _script: { + type: 'number', + order: 'asc', + script: { + lang: 'painless', + source: ` +if (doc['task.retryAt'].size()!=0) { + return doc['task.retryAt'].value.toInstant().toEpochMilli(); +} +if (doc['task.runAt'].size()!=0) { + return doc['task.runAt'].value.toInstant().toEpochMilli(); +} + `, + }, + }, + }, + ]); + }); + + test('it supports claiming specific tasks by id', async () => { + const maxAttempts = _.random(2, 43); + const customMaxAttempts = _.random(44, 100); + const definitions = new TaskTypeDictionary(mockLogger()); + const taskManagerId = uuid.v1(); + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: new Date(Date.now()), + }; + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + createTaskRunner: jest.fn(), + }, + bar: { + title: 'bar', + maxAttempts: customMaxAttempts, + createTaskRunner: jest.fn(), + }, + }); + const [ + { + args: { + updateByQuery: [{ query, script, sort }], + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: { + maxAttempts, + }, + claimingOpts: { + claimOwnershipUntil: new Date(), + claimTasksById: [ + '33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + }, + }); + + expect(query).toMatchObject({ + bool: { + must: [ + { + pinned: { + ids: [ + 'task:33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'task:a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + organic: { + bool: { + must: [ + { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'task.status': 'idle' } }, + { range: { 'task.runAt': { lte: 'now' } } }, + ], + }, + }, + { + bool: { + must: [ + { + bool: { + should: [ + { term: { 'task.status': 'running' } }, + { term: { 'task.status': 'claiming' } }, + ], + }, + }, + { range: { 'task.retryAt': { lte: 'now' } } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + }, + }, + ], + filter: [ + { + bool: { + must_not: [ + { + bool: { + should: [ + { term: { 'task.status': 'running' } }, + { term: { 'task.status': 'claiming' } }, + ], + must: { range: { 'task.retryAt': { gt: 'now' } } }, + }, + }, + ], + }, + }, + ], + }, + }); + + expect(script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [ + 'task:33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'task:a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + claimableTaskTypes: ['foo', 'bar'], + skippedTaskTypes: [], + taskMaxAttempts: { + bar: customMaxAttempts, + foo: maxAttempts, + }, + }, + }); + + expect(sort).toMatchObject([ + '_score', + { + _script: { + type: 'number', + order: 'asc', + script: { + lang: 'painless', + source: ` +if (doc['task.retryAt'].size()!=0) { + return doc['task.retryAt'].value.toInstant().toEpochMilli(); +} +if (doc['task.runAt'].size()!=0) { + return doc['task.runAt'].value.toInstant().toEpochMilli(); +} + `, + }, + }, + }, + ]); + }); + + test('it should claim in batches partitioned by maxConcurrency', async () => { + const maxAttempts = _.random(2, 43); + const definitions = new TaskTypeDictionary(mockLogger()); + const taskManagerId = uuid.v1(); + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: new Date(Date.now()), + }; + definitions.registerTaskDefinitions({ + unlimited: { + title: 'unlimited', + createTaskRunner: jest.fn(), + }, + limitedToZero: { + title: 'limitedToZero', + maxConcurrency: 0, + createTaskRunner: jest.fn(), + }, + anotherUnlimited: { + title: 'anotherUnlimited', + createTaskRunner: jest.fn(), + }, + finalUnlimited: { + title: 'finalUnlimited', + createTaskRunner: jest.fn(), + }, + limitedToOne: { + title: 'limitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + anotherLimitedToOne: { + title: 'anotherLimitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + limitedToTwo: { + title: 'limitedToTwo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + const results = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: { + maxAttempts, + getCapacity: (type) => { + switch (type) { + case 'limitedToOne': + case 'anotherLimitedToOne': + return 1; + case 'limitedToTwo': + return 2; + default: + return 10; + } + }, + }, + claimingOpts: { + claimOwnershipUntil: new Date(), + claimTasksById: [ + '33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + }, + }); + + expect(results.length).toEqual(4); + + expect(results[0].args.updateByQuery[1].max_docs).toEqual(10); + expect(results[0].args.updateByQuery[0].script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [ + 'task:33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'task:a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + claimableTaskTypes: ['unlimited', 'anotherUnlimited', 'finalUnlimited'], + skippedTaskTypes: [ + 'limitedToZero', + 'limitedToOne', + 'anotherLimitedToOne', + 'limitedToTwo', + ], + taskMaxAttempts: { + unlimited: maxAttempts, + }, + }, + }); + + expect(results[1].args.updateByQuery[1].max_docs).toEqual(1); + expect(results[1].args.updateByQuery[0].script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [], + claimableTaskTypes: ['limitedToOne'], + skippedTaskTypes: [ + 'unlimited', + 'limitedToZero', + 'anotherUnlimited', + 'finalUnlimited', + 'anotherLimitedToOne', + 'limitedToTwo', + ], + taskMaxAttempts: { + limitedToOne: maxAttempts, + }, + }, + }); + + expect(results[2].args.updateByQuery[1].max_docs).toEqual(1); + expect(results[2].args.updateByQuery[0].script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [], + claimableTaskTypes: ['anotherLimitedToOne'], + skippedTaskTypes: [ + 'unlimited', + 'limitedToZero', + 'anotherUnlimited', + 'finalUnlimited', + 'limitedToOne', + 'limitedToTwo', + ], + taskMaxAttempts: { + anotherLimitedToOne: maxAttempts, + }, + }, + }); + + expect(results[3].args.updateByQuery[1].max_docs).toEqual(2); + expect(results[3].args.updateByQuery[0].script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [], + claimableTaskTypes: ['limitedToTwo'], + skippedTaskTypes: [ + 'unlimited', + 'limitedToZero', + 'anotherUnlimited', + 'finalUnlimited', + 'limitedToOne', + 'anotherLimitedToOne', + ], + taskMaxAttempts: { + limitedToTwo: maxAttempts, + }, + }, + }); + }); + + test('it should reduce the available capacity from batch to batch', async () => { + const maxAttempts = _.random(2, 43); + const definitions = new TaskTypeDictionary(mockLogger()); + const taskManagerId = uuid.v1(); + definitions.registerTaskDefinitions({ + unlimited: { + title: 'unlimited', + createTaskRunner: jest.fn(), + }, + limitedToFive: { + title: 'limitedToFive', + maxConcurrency: 5, + createTaskRunner: jest.fn(), + }, + limitedToTwo: { + title: 'limitedToTwo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + const results = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: { + maxAttempts, + getCapacity: (type) => { + switch (type) { + case 'limitedToTwo': + return 2; + case 'limitedToFive': + return 5; + default: + return 10; + } + }, + }, + hits: [ + [ + // 7 returned by unlimited query + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + ], + // 2 returned by limitedToFive query + [ + mockInstance({ + taskType: 'limitedToFive', + }), + mockInstance({ + taskType: 'limitedToFive', + }), + ], + // 1 reterned by limitedToTwo query + [ + mockInstance({ + taskType: 'limitedToTwo', + }), + ], + ], + claimingOpts: { + claimOwnershipUntil: new Date(), + claimTasksById: [], + }, + }); + + expect(results.length).toEqual(3); + + expect(results[0].args.updateByQuery[1].max_docs).toEqual(10); + + // only capacity for 3, even though 5 are allowed + expect(results[1].args.updateByQuery[1].max_docs).toEqual(3); + + // only capacity for 1, even though 2 are allowed + expect(results[2].args.updateByQuery[1].max_docs).toEqual(1); + }); + + test('it shuffles the types claimed in batches to ensure no type starves another', async () => { + const maxAttempts = _.random(2, 43); + const definitions = new TaskTypeDictionary(mockLogger()); + const taskManagerId = uuid.v1(); + definitions.registerTaskDefinitions({ + unlimited: { + title: 'unlimited', + createTaskRunner: jest.fn(), + }, + anotherUnlimited: { + title: 'anotherUnlimited', + createTaskRunner: jest.fn(), + }, + finalUnlimited: { + title: 'finalUnlimited', + createTaskRunner: jest.fn(), + }, + limitedToOne: { + title: 'limitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + anotherLimitedToOne: { + title: 'anotherLimitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + limitedToTwo: { + title: 'limitedToTwo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + + const { taskClaiming, store } = initialiseTestClaiming({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: { + maxAttempts, + getCapacity: (type) => { + switch (type) { + case 'limitedToOne': + case 'anotherLimitedToOne': + return 1; + case 'limitedToTwo': + return 2; + default: + return 10; + } + }, + }, + }); + + async function getUpdateByQueryScriptParams() { + return ( + await getAllAsPromise( + taskClaiming.claimAvailableTasks({ + claimOwnershipUntil: new Date(), + }) + ) + ).map( + (result, index) => + (store.updateByQuery.mock.calls[index][0] as { + query: BoolClauseWithAnyCondition; + size: number; + sort: string | string[]; + script: { + params: { + claimableTaskTypes: string[]; + }; + }; + }).script.params.claimableTaskTypes + ); + } + + const firstCycle = await getUpdateByQueryScriptParams(); + store.updateByQuery.mockClear(); + const secondCycle = await getUpdateByQueryScriptParams(); + + expect(firstCycle.length).toEqual(4); + expect(secondCycle.length).toEqual(4); + expect(firstCycle).not.toMatchObject(secondCycle); + }); + + test('it claims tasks by setting their ownerId, status and retryAt', async () => { + const taskManagerId = uuid.v1(); + const claimOwnershipUntil = new Date(Date.now()); + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: claimOwnershipUntil, + }; + const [ + { + args: { + updateByQuery: [{ script }], + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + }, + taskClaimingOpts: {}, + claimingOpts: { + claimOwnershipUntil, + }, + }); + expect(script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimableTaskTypes: ['report', 'dernstraight', 'yawn'], + skippedTaskTypes: [], + taskMaxAttempts: { + dernstraight: 2, + report: 2, + yawn: 2, + }, + }, + }); + }); + + test('it filters out running tasks', async () => { + const taskManagerId = uuid.v1(); + const claimOwnershipUntil = new Date(Date.now()); + const runAt = new Date(); + const tasks = [ + mockInstance({ + id: 'aaa', + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + }), + ]; + const [ + { + result: { docs }, + args: { + search: { query }, + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + }, + taskClaimingOpts: {}, + claimingOpts: { + claimOwnershipUntil, + }, + hits: [tasks], + }); + + expect(query).toMatchObject({ + bool: { + must: [ + { + term: { + 'task.ownerId': taskManagerId, + }, + }, + { term: { 'task.status': 'claiming' } }, + { + bool: { + should: [ + { + term: { + 'task.taskType': 'report', + }, + }, + { + term: { + 'task.taskType': 'dernstraight', + }, + }, + { + term: { + 'task.taskType': 'yawn', + }, + }, + ], + }, + }, + ], + }, + }); + + expect(docs).toMatchObject([ + { + attempts: 0, + id: 'aaa', + schedule: undefined, + params: { hello: 'world' }, + runAt, + scope: ['reporting'], + state: { baby: 'Henhen' }, + status: 'claiming', + taskType: 'foo', + user: 'jimbo', + ownerId: taskManagerId, + }, + ]); + }); + + test('it returns task objects', async () => { + const taskManagerId = uuid.v1(); + const claimOwnershipUntil = new Date(Date.now()); + const runAt = new Date(); + const tasks = [ + mockInstance({ + id: 'aaa', + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + }), + mockInstance({ + id: 'bbb', + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: TaskStatus.Claiming, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + }), + ]; + const [ + { + result: { docs }, + args: { + search: { query }, + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + }, + taskClaimingOpts: {}, + claimingOpts: { + claimOwnershipUntil, + }, + hits: [tasks], + }); + + expect(query).toMatchObject({ + bool: { + must: [ + { + term: { + 'task.ownerId': taskManagerId, + }, + }, + { term: { 'task.status': 'claiming' } }, + { + bool: { + should: [ + { + term: { + 'task.taskType': 'report', + }, + }, + { + term: { + 'task.taskType': 'dernstraight', + }, + }, + { + term: { + 'task.taskType': 'yawn', + }, + }, + ], + }, + }, + ], + }, + }); + + expect(docs).toMatchObject([ + { + attempts: 0, + id: 'aaa', + schedule: undefined, + params: { hello: 'world' }, + runAt, + scope: ['reporting'], + state: { baby: 'Henhen' }, + status: 'claiming', + taskType: 'foo', + user: 'jimbo', + ownerId: taskManagerId, + }, + { + attempts: 2, + id: 'bbb', + schedule: { interval: '5m' }, + params: { shazm: 1 }, + runAt, + scope: ['reporting', 'ceo'], + state: { henry: 'The 8th' }, + status: 'claiming', + taskType: 'bar', + user: 'dabo', + ownerId: taskManagerId, + }, + ]); + }); + + test('it returns version_conflicts that do not include conflicts that were proceeded against', async () => { + const taskManagerId = uuid.v1(); + const claimOwnershipUntil = new Date(Date.now()); + const runAt = new Date(); + const tasks = [ + mockInstance({ + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + }), + mockInstance({ + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: TaskStatus.Claiming, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + }), + ]; + const maxDocs = 10; + const [ + { + result: { + stats: { tasksUpdated, tasksConflicted, tasksClaimed }, + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + }, + taskClaimingOpts: { getCapacity: () => maxDocs }, + claimingOpts: { + claimOwnershipUntil, + }, + hits: [tasks], + // assume there were 20 version conflists, but thanks to `conflicts="proceed"` + // we proceeded to claim tasks + versionConflicts: 20, + }); + + expect(tasksUpdated).toEqual(2); + // ensure we only count conflicts that *may* have counted against max_docs, no more than that + expect(tasksConflicted).toEqual(10 - tasksUpdated!); + expect(tasksClaimed).toEqual(2); + }); + }); + + describe('task events', () => { + function generateTasks(taskManagerId: string) { + const runAt = new Date(); + const tasks = [ + { + id: 'claimed-by-id', + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }, + { + id: 'claimed-by-schedule', + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: TaskStatus.Claiming, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }, + { + id: 'already-running', + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: TaskStatus.Running, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }, + ]; + + return { taskManagerId, runAt, tasks }; + } + + function instantiateStoreWithMockedApiResponses({ + taskManagerId = uuid.v4(), + definitions = taskDefinitions, + getCapacity = () => 10, + tasksClaimed, + }: Partial> & { + taskManagerId?: string; + tasksClaimed?: ConcreteTaskInstance[][]; + } = {}) { + const { runAt, tasks: generatedTasks } = generateTasks(taskManagerId); + const taskCycles = tasksClaimed ?? [generatedTasks]; + + const taskStore = taskStoreMock.create({ taskManagerId }); + taskStore.convertToSavedObjectIds.mockImplementation((ids) => ids.map((id) => `task:${id}`)); + for (const docs of taskCycles) { + taskStore.fetch.mockResolvedValueOnce({ docs }); + taskStore.updateByQuery.mockResolvedValueOnce({ + updated: docs.length, + version_conflicts: 0, + total: docs.length, + }); + } + + taskStore.fetch.mockResolvedValue({ docs: [] }); + taskStore.updateByQuery.mockResolvedValue({ + updated: 0, + version_conflicts: 0, + total: 0, + }); + + const taskClaiming = new TaskClaiming({ + logger: taskManagerLogger, + definitions, + taskStore, + maxAttempts: 2, + getCapacity, + }); + + return { taskManagerId, runAt, taskClaiming }; + } + + test('emits an event when a task is succesfully claimed by id', async () => { + const { taskManagerId, runAt, taskClaiming } = instantiateStoreWithMockedApiResponses(); + + const promise = taskClaiming.events + .pipe( + filter( + (event: TaskEvent) => event.id === 'claimed-by-id' + ), + take(1) + ) + .toPromise(); + + await getFirstAsPromise( + taskClaiming.claimAvailableTasks({ + claimTasksById: ['claimed-by-id'], + claimOwnershipUntil: new Date(), + }) + ); + + const event = await promise; + expect(event).toMatchObject( + asTaskClaimEvent( + 'claimed-by-id', + asOk({ + id: 'claimed-by-id', + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: 'claiming' as TaskStatus, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }) + ) + ); + }); + + test('emits an event when a task is succesfully claimed by id by is rejected as it would exceed maxCapacity of its taskType', async () => { + const definitions = new TaskTypeDictionary(mockLogger()); + definitions.registerTaskDefinitions({ + unlimited: { + title: 'unlimited', + createTaskRunner: jest.fn(), + }, + limitedToOne: { + title: 'limitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + anotherLimitedToOne: { + title: 'anotherLimitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + }); + + const taskManagerId = uuid.v4(); + const { runAt, taskClaiming } = instantiateStoreWithMockedApiResponses({ + taskManagerId, + definitions, + getCapacity: (type) => { + switch (type) { + case 'limitedToOne': + // return 0 as there's already a `limitedToOne` task running + return 0; + default: + return 10; + } + }, + tasksClaimed: [ + // find on first claim cycle + [ + { + id: 'claimed-by-id-limited-concurrency', + runAt: new Date(), + taskType: 'limitedToOne', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }, + ], + // second cycle + [ + { + id: 'claimed-by-schedule-unlimited', + runAt: new Date(), + taskType: 'unlimited', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }, + ], + ], + }); + + const promise = taskClaiming.events + .pipe( + filter( + (event: TaskEvent) => + event.id === 'claimed-by-id-limited-concurrency' + ), + take(1) + ) + .toPromise(); + + const [firstCycleResult, secondCycleResult] = await getAllAsPromise( + taskClaiming.claimAvailableTasks({ + claimTasksById: ['claimed-by-id-limited-concurrency'], + claimOwnershipUntil: new Date(), + }) + ); + + expect(firstCycleResult.stats.tasksClaimed).toEqual(0); + expect(firstCycleResult.stats.tasksRejected).toEqual(1); + expect(firstCycleResult.stats.tasksUpdated).toEqual(1); + + // values accumulate from cycle to cycle + expect(secondCycleResult.stats.tasksClaimed).toEqual(0); + expect(secondCycleResult.stats.tasksRejected).toEqual(1); + expect(secondCycleResult.stats.tasksUpdated).toEqual(1); + + const event = await promise; + expect(event).toMatchObject( + asTaskClaimEvent( + 'claimed-by-id-limited-concurrency', + asErr({ + task: some({ + id: 'claimed-by-id-limited-concurrency', + runAt, + taskType: 'limitedToOne', + schedule: undefined, + attempts: 0, + status: 'claiming' as TaskStatus, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }), + errorType: TaskClaimErrorType.CLAIMED_BY_ID_OUT_OF_CAPACITY, + }) + ) + ); + }); + + test('emits an event when a task is succesfully by scheduling', async () => { + const { taskManagerId, runAt, taskClaiming } = instantiateStoreWithMockedApiResponses(); + + const promise = taskClaiming.events + .pipe( + filter( + (event: TaskEvent) => + event.id === 'claimed-by-schedule' + ), + take(1) + ) + .toPromise(); + + await getFirstAsPromise( + taskClaiming.claimAvailableTasks({ + claimTasksById: ['claimed-by-id'], + claimOwnershipUntil: new Date(), + }) + ); + + const event = await promise; + expect(event).toMatchObject( + asTaskClaimEvent( + 'claimed-by-schedule', + asOk({ + id: 'claimed-by-schedule', + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: 'claiming' as TaskStatus, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }) + ) + ); + }); + + test('emits an event when the store fails to claim a required task by id', async () => { + const { taskManagerId, runAt, taskClaiming } = instantiateStoreWithMockedApiResponses(); + + const promise = taskClaiming.events + .pipe( + filter( + (event: TaskEvent) => event.id === 'already-running' + ), + take(1) + ) + .toPromise(); + + await getFirstAsPromise( + taskClaiming.claimAvailableTasks({ + claimTasksById: ['already-running'], + claimOwnershipUntil: new Date(), + }) + ); + + const event = await promise; + expect(event).toMatchObject( + asTaskClaimEvent( + 'already-running', + asErr({ + task: some({ + id: 'already-running', + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: 'running' as TaskStatus, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }), + errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_IN_CLAIMING_STATUS, + }) + ) + ); + }); + + test('emits an event when the store fails to find a task which was required by id', async () => { + const { taskClaiming } = instantiateStoreWithMockedApiResponses(); + + const promise = taskClaiming.events + .pipe( + filter( + (event: TaskEvent) => event.id === 'unknown-task' + ), + take(1) + ) + .toPromise(); + + await getFirstAsPromise( + taskClaiming.claimAvailableTasks({ + claimTasksById: ['unknown-task'], + claimOwnershipUntil: new Date(), + }) + ); + + const event = await promise; + expect(event).toMatchObject( + asTaskClaimEvent( + 'unknown-task', + asErr({ + task: none, + errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED, + }) + ) + ); + }); + }); +}); + +function generateFakeTasks(count: number = 1) { + return _.times(count, (index) => mockInstance({ id: `task:id-${index}` })); +} + +function mockInstance(instance: Partial = {}) { + return Object.assign( + { + id: uuid.v4(), + taskType: 'bar', + sequenceNumber: 32, + primaryTerm: 32, + runAt: new Date(), + scheduledAt: new Date(), + startedAt: null, + retryAt: null, + attempts: 0, + params: {}, + scope: ['reporting'], + state: {}, + status: 'idle', + user: 'example', + ownerId: null, + }, + instance + ); +} + +function getFirstAsPromise(obs$: Observable): Promise { + return new Promise((resolve, reject) => { + obs$.subscribe(resolve, reject); + }); +} +function getAllAsPromise(obs$: Observable): Promise { + return new Promise((resolve, reject) => { + obs$.pipe(toArray()).subscribe(resolve, reject); + }); +} diff --git a/x-pack/plugins/task_manager/server/queries/task_claiming.ts b/x-pack/plugins/task_manager/server/queries/task_claiming.ts new file mode 100644 index 0000000000000..b4e11dbf81eb1 --- /dev/null +++ b/x-pack/plugins/task_manager/server/queries/task_claiming.ts @@ -0,0 +1,488 @@ +/* + * 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. + */ + +/* + * This module contains helpers for managing the task manager storage layer. + */ +import apm from 'elastic-apm-node'; +import { Subject, Observable, from, of } from 'rxjs'; +import { map, mergeScan } from 'rxjs/operators'; +import { difference, partition, groupBy, mapValues, countBy, pick } from 'lodash'; +import { some, none } from 'fp-ts/lib/Option'; + +import { Logger } from '../../../../../src/core/server'; + +import { asOk, asErr, Result } from '../lib/result_type'; +import { ConcreteTaskInstance, TaskStatus } from '../task'; +import { + TaskClaim, + asTaskClaimEvent, + TaskClaimErrorType, + startTaskTimer, + TaskTiming, +} from '../task_events'; + +import { + asUpdateByQuery, + shouldBeOneOf, + mustBeAllOf, + filterDownBy, + asPinnedQuery, + matchesClauses, + SortOptions, +} from './query_clauses'; + +import { + updateFieldsAndMarkAsFailed, + IdleTaskWithExpiredRunAt, + InactiveTasks, + RunningOrClaimingTaskWithExpiredRetryAt, + SortByRunAtAndRetryAt, + tasksClaimedByOwner, + tasksOfType, +} from './mark_available_tasks_as_claimed'; +import { TaskTypeDictionary } from '../task_type_dictionary'; +import { + correctVersionConflictsForContinuation, + TaskStore, + UpdateByQueryResult, +} from '../task_store'; +import { FillPoolResult } from '../lib/fill_pool'; + +export interface TaskClaimingOpts { + logger: Logger; + definitions: TaskTypeDictionary; + taskStore: TaskStore; + maxAttempts: number; + getCapacity: (taskType?: string) => number; +} + +export interface OwnershipClaimingOpts { + claimOwnershipUntil: Date; + claimTasksById?: string[]; + size: number; + taskTypes: Set; +} +export type IncrementalOwnershipClaimingOpts = OwnershipClaimingOpts & { + precedingQueryResult: UpdateByQueryResult; +}; +export type IncrementalOwnershipClaimingReduction = ( + opts: IncrementalOwnershipClaimingOpts +) => Promise; + +export interface FetchResult { + docs: ConcreteTaskInstance[]; +} + +export interface ClaimOwnershipResult { + stats: { + tasksUpdated: number; + tasksConflicted: number; + tasksClaimed: number; + tasksRejected: number; + }; + docs: ConcreteTaskInstance[]; + timing?: TaskTiming; +} + +enum BatchConcurrency { + Unlimited, + Limited, +} + +type TaskClaimingBatches = Array; +interface TaskClaimingBatch { + concurrency: Concurrency; + tasksTypes: TaskType; +} +type UnlimitedBatch = TaskClaimingBatch>; +type LimitedBatch = TaskClaimingBatch; + +export class TaskClaiming { + public readonly errors$ = new Subject(); + public readonly maxAttempts: number; + + private definitions: TaskTypeDictionary; + private events$: Subject; + private taskStore: TaskStore; + private getCapacity: (taskType?: string) => number; + private logger: Logger; + private readonly taskClaimingBatchesByType: TaskClaimingBatches; + private readonly taskMaxAttempts: Record; + + /** + * Constructs a new TaskStore. + * @param {TaskClaimingOpts} opts + * @prop {number} maxAttempts - The maximum number of attempts before a task will be abandoned + * @prop {TaskDefinition} definition - The definition of the task being run + */ + constructor(opts: TaskClaimingOpts) { + this.definitions = opts.definitions; + this.maxAttempts = opts.maxAttempts; + this.taskStore = opts.taskStore; + this.getCapacity = opts.getCapacity; + this.logger = opts.logger; + this.taskClaimingBatchesByType = this.partitionIntoClaimingBatches(this.definitions); + this.taskMaxAttempts = Object.fromEntries(this.normalizeMaxAttempts(this.definitions)); + + this.events$ = new Subject(); + } + + private partitionIntoClaimingBatches(definitions: TaskTypeDictionary): TaskClaimingBatches { + const { + limitedConcurrency, + unlimitedConcurrency, + skippedTypes, + } = groupBy(definitions.getAllDefinitions(), (definition) => + definition.maxConcurrency + ? 'limitedConcurrency' + : definition.maxConcurrency === 0 + ? 'skippedTypes' + : 'unlimitedConcurrency' + ); + + if (skippedTypes?.length) { + this.logger.info( + `Task Manager will never claim tasks of the following types as their "maxConcurrency" is set to 0: ${skippedTypes + .map(({ type }) => type) + .join(', ')}` + ); + } + return [ + ...(unlimitedConcurrency + ? [asUnlimited(new Set(unlimitedConcurrency.map(({ type }) => type)))] + : []), + ...(limitedConcurrency ? limitedConcurrency.map(({ type }) => asLimited(type)) : []), + ]; + } + + private normalizeMaxAttempts(definitions: TaskTypeDictionary) { + return new Map( + [...definitions].map(([type, { maxAttempts }]) => [type, maxAttempts || this.maxAttempts]) + ); + } + + private claimingBatchIndex = 0; + private getClaimingBatches() { + // return all batches, starting at index and cycling back to where we began + const batch = [ + ...this.taskClaimingBatchesByType.slice(this.claimingBatchIndex), + ...this.taskClaimingBatchesByType.slice(0, this.claimingBatchIndex), + ]; + // shift claimingBatchIndex by one so that next cycle begins at the next index + this.claimingBatchIndex = (this.claimingBatchIndex + 1) % this.taskClaimingBatchesByType.length; + return batch; + } + + public get events(): Observable { + return this.events$; + } + + private emitEvents = (events: TaskClaim[]) => { + events.forEach((event) => this.events$.next(event)); + }; + + public claimAvailableTasksIfCapacityIsAvailable( + claimingOptions: Omit + ): Observable> { + if (this.getCapacity()) { + return this.claimAvailableTasks(claimingOptions).pipe( + map((claimResult) => asOk(claimResult)) + ); + } + this.logger.debug( + `[Task Ownership]: Task Manager has skipped Claiming Ownership of available tasks at it has ran out Available Workers.` + ); + return of(asErr(FillPoolResult.NoAvailableWorkers)); + } + + public claimAvailableTasks({ + claimOwnershipUntil, + claimTasksById = [], + }: Omit): Observable { + const initialCapacity = this.getCapacity(); + return from(this.getClaimingBatches()).pipe( + mergeScan( + (accumulatedResult, batch) => { + const stopTaskTimer = startTaskTimer(); + const capacity = Math.min( + initialCapacity - accumulatedResult.stats.tasksClaimed, + isLimited(batch) ? this.getCapacity(batch.tasksTypes) : this.getCapacity() + ); + // if we have no more capacity, short circuit here + if (capacity <= 0) { + return of(accumulatedResult); + } + return from( + this.executClaimAvailableTasks({ + claimOwnershipUntil, + claimTasksById: claimTasksById.splice(0, capacity), + size: capacity, + taskTypes: isLimited(batch) ? new Set([batch.tasksTypes]) : batch.tasksTypes, + }).then((result) => { + const { stats, docs } = accumulateClaimOwnershipResults(accumulatedResult, result); + stats.tasksConflicted = correctVersionConflictsForContinuation( + stats.tasksClaimed, + stats.tasksConflicted, + initialCapacity + ); + return { stats, docs, timing: stopTaskTimer() }; + }) + ); + }, + // initialise the accumulation with no results + accumulateClaimOwnershipResults(), + // only run one batch at a time + 1 + ) + ); + } + + private executClaimAvailableTasks = async ({ + claimOwnershipUntil, + claimTasksById = [], + size, + taskTypes, + }: OwnershipClaimingOpts): Promise => { + const claimTasksByIdWithRawIds = this.taskStore.convertToSavedObjectIds(claimTasksById); + const { + updated: tasksUpdated, + version_conflicts: tasksConflicted, + } = await this.markAvailableTasksAsClaimed({ + claimOwnershipUntil, + claimTasksById: claimTasksByIdWithRawIds, + size, + taskTypes, + }); + + const docs = + tasksUpdated > 0 + ? await this.sweepForClaimedTasks(claimTasksByIdWithRawIds, taskTypes, size) + : []; + + const [documentsReturnedById, documentsClaimedBySchedule] = partition(docs, (doc) => + claimTasksById.includes(doc.id) + ); + + const [documentsClaimedById, documentsRequestedButNotClaimed] = partition( + documentsReturnedById, + // we filter the schduled tasks down by status is 'claiming' in the esearch, + // but we do not apply this limitation on tasks claimed by ID so that we can + // provide more detailed error messages when we fail to claim them + (doc) => doc.status === TaskStatus.Claiming + ); + + // count how many tasks we've claimed by ID and validate we have capacity for them to run + const remainingCapacityOfClaimByIdByType = mapValues( + // This means we take the tasks that were claimed by their ID and count them by their type + countBy(documentsClaimedById, (doc) => doc.taskType), + (count, type) => this.getCapacity(type) - count + ); + + const [documentsClaimedByIdWithinCapacity, documentsClaimedByIdOutOfCapacity] = partition( + documentsClaimedById, + (doc) => { + // if we've exceeded capacity, we reject this task + if (remainingCapacityOfClaimByIdByType[doc.taskType] < 0) { + // as we're rejecting this task we can inc the count so that we know + // to keep the next one returned by ID of the same type + remainingCapacityOfClaimByIdByType[doc.taskType]++; + return false; + } + return true; + } + ); + + const documentsRequestedButNotReturned = difference( + claimTasksById, + documentsReturnedById.map((doc) => doc.id) + ); + + this.emitEvents([ + ...documentsClaimedByIdWithinCapacity.map((doc) => asTaskClaimEvent(doc.id, asOk(doc))), + ...documentsClaimedByIdOutOfCapacity.map((doc) => + asTaskClaimEvent( + doc.id, + asErr({ + task: some(doc), + errorType: TaskClaimErrorType.CLAIMED_BY_ID_OUT_OF_CAPACITY, + }) + ) + ), + ...documentsClaimedBySchedule.map((doc) => asTaskClaimEvent(doc.id, asOk(doc))), + ...documentsRequestedButNotClaimed.map((doc) => + asTaskClaimEvent( + doc.id, + asErr({ + task: some(doc), + errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_IN_CLAIMING_STATUS, + }) + ) + ), + ...documentsRequestedButNotReturned.map((id) => + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ), + ]); + + const stats = { + tasksUpdated, + tasksConflicted, + tasksRejected: documentsClaimedByIdOutOfCapacity.length, + tasksClaimed: documentsClaimedByIdWithinCapacity.length + documentsClaimedBySchedule.length, + }; + + if (docs.length !== stats.tasksClaimed + stats.tasksRejected) { + this.logger.warn( + `[Task Ownership error]: ${stats.tasksClaimed} tasks were claimed by Kibana, but ${ + docs.length + } task(s) were fetched (${docs.map((doc) => doc.id).join(', ')})` + ); + } + + return { + stats, + docs: [...documentsClaimedByIdWithinCapacity, ...documentsClaimedBySchedule], + }; + }; + + private async markAvailableTasksAsClaimed({ + claimOwnershipUntil, + claimTasksById, + size, + taskTypes, + }: OwnershipClaimingOpts): Promise { + const { taskTypesToSkip = [], taskTypesToClaim = [] } = groupBy( + this.definitions.getAllTypes(), + (type) => (taskTypes.has(type) ? 'taskTypesToClaim' : 'taskTypesToSkip') + ); + + const queryForScheduledTasks = mustBeAllOf( + // Either a task with idle status and runAt <= now or + // status running or claiming with a retryAt <= now. + shouldBeOneOf(IdleTaskWithExpiredRunAt, RunningOrClaimingTaskWithExpiredRetryAt) + ); + + // The documents should be sorted by runAt/retryAt, unless there are pinned + // tasks being queried, in which case we want to sort by score first, and then + // the runAt/retryAt. That way we'll get the pinned tasks first. Note that + // the score seems to favor newer documents rather than older documents, so + // if there are not pinned tasks being queried, we do NOT want to sort by score + // at all, just by runAt/retryAt. + const sort: SortOptions = [SortByRunAtAndRetryAt]; + if (claimTasksById && claimTasksById.length) { + sort.unshift('_score'); + } + + const apmTrans = apm.startTransaction(`taskManager markAvailableTasksAsClaimed`, 'taskManager'); + const result = await this.taskStore.updateByQuery( + asUpdateByQuery({ + query: matchesClauses( + claimTasksById && claimTasksById.length + ? mustBeAllOf(asPinnedQuery(claimTasksById, queryForScheduledTasks)) + : queryForScheduledTasks, + filterDownBy(InactiveTasks) + ), + update: updateFieldsAndMarkAsFailed( + { + ownerId: this.taskStore.taskManagerId, + retryAt: claimOwnershipUntil, + }, + claimTasksById || [], + taskTypesToClaim, + taskTypesToSkip, + pick(this.taskMaxAttempts, taskTypesToClaim) + ), + sort, + }), + { + max_docs: size, + } + ); + + if (apmTrans) apmTrans.end(); + return result; + } + + /** + * Fetches tasks from the index, which are owned by the current Kibana instance + */ + private async sweepForClaimedTasks( + claimTasksById: OwnershipClaimingOpts['claimTasksById'], + taskTypes: Set, + size: number + ): Promise { + const claimedTasksQuery = tasksClaimedByOwner( + this.taskStore.taskManagerId, + tasksOfType([...taskTypes]) + ); + const { docs } = await this.taskStore.fetch({ + query: + claimTasksById && claimTasksById.length + ? asPinnedQuery(claimTasksById, claimedTasksQuery) + : claimedTasksQuery, + size, + sort: SortByRunAtAndRetryAt, + seq_no_primary_term: true, + }); + + return docs; + } +} + +const emptyClaimOwnershipResult = () => { + return { + stats: { + tasksUpdated: 0, + tasksConflicted: 0, + tasksClaimed: 0, + tasksRejected: 0, + }, + docs: [], + }; +}; + +function accumulateClaimOwnershipResults( + prev: ClaimOwnershipResult = emptyClaimOwnershipResult(), + next?: ClaimOwnershipResult +) { + if (next) { + const { stats, docs, timing } = next; + const res = { + stats: { + tasksUpdated: stats.tasksUpdated + prev.stats.tasksUpdated, + tasksConflicted: stats.tasksConflicted + prev.stats.tasksConflicted, + tasksClaimed: stats.tasksClaimed + prev.stats.tasksClaimed, + tasksRejected: stats.tasksRejected + prev.stats.tasksRejected, + }, + docs, + timing, + }; + return res; + } + return prev; +} + +function isLimited( + batch: TaskClaimingBatch +): batch is LimitedBatch { + return batch.concurrency === BatchConcurrency.Limited; +} +function asLimited(tasksType: string): LimitedBatch { + return { + concurrency: BatchConcurrency.Limited, + tasksTypes: tasksType, + }; +} +function asUnlimited(tasksTypes: Set): UnlimitedBatch { + return { + concurrency: BatchConcurrency.Unlimited, + tasksTypes, + }; +} diff --git a/x-pack/plugins/task_manager/server/task.ts b/x-pack/plugins/task_manager/server/task.ts index 04589d696427a..4b86943ff8eca 100644 --- a/x-pack/plugins/task_manager/server/task.ts +++ b/x-pack/plugins/task_manager/server/task.ts @@ -127,6 +127,16 @@ export const taskDefinitionSchema = schema.object( min: 1, }) ), + /** + * The maximum number tasks of this type that can be run concurrently per Kibana instance. + * Setting this value will force Task Manager to poll for this task type seperatly from other task types + * which can add significant load to the ES cluster, so please use this configuration only when absolutly necesery. + */ + maxConcurrency: schema.maybe( + schema.number({ + min: 0, + }) + ), }, { validate({ timeout }) { diff --git a/x-pack/plugins/task_manager/server/task_events.ts b/x-pack/plugins/task_manager/server/task_events.ts index d3fb68aa367c1..aecf7c9a2b7e8 100644 --- a/x-pack/plugins/task_manager/server/task_events.ts +++ b/x-pack/plugins/task_manager/server/task_events.ts @@ -23,6 +23,12 @@ export enum TaskEventType { TASK_MANAGER_STAT = 'TASK_MANAGER_STAT', } +export enum TaskClaimErrorType { + CLAIMED_BY_ID_OUT_OF_CAPACITY = 'CLAIMED_BY_ID_OUT_OF_CAPACITY', + CLAIMED_BY_ID_NOT_RETURNED = 'CLAIMED_BY_ID_NOT_RETURNED', + CLAIMED_BY_ID_NOT_IN_CLAIMING_STATUS = 'CLAIMED_BY_ID_NOT_IN_CLAIMING_STATUS', +} + export interface TaskTiming { start: number; stop: number; @@ -47,14 +53,18 @@ export interface RanTask { export type ErroredTask = RanTask & { error: Error; }; +export interface ClaimTaskErr { + task: Option; + errorType: TaskClaimErrorType; +} export type TaskMarkRunning = TaskEvent; export type TaskRun = TaskEvent; -export type TaskClaim = TaskEvent>; +export type TaskClaim = TaskEvent; export type TaskRunRequest = TaskEvent; export type TaskPollingCycle = TaskEvent>; -export type TaskManagerStats = 'load' | 'pollingDelay'; +export type TaskManagerStats = 'load' | 'pollingDelay' | 'claimDuration'; export type TaskManagerStat = TaskEvent; export type OkResultOf = EventType extends TaskEvent @@ -92,7 +102,7 @@ export function asTaskRunEvent( export function asTaskClaimEvent( id: string, - event: Result>, + event: Result, timing?: TaskTiming ): TaskClaim { return { diff --git a/x-pack/plugins/task_manager/server/task_pool.test.ts b/x-pack/plugins/task_manager/server/task_pool.test.ts index 6f82c477dca9e..05eb7bd1b43e1 100644 --- a/x-pack/plugins/task_manager/server/task_pool.test.ts +++ b/x-pack/plugins/task_manager/server/task_pool.test.ts @@ -15,6 +15,7 @@ import { asOk } from './lib/result_type'; import { SavedObjectsErrorHelpers } from '../../../../src/core/server'; import moment from 'moment'; import uuid from 'uuid'; +import { TaskRunningStage } from './task_running'; describe('TaskPool', () => { test('occupiedWorkers are a sum of running tasks', async () => { @@ -370,6 +371,7 @@ describe('TaskPool', () => { cancel: async () => undefined, markTaskAsRunning: jest.fn(async () => true), run: mockRun(), + stage: TaskRunningStage.PENDING, toString: () => `TaskType "shooooo"`, get expiration() { return new Date(); diff --git a/x-pack/plugins/task_manager/server/task_pool.ts b/x-pack/plugins/task_manager/server/task_pool.ts index e30f9ef3154b2..14c0c4581a15b 100644 --- a/x-pack/plugins/task_manager/server/task_pool.ts +++ b/x-pack/plugins/task_manager/server/task_pool.ts @@ -25,6 +25,8 @@ interface Opts { } export enum TaskPoolRunResult { + // This mean we have no Run Result becuse no tasks were Ran in this cycle + NoTaskWereRan = 'NoTaskWereRan', // This means we're running all the tasks we claimed RunningAllClaimedTasks = 'RunningAllClaimedTasks', // This means we're running all the tasks we claimed and we're at capacity @@ -40,7 +42,7 @@ const VERSION_CONFLICT_MESSAGE = 'Task has been claimed by another Kibana servic */ export class TaskPool { private maxWorkers: number = 0; - private running = new Set(); + private tasksInPool = new Map(); private logger: Logger; private load$ = new Subject(); @@ -68,7 +70,7 @@ export class TaskPool { * Gets how many workers are currently in use. */ public get occupiedWorkers() { - return this.running.size; + return this.tasksInPool.size; } /** @@ -93,6 +95,16 @@ export class TaskPool { return this.maxWorkers - this.occupiedWorkers; } + /** + * Gets how many workers are currently in use by type. + */ + public getOccupiedWorkersByType(type: string) { + return [...this.tasksInPool.values()].reduce( + (count, runningTask) => (runningTask.definition.type === type ? ++count : count), + 0 + ); + } + /** * Attempts to run the specified list of tasks. Returns true if it was able * to start every task in the list, false if there was not enough capacity @@ -106,9 +118,11 @@ export class TaskPool { if (tasksToRun.length) { performance.mark('attemptToRun_start'); await Promise.all( - tasksToRun.map( - async (taskRunner) => - await taskRunner + tasksToRun + .filter((taskRunner) => !this.tasksInPool.has(taskRunner.id)) + .map(async (taskRunner) => { + this.tasksInPool.set(taskRunner.id, taskRunner); + return taskRunner .markTaskAsRunning() .then((hasTaskBeenMarkAsRunning: boolean) => hasTaskBeenMarkAsRunning @@ -118,8 +132,8 @@ export class TaskPool { message: VERSION_CONFLICT_MESSAGE, }) ) - .catch((err) => this.handleFailureOfMarkAsRunning(taskRunner, err)) - ) + .catch((err) => this.handleFailureOfMarkAsRunning(taskRunner, err)); + }) ); performance.mark('attemptToRun_stop'); @@ -139,13 +153,12 @@ export class TaskPool { public cancelRunningTasks() { this.logger.debug('Cancelling running tasks.'); - for (const task of this.running) { + for (const task of this.tasksInPool.values()) { this.cancelTask(task); } } private handleMarkAsRunning(taskRunner: TaskRunner) { - this.running.add(taskRunner); taskRunner .run() .catch((err) => { @@ -161,26 +174,31 @@ export class TaskPool { this.logger.warn(errorLogLine); } }) - .then(() => this.running.delete(taskRunner)); + .then(() => this.tasksInPool.delete(taskRunner.id)); } private handleFailureOfMarkAsRunning(task: TaskRunner, err: Error) { + this.tasksInPool.delete(task.id); this.logger.error(`Failed to mark Task ${task.toString()} as running: ${err.message}`); } private cancelExpiredTasks() { - for (const task of this.running) { - if (task.isExpired) { + for (const taskRunner of this.tasksInPool.values()) { + if (taskRunner.isExpired) { this.logger.warn( - `Cancelling task ${task.toString()} as it expired at ${task.expiration.toISOString()}${ - task.startedAt + `Cancelling task ${taskRunner.toString()} as it expired at ${taskRunner.expiration.toISOString()}${ + taskRunner.startedAt ? ` after running for ${durationAsString( - moment.duration(moment(new Date()).utc().diff(task.startedAt)) + moment.duration(moment(new Date()).utc().diff(taskRunner.startedAt)) )}` : `` - }${task.definition.timeout ? ` (with timeout set at ${task.definition.timeout})` : ``}.` + }${ + taskRunner.definition.timeout + ? ` (with timeout set at ${taskRunner.definition.timeout})` + : `` + }.` ); - this.cancelTask(task); + this.cancelTask(taskRunner); } } } @@ -188,7 +206,7 @@ export class TaskPool { private async cancelTask(task: TaskRunner) { try { this.logger.debug(`Cancelling task ${task.toString()}.`); - this.running.delete(task); + this.tasksInPool.delete(task.id); await task.cancel(); } catch (err) { this.logger.error(`Failed to cancel task ${task.toString()}: ${err}`); diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts index dff8c1f24de0a..5a36d6affe686 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts @@ -9,7 +9,7 @@ import _ from 'lodash'; import sinon from 'sinon'; import { secondsFromNow } from '../lib/intervals'; import { asOk, asErr } from '../lib/result_type'; -import { TaskManagerRunner, TaskRunResult } from '../task_running'; +import { TaskManagerRunner, TaskRunningStage, TaskRunResult } from '../task_running'; import { TaskEvent, asTaskRunEvent, asTaskMarkRunningEvent, TaskRun } from '../task_events'; import { ConcreteTaskInstance, TaskStatus } from '../task'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; @@ -17,6 +17,7 @@ import moment from 'moment'; import { TaskDefinitionRegistry, TaskTypeDictionary } from '../task_type_dictionary'; import { mockLogger } from '../test_utils'; import { throwUnrecoverableError } from './errors'; +import { taskStoreMock } from '../task_store.mock'; const minutesFromNow = (mins: number): Date => secondsFromNow(mins * 60); @@ -29,980 +30,834 @@ beforeAll(() => { afterAll(() => fakeTimer.restore()); describe('TaskManagerRunner', () => { - test('provides details about the task that is running', () => { - const { runner } = testOpts({ - instance: { - id: 'foo', - taskType: 'bar', - }, - }); + const pendingStageSetup = (opts: TestOpts) => testOpts(TaskRunningStage.PENDING, opts); + const readyToRunStageSetup = (opts: TestOpts) => testOpts(TaskRunningStage.READY_TO_RUN, opts); - expect(runner.id).toEqual('foo'); - expect(runner.taskType).toEqual('bar'); - expect(runner.toString()).toEqual('bar "foo"'); - }); - - test('queues a reattempt if the task fails', async () => { - const initialAttempts = _.random(0, 2); - const id = Date.now().toString(); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - params: { a: 'b' }, - state: { hey: 'there' }, - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - throw new Error('Dangit!'); - }, - }), + describe('Pending Stage', () => { + test('provides details about the task that is running', async () => { + const { runner } = await pendingStageSetup({ + instance: { + id: 'foo', + taskType: 'bar', }, - }, + }); + + expect(runner.id).toEqual('foo'); + expect(runner.taskType).toEqual('bar'); + expect(runner.toString()).toEqual('bar "foo"'); }); - await runner.run(); + test('calculates retryAt by schedule when running a recurring task', async () => { + const intervalMinutes = 10; + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(0, 2); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: { + interval: `${intervalMinutes}m`, + }, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + await runner.markTaskAsRunning(); - expect(instance.id).toEqual(id); - expect(instance.runAt.getTime()).toEqual(minutesFromNow(initialAttempts * 5).getTime()); - expect(instance.params).toEqual({ a: 'b' }); - expect(instance.state).toEqual({ hey: 'there' }); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test('reschedules tasks that have an schedule', async () => { - const { runner, store } = testOpts({ - instance: { - schedule: { interval: '10m' }, - status: TaskStatus.Running, - startedAt: new Date(), - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - return { state: {} }; - }, - }), - }, - }, + expect(instance.retryAt!.getTime()).toEqual( + instance.startedAt!.getTime() + intervalMinutes * 60 * 1000 + ); }); - await runner.run(); + test('calculates retryAt by default timout when it exceeds the schedule of a recurring task', async () => { + const intervalSeconds = 20; + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(0, 2); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: { + interval: `${intervalSeconds}s`, + }, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + await runner.markTaskAsRunning(); - expect(instance.runAt.getTime()).toBeGreaterThan(minutesFromNow(9).getTime()); - expect(instance.runAt.getTime()).toBeLessThanOrEqual(minutesFromNow(10).getTime()); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test('expiration returns time after which timeout will have elapsed from start', async () => { - const now = moment(); - const { runner } = testOpts({ - instance: { - schedule: { interval: '10m' }, - status: TaskStatus.Running, - startedAt: now.toDate(), - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `1m`, - createTaskRunner: () => ({ - async run() { - return { state: {} }; - }, - }), - }, - }, + expect(instance.retryAt!.getTime()).toEqual(instance.startedAt!.getTime() + 5 * 60 * 1000); }); - await runner.run(); - - expect(runner.isExpired).toBe(false); - expect(runner.expiration).toEqual(now.add(1, 'm').toDate()); - }); - - test('runDuration returns duration which has elapsed since start', async () => { - const now = moment().subtract(30, 's').toDate(); - const { runner } = testOpts({ - instance: { - schedule: { interval: '10m' }, - status: TaskStatus.Running, - startedAt: now, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `1m`, - createTaskRunner: () => ({ - async run() { - return { state: {} }; - }, - }), + test('calculates retryAt by timeout if it exceeds the schedule when running a recurring task', async () => { + const timeoutMinutes = 1; + const intervalSeconds = 20; + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(0, 2); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: { + interval: `${intervalSeconds}s`, + }, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.run(); + await runner.markTaskAsRunning(); - expect(runner.isExpired).toBe(false); - expect(runner.startedAt).toEqual(now); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test('reschedules tasks that return a runAt', async () => { - const runAt = minutesFromNow(_.random(1, 10)); - const { runner, store } = testOpts({ - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - return { runAt, state: {} }; - }, - }), - }, - }, + expect(instance.retryAt!.getTime()).toEqual( + instance.startedAt!.getTime() + timeoutMinutes * 60 * 1000 + ); }); - await runner.run(); - - sinon.assert.calledOnce(store.update); - sinon.assert.calledWithMatch(store.update, { runAt }); - }); - - test('reschedules tasks that return a schedule', async () => { - const runAt = minutesFromNow(1); - const schedule = { - interval: '1m', - }; - const { runner, store } = testOpts({ - instance: { - status: TaskStatus.Running, - startedAt: new Date(), - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - return { schedule, state: {} }; - }, - }), + test('sets startedAt, status, attempts and retryAt when claiming a task', async () => { + const timeoutMinutes = 1; + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(0, 2); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.run(); + await runner.markTaskAsRunning(); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWithMatch(store.update, { runAt }); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test(`doesn't reschedule recurring tasks that throw an unrecoverable error`, async () => { - const id = _.random(1, 20).toString(); - const error = new Error('Dangit!'); - const onTaskEvent = jest.fn(); - const { runner, store, instance: originalInstance } = testOpts({ - onTaskEvent, - instance: { id, status: TaskStatus.Running, startedAt: new Date() }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - throwUnrecoverableError(error); - }, - }), - }, - }, + expect(instance.attempts).toEqual(initialAttempts + 1); + expect(instance.status).toBe('running'); + expect(instance.startedAt!.getTime()).toEqual(Date.now()); + expect(instance.retryAt!.getTime()).toEqual( + minutesFromNow((initialAttempts + 1) * 5).getTime() + timeoutMinutes * 60 * 1000 + ); }); - await runner.run(); - - const instance = store.update.args[0][0]; - expect(instance.status).toBe('failed'); - - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming( - asTaskRunEvent( + test('uses getRetry (returning date) to set retryAt when defined', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { id, - asErr({ - error, - task: originalInstance, - result: TaskRunResult.Failed, - }) - ) - ) - ); - expect(onTaskEvent).toHaveBeenCalledTimes(1); - }); - - test('tasks that return runAt override the schedule', async () => { - const runAt = minutesFromNow(_.random(5)); - const { runner, store } = testOpts({ - instance: { - schedule: { interval: '20m' }, - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - return { runAt, state: {} }; - }, - }), + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.run(); + await runner.markTaskAsRunning(); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWithMatch(store.update, { runAt }); - }); + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts + 1); + const instance = store.update.mock.calls[0][0]; - test('removes non-recurring tasks after they complete', async () => { - const id = _.random(1, 20).toString(); - const { runner, store } = testOpts({ - instance: { - id, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - return undefined; - }, - }), - }, - }, + expect(instance.retryAt!.getTime()).toEqual( + new Date(nextRetry.getTime() + timeoutMinutes * 60 * 1000).getTime() + ); }); - await runner.run(); - - sinon.assert.calledOnce(store.remove); - sinon.assert.calledWith(store.remove, id); - }); - - test('cancel cancels the task runner, if it is cancellable', async () => { - let wasCancelled = false; - const { runner, logger } = testOpts({ - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - const promise = new Promise((r) => setTimeout(r, 1000)); - fakeTimer.tick(1000); - await promise; - }, - async cancel() { - wasCancelled = true; - }, - }), + test('it returns false when markTaskAsRunning fails due to VERSION_CONFLICT_STATUS', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - const promise = runner.run(); - await Promise.resolve(); - await runner.cancel(); - await promise; + store.update.mockRejectedValue( + SavedObjectsErrorHelpers.decorateConflictError(new Error('repo error')) + ); - expect(wasCancelled).toBeTruthy(); - expect(logger.warn).not.toHaveBeenCalled(); - }); + expect(await runner.markTaskAsRunning()).toEqual(false); + }); - test('debug logs if cancel is called on a non-cancellable task', async () => { - const { runner, logger } = testOpts({ - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - run: async () => undefined, - }), + test('it throw when markTaskAsRunning fails for unexpected reasons', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - const promise = runner.run(); - await runner.cancel(); - await promise; + store.update.mockRejectedValue( + SavedObjectsErrorHelpers.createGenericNotFoundError('type', 'id') + ); - expect(logger.debug).toHaveBeenCalledWith(`The task bar "foo" is not cancellable.`); - }); + return expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( + `[Error: Saved object [type/id] not found]` + ); + }); - test('sets startedAt, status, attempts and retryAt when claiming a task', async () => { - const timeoutMinutes = 1; - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(0, 2); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - createTaskRunner: () => ({ - run: async () => undefined, - }), + test(`it tries to increment a task's attempts when markTaskAsRunning fails for unexpected reasons`, async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.markTaskAsRunning(); + store.update.mockRejectedValueOnce(SavedObjectsErrorHelpers.createBadRequestError('type')); + store.update.mockResolvedValueOnce( + mockInstance({ + id, + attempts: initialAttempts, + schedule: undefined, + }) + ); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + await expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( + `[Error: type: Bad Request]` + ); - expect(instance.attempts).toEqual(initialAttempts + 1); - expect(instance.status).toBe('running'); - expect(instance.startedAt.getTime()).toEqual(Date.now()); - expect(instance.retryAt.getTime()).toEqual( - minutesFromNow((initialAttempts + 1) * 5).getTime() + timeoutMinutes * 60 * 1000 - ); - }); + expect(store.update).toHaveBeenCalledWith({ + ...mockInstance({ + id, + attempts: initialAttempts + 1, + schedule: undefined, + }), + status: TaskStatus.Idle, + startedAt: null, + retryAt: null, + ownerId: null, + }); + }); - test('calculates retryAt by schedule when running a recurring task', async () => { - const intervalMinutes = 10; - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(0, 2); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { - interval: `${intervalMinutes}m`, + test(`it doesnt try to increment a task's attempts when markTaskAsRunning fails for version conflict`, async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - run: async () => undefined, - }), + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, }, - }, - }); + }); - await runner.markTaskAsRunning(); + store.update.mockRejectedValueOnce( + SavedObjectsErrorHelpers.createConflictError('type', 'id') + ); + store.update.mockResolvedValueOnce( + mockInstance({ + id, + attempts: initialAttempts, + schedule: undefined, + }) + ); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + await expect(runner.markTaskAsRunning()).resolves.toMatchInlineSnapshot(`false`); - expect(instance.retryAt.getTime()).toEqual( - instance.startedAt.getTime() + intervalMinutes * 60 * 1000 - ); - }); + expect(store.update).toHaveBeenCalledTimes(1); + }); - test('calculates retryAt by default timout when it exceeds the schedule of a recurring task', async () => { - const intervalSeconds = 20; - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(0, 2); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { - interval: `${intervalSeconds}s`, + test(`it doesnt try to increment a task's attempts when markTaskAsRunning fails due to Saved Object not being found`, async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - run: async () => undefined, - }), + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, }, - }, - }); + }); - await runner.markTaskAsRunning(); + store.update.mockRejectedValueOnce( + SavedObjectsErrorHelpers.createGenericNotFoundError('type', 'id') + ); + store.update.mockResolvedValueOnce( + mockInstance({ + id, + attempts: initialAttempts, + schedule: undefined, + }) + ); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + await expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( + `[Error: Saved object [type/id] not found]` + ); - expect(instance.retryAt.getTime()).toEqual(instance.startedAt.getTime() + 5 * 60 * 1000); - }); + expect(store.update).toHaveBeenCalledTimes(1); + }); - test('calculates retryAt by timeout if it exceeds the schedule when running a recurring task', async () => { - const timeoutMinutes = 1; - const intervalSeconds = 20; - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(0, 2); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { - interval: `${intervalSeconds}s`, + test('uses getRetry (returning true) to set retryAt when defined', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(true); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - createTaskRunner: () => ({ - run: async () => undefined, - }), + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, }, - }, - }); + }); - await runner.markTaskAsRunning(); + await runner.markTaskAsRunning(); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts + 1); + const instance = store.update.mock.calls[0][0]; - expect(instance.retryAt.getTime()).toEqual( - instance.startedAt.getTime() + timeoutMinutes * 60 * 1000 - ); - }); + const attemptDelay = (initialAttempts + 1) * 5 * 60 * 1000; + const timeoutDelay = timeoutMinutes * 60 * 1000; + expect(instance.retryAt!.getTime()).toEqual( + new Date(Date.now() + attemptDelay + timeoutDelay).getTime() + ); + }); - test('uses getRetry function (returning date) on error when defined', async () => { - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const id = Date.now().toString(); - const getRetryStub = sinon.stub().returns(nextRetry); - const error = new Error('Dangit!'); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - }, - definitions: { - bar: { - title: 'Bar!', - getRetry: getRetryStub, - createTaskRunner: () => ({ - async run() { - throw error; - }, - }), + test('uses getRetry (returning false) to set retryAt when defined', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(false); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.run(); + await runner.markTaskAsRunning(); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts, error); - const instance = store.update.args[0][0]; + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts + 1); + const instance = store.update.mock.calls[0][0]; - expect(instance.runAt.getTime()).toEqual(nextRetry.getTime()); - }); + expect(instance.retryAt!).toBeNull(); + expect(instance.status).toBe('running'); + }); - test('uses getRetry function (returning true) on error when defined', async () => { - const initialAttempts = _.random(1, 3); - const id = Date.now().toString(); - const getRetryStub = sinon.stub().returns(true); - const error = new Error('Dangit!'); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - }, - definitions: { - bar: { - title: 'Bar!', - getRetry: getRetryStub, - createTaskRunner: () => ({ - async run() { - throw error; - }, - }), + test('bypasses getRetry (returning false) of a recurring task to set retryAt when defined', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(false); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: { interval: '1m' }, + startedAt: new Date(), }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.run(); + await runner.markTaskAsRunning(); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts, error); - const instance = store.update.args[0][0]; + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.notCalled(getRetryStub); + const instance = store.update.mock.calls[0][0]; - const expectedRunAt = new Date(Date.now() + initialAttempts * 5 * 60 * 1000); - expect(instance.runAt.getTime()).toEqual(expectedRunAt.getTime()); - }); + const timeoutDelay = timeoutMinutes * 60 * 1000; + expect(instance.retryAt!.getTime()).toEqual(new Date(Date.now() + timeoutDelay).getTime()); + }); - test('uses getRetry function (returning false) on error when defined', async () => { - const initialAttempts = _.random(1, 3); - const id = Date.now().toString(); - const getRetryStub = sinon.stub().returns(false); - const error = new Error('Dangit!'); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - }, - definitions: { - bar: { - title: 'Bar!', - getRetry: getRetryStub, - createTaskRunner: () => ({ - async run() { - throw error; + describe('TaskEvents', () => { + test('emits TaskEvent when a task is marked as running', async () => { + const id = _.random(1, 20).toString(); + const onTaskEvent = jest.fn(); + const { runner, instance, store } = await pendingStageSetup({ + onTaskEvent, + instance: { + id, + }, + definitions: { + bar: { + title: 'Bar!', + timeout: `1m`, + createTaskRunner: () => ({ + run: async () => undefined, + }), }, - }), - }, - }, - }); + }, + }); - await runner.run(); + store.update.mockResolvedValueOnce(instance); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts, error); - const instance = store.update.args[0][0]; + await runner.markTaskAsRunning(); - expect(instance.status).toBe('failed'); - }); + expect(onTaskEvent).toHaveBeenCalledWith(asTaskMarkRunningEvent(id, asOk(instance))); + }); - test('bypasses getRetry function (returning false) on error of a recurring task', async () => { - const initialAttempts = _.random(1, 3); - const id = Date.now().toString(); - const getRetryStub = sinon.stub().returns(false); - const error = new Error('Dangit!'); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { interval: '1m' }, - startedAt: new Date(), - }, - definitions: { - bar: { - title: 'Bar!', - getRetry: getRetryStub, - createTaskRunner: () => ({ - async run() { - throw error; - }, - }), - }, - }, - }); + test('emits TaskEvent when a task fails to be marked as running', async () => { + expect.assertions(2); - await runner.run(); + const id = _.random(1, 20).toString(); + const onTaskEvent = jest.fn(); + const { runner, store } = await pendingStageSetup({ + onTaskEvent, + instance: { + id, + }, + definitions: { + bar: { + title: 'Bar!', + timeout: `1m`, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - sinon.assert.notCalled(getRetryStub); - const instance = store.update.args[0][0]; + store.update.mockRejectedValueOnce(new Error('cant mark as running')); - const nextIntervalDelay = 60000; // 1m - const expectedRunAt = new Date(Date.now() + nextIntervalDelay); - expect(instance.runAt.getTime()).toEqual(expectedRunAt.getTime()); + try { + await runner.markTaskAsRunning(); + } catch (err) { + expect(onTaskEvent).toHaveBeenCalledWith(asTaskMarkRunningEvent(id, asErr(err))); + } + expect(onTaskEvent).toHaveBeenCalledTimes(1); + }); + }); }); - test('uses getRetry (returning date) to set retryAt when defined', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), + describe('Ready To Run Stage', () => { + test('queues a reattempt if the task fails', async () => { + const initialAttempts = _.random(0, 2); + const id = Date.now().toString(); + const { runner, store } = await readyToRunStageSetup({ + instance: { + id, + attempts: initialAttempts, + params: { a: 'b' }, + state: { hey: 'there' }, }, - }, - }); - - await runner.markTaskAsRunning(); + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + throw new Error('Dangit!'); + }, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts + 1); - const instance = store.update.args[0][0]; + await runner.run(); - expect(instance.retryAt.getTime()).toEqual( - new Date(nextRetry.getTime() + timeoutMinutes * 60 * 1000).getTime() - ); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test('it returns false when markTaskAsRunning fails due to VERSION_CONFLICT_STATUS', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(instance.id).toEqual(id); + expect(instance.runAt.getTime()).toEqual(minutesFromNow(initialAttempts * 5).getTime()); + expect(instance.params).toEqual({ a: 'b' }); + expect(instance.state).toEqual({ hey: 'there' }); }); - store.update = sinon - .stub() - .throws(SavedObjectsErrorHelpers.decorateConflictError(new Error('repo error'))); - - expect(await runner.markTaskAsRunning()).toEqual(false); - }); - - test('it throw when markTaskAsRunning fails for unexpected reasons', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), + test('reschedules tasks that have an schedule', async () => { + const { runner, store } = await readyToRunStageSetup({ + instance: { + schedule: { interval: '10m' }, + status: TaskStatus.Running, + startedAt: new Date(), }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { state: {} }; + }, + }), + }, + }, + }); - store.update = sinon - .stub() - .throws(SavedObjectsErrorHelpers.createGenericNotFoundError('type', 'id')); + await runner.run(); - return expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( - `[Error: Saved object [type/id] not found]` - ); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test(`it tries to increment a task's attempts when markTaskAsRunning fails for unexpected reasons`, async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(instance.runAt.getTime()).toBeGreaterThan(minutesFromNow(9).getTime()); + expect(instance.runAt.getTime()).toBeLessThanOrEqual(minutesFromNow(10).getTime()); }); - store.update = sinon.stub(); - store.update.onFirstCall().throws(SavedObjectsErrorHelpers.createBadRequestError('type')); - store.update.onSecondCall().resolves(); + test('expiration returns time after which timeout will have elapsed from start', async () => { + const now = moment(); + const { runner } = await readyToRunStageSetup({ + instance: { + schedule: { interval: '10m' }, + status: TaskStatus.Running, + startedAt: now.toDate(), + }, + definitions: { + bar: { + title: 'Bar!', + timeout: `1m`, + createTaskRunner: () => ({ + async run() { + return { state: {} }; + }, + }), + }, + }, + }); - await expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( - `[Error: type: Bad Request]` - ); + await runner.run(); - sinon.assert.calledWith(store.update, { - ...mockInstance({ - id, - attempts: initialAttempts + 1, - schedule: undefined, - }), - status: TaskStatus.Idle, - startedAt: null, - retryAt: null, - ownerId: null, + expect(runner.isExpired).toBe(false); + expect(runner.expiration).toEqual(now.add(1, 'm').toDate()); }); - }); - test(`it doesnt try to increment a task's attempts when markTaskAsRunning fails for version conflict`, async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), + test('runDuration returns duration which has elapsed since start', async () => { + const now = moment().subtract(30, 's').toDate(); + const { runner } = await readyToRunStageSetup({ + instance: { + schedule: { interval: '10m' }, + status: TaskStatus.Running, + startedAt: now, }, - }, - }); - - store.update = sinon.stub(); - store.update.onFirstCall().throws(SavedObjectsErrorHelpers.createConflictError('type', 'id')); - store.update.onSecondCall().resolves(); - - await expect(runner.markTaskAsRunning()).resolves.toMatchInlineSnapshot(`false`); + definitions: { + bar: { + title: 'Bar!', + timeout: `1m`, + createTaskRunner: () => ({ + async run() { + return { state: {} }; + }, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - }); + await runner.run(); - test(`it doesnt try to increment a task's attempts when markTaskAsRunning fails due to Saved Object not being found`, async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(runner.isExpired).toBe(false); + expect(runner.startedAt).toEqual(now); }); - store.update = sinon.stub(); - store.update - .onFirstCall() - .throws(SavedObjectsErrorHelpers.createGenericNotFoundError('type', 'id')); - store.update.onSecondCall().resolves(); - - await expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( - `[Error: Saved object [type/id] not found]` - ); + test('reschedules tasks that return a runAt', async () => { + const runAt = minutesFromNow(_.random(1, 10)); + const { runner, store } = await readyToRunStageSetup({ + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { runAt, state: {} }; + }, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - }); + await runner.run(); - test('uses getRetry (returning true) to set retryAt when defined', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(true); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(store.update).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledWith(expect.objectContaining({ runAt })); }); - await runner.markTaskAsRunning(); - - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts + 1); - const instance = store.update.args[0][0]; + test('reschedules tasks that return a schedule', async () => { + const runAt = minutesFromNow(1); + const schedule = { + interval: '1m', + }; + const { runner, store } = await readyToRunStageSetup({ + instance: { + status: TaskStatus.Running, + startedAt: new Date(), + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { schedule, state: {} }; + }, + }), + }, + }, + }); - const attemptDelay = (initialAttempts + 1) * 5 * 60 * 1000; - const timeoutDelay = timeoutMinutes * 60 * 1000; - expect(instance.retryAt.getTime()).toEqual( - new Date(Date.now() + attemptDelay + timeoutDelay).getTime() - ); - }); + await runner.run(); - test('uses getRetry (returning false) to set retryAt when defined', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(false); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(store.update).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledWith(expect.objectContaining({ runAt })); }); - await runner.markTaskAsRunning(); + test(`doesn't reschedule recurring tasks that throw an unrecoverable error`, async () => { + const id = _.random(1, 20).toString(); + const error = new Error('Dangit!'); + const onTaskEvent = jest.fn(); + const { runner, store, instance: originalInstance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { id, status: TaskStatus.Running, startedAt: new Date() }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + throwUnrecoverableError(error); + }, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts + 1); - const instance = store.update.args[0][0]; + await runner.run(); - expect(instance.retryAt).toBeNull(); - expect(instance.status).toBe('running'); - }); + const instance = store.update.mock.calls[0][0]; + expect(instance.status).toBe('failed'); - test('bypasses getRetry (returning false) of a recurring task to set retryAt when defined', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(false); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { interval: '1m' }, - startedAt: new Date(), - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming( + asTaskRunEvent( + id, + asErr({ + error, + task: originalInstance, + result: TaskRunResult.Failed, + }) + ) + ) + ); + expect(onTaskEvent).toHaveBeenCalledTimes(1); }); - await runner.markTaskAsRunning(); + test('tasks that return runAt override the schedule', async () => { + const runAt = minutesFromNow(_.random(5)); + const { runner, store } = await readyToRunStageSetup({ + instance: { + schedule: { interval: '20m' }, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { runAt, state: {} }; + }, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - sinon.assert.notCalled(getRetryStub); - const instance = store.update.args[0][0]; + await runner.run(); - const timeoutDelay = timeoutMinutes * 60 * 1000; - expect(instance.retryAt.getTime()).toEqual(new Date(Date.now() + timeoutDelay).getTime()); - }); + expect(store.update).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledWith(expect.objectContaining({ runAt })); + }); - test('Fails non-recurring task when maxAttempts reached', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = 3; - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - maxAttempts: 3, - createTaskRunner: () => ({ - run: async () => { - throw new Error(); - }, - }), + test('removes non-recurring tasks after they complete', async () => { + const id = _.random(1, 20).toString(); + const { runner, store } = await readyToRunStageSetup({ + instance: { + id, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return undefined; + }, + }), + }, + }, + }); - await runner.run(); + await runner.run(); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; - expect(instance.attempts).toEqual(3); - expect(instance.status).toEqual('failed'); - expect(instance.retryAt).toBeNull(); - expect(instance.runAt.getTime()).toBeLessThanOrEqual(Date.now()); - }); + expect(store.remove).toHaveBeenCalledTimes(1); + expect(store.remove).toHaveBeenCalledWith(id); + }); - test(`Doesn't fail recurring tasks when maxAttempts reached`, async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = 3; - const intervalSeconds = 10; - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { interval: `${intervalSeconds}s` }, - startedAt: new Date(), - }, - definitions: { - bar: { - title: 'Bar!', - maxAttempts: 3, - createTaskRunner: () => ({ - run: async () => { - throw new Error(); - }, - }), + test('cancel cancels the task runner, if it is cancellable', async () => { + let wasCancelled = false; + const { runner, logger } = await readyToRunStageSetup({ + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + const promise = new Promise((r) => setTimeout(r, 1000)); + fakeTimer.tick(1000); + await promise; + }, + async cancel() { + wasCancelled = true; + }, + }), + }, }, - }, - }); + }); - await runner.run(); + const promise = runner.run(); + await Promise.resolve(); + await runner.cancel(); + await promise; - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; - expect(instance.attempts).toEqual(3); - expect(instance.status).toEqual('idle'); - expect(instance.runAt.getTime()).toEqual( - new Date(Date.now() + intervalSeconds * 1000).getTime() - ); - }); + expect(wasCancelled).toBeTruthy(); + expect(logger.warn).not.toHaveBeenCalled(); + }); - describe('TaskEvents', () => { - test('emits TaskEvent when a task is marked as running', async () => { - const id = _.random(1, 20).toString(); - const onTaskEvent = jest.fn(); - const { runner, instance, store } = testOpts({ - onTaskEvent, - instance: { - id, - }, + test('debug logs if cancel is called on a non-cancellable task', async () => { + const { runner, logger } = await readyToRunStageSetup({ definitions: { bar: { title: 'Bar!', - timeout: `1m`, createTaskRunner: () => ({ run: async () => undefined, }), @@ -1010,58 +865,63 @@ describe('TaskManagerRunner', () => { }, }); - store.update.returns(instance); + const promise = runner.run(); + await runner.cancel(); + await promise; - await runner.markTaskAsRunning(); - - expect(onTaskEvent).toHaveBeenCalledWith(asTaskMarkRunningEvent(id, asOk(instance))); + expect(logger.debug).toHaveBeenCalledWith(`The task bar "foo" is not cancellable.`); }); - test('emits TaskEvent when a task fails to be marked as running', async () => { - expect.assertions(2); - - const id = _.random(1, 20).toString(); - const onTaskEvent = jest.fn(); - const { runner, store } = testOpts({ - onTaskEvent, + test('uses getRetry function (returning date) on error when defined', async () => { + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const id = Date.now().toString(); + const getRetryStub = sinon.stub().returns(nextRetry); + const error = new Error('Dangit!'); + const { runner, store } = await readyToRunStageSetup({ instance: { id, + attempts: initialAttempts, }, definitions: { bar: { title: 'Bar!', - timeout: `1m`, + getRetry: getRetryStub, createTaskRunner: () => ({ - run: async () => undefined, + async run() { + throw error; + }, }), }, }, }); - store.update.throws(new Error('cant mark as running')); + await runner.run(); - try { - await runner.markTaskAsRunning(); - } catch (err) { - expect(onTaskEvent).toHaveBeenCalledWith(asTaskMarkRunningEvent(id, asErr(err))); - } - expect(onTaskEvent).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts, error); + const instance = store.update.mock.calls[0][0]; + + expect(instance.runAt.getTime()).toEqual(nextRetry.getTime()); }); - test('emits TaskEvent when a task is run successfully', async () => { - const id = _.random(1, 20).toString(); - const onTaskEvent = jest.fn(); - const { runner, instance } = testOpts({ - onTaskEvent, + test('uses getRetry function (returning true) on error when defined', async () => { + const initialAttempts = _.random(1, 3); + const id = Date.now().toString(); + const getRetryStub = sinon.stub().returns(true); + const error = new Error('Dangit!'); + const { runner, store } = await readyToRunStageSetup({ instance: { id, + attempts: initialAttempts, }, definitions: { bar: { title: 'Bar!', + getRetry: getRetryStub, createTaskRunner: () => ({ async run() { - return { state: {} }; + throw error; }, }), }, @@ -1070,27 +930,31 @@ describe('TaskManagerRunner', () => { await runner.run(); - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming(asTaskRunEvent(id, asOk({ task: instance, result: TaskRunResult.Success }))) - ); + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts, error); + const instance = store.update.mock.calls[0][0]; + + const expectedRunAt = new Date(Date.now() + initialAttempts * 5 * 60 * 1000); + expect(instance.runAt.getTime()).toEqual(expectedRunAt.getTime()); }); - test('emits TaskEvent when a recurring task is run successfully', async () => { - const id = _.random(1, 20).toString(); - const runAt = minutesFromNow(_.random(5)); - const onTaskEvent = jest.fn(); - const { runner, instance } = testOpts({ - onTaskEvent, + test('uses getRetry function (returning false) on error when defined', async () => { + const initialAttempts = _.random(1, 3); + const id = Date.now().toString(); + const getRetryStub = sinon.stub().returns(false); + const error = new Error('Dangit!'); + const { runner, store } = await readyToRunStageSetup({ instance: { id, - schedule: { interval: '1m' }, + attempts: initialAttempts, }, definitions: { bar: { title: 'Bar!', + getRetry: getRetryStub, createTaskRunner: () => ({ async run() { - return { runAt, state: {} }; + throw error; }, }), }, @@ -1099,23 +963,29 @@ describe('TaskManagerRunner', () => { await runner.run(); - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming(asTaskRunEvent(id, asOk({ task: instance, result: TaskRunResult.Success }))) - ); + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts, error); + const instance = store.update.mock.calls[0][0]; + + expect(instance.status).toBe('failed'); }); - test('emits TaskEvent when a task run throws an error', async () => { - const id = _.random(1, 20).toString(); + test('bypasses getRetry function (returning false) on error of a recurring task', async () => { + const initialAttempts = _.random(1, 3); + const id = Date.now().toString(); + const getRetryStub = sinon.stub().returns(false); const error = new Error('Dangit!'); - const onTaskEvent = jest.fn(); - const { runner, instance } = testOpts({ - onTaskEvent, + const { runner, store } = await readyToRunStageSetup({ instance: { id, + attempts: initialAttempts, + schedule: { interval: '1m' }, + startedAt: new Date(), }, definitions: { bar: { title: 'Bar!', + getRetry: getRetryStub, createTaskRunner: () => ({ async run() { throw error; @@ -1124,33 +994,34 @@ describe('TaskManagerRunner', () => { }, }, }); + await runner.run(); - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming( - asTaskRunEvent(id, asErr({ error, task: instance, result: TaskRunResult.RetryScheduled })) - ) - ); - expect(onTaskEvent).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.notCalled(getRetryStub); + const instance = store.update.mock.calls[0][0]; + + const nextIntervalDelay = 60000; // 1m + const expectedRunAt = new Date(Date.now() + nextIntervalDelay); + expect(instance.runAt.getTime()).toEqual(expectedRunAt.getTime()); }); - test('emits TaskEvent when a task run returns an error', async () => { + test('Fails non-recurring task when maxAttempts reached', async () => { const id = _.random(1, 20).toString(); - const error = new Error('Dangit!'); - const onTaskEvent = jest.fn(); - const { runner, instance } = testOpts({ - onTaskEvent, + const initialAttempts = 3; + const { runner, store } = await readyToRunStageSetup({ instance: { id, - schedule: { interval: '1m' }, - startedAt: new Date(), + attempts: initialAttempts, + schedule: undefined, }, definitions: { bar: { title: 'Bar!', + maxAttempts: 3, createTaskRunner: () => ({ - async run() { - return { error, state: {} }; + run: async () => { + throw new Error(); }, }), }, @@ -1159,31 +1030,32 @@ describe('TaskManagerRunner', () => { await runner.run(); - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming( - asTaskRunEvent(id, asErr({ error, task: instance, result: TaskRunResult.RetryScheduled })) - ) - ); - expect(onTaskEvent).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; + expect(instance.attempts).toEqual(3); + expect(instance.status).toEqual('failed'); + expect(instance.retryAt!).toBeNull(); + expect(instance.runAt.getTime()).toBeLessThanOrEqual(Date.now()); }); - test('emits TaskEvent when a task returns an error and is marked as failed', async () => { + test(`Doesn't fail recurring tasks when maxAttempts reached`, async () => { const id = _.random(1, 20).toString(); - const error = new Error('Dangit!'); - const onTaskEvent = jest.fn(); - const { runner, store, instance: originalInstance } = testOpts({ - onTaskEvent, + const initialAttempts = 3; + const intervalSeconds = 10; + const { runner, store } = await readyToRunStageSetup({ instance: { id, + attempts: initialAttempts, + schedule: { interval: `${intervalSeconds}s` }, startedAt: new Date(), }, definitions: { bar: { title: 'Bar!', - getRetry: () => false, + maxAttempts: 3, createTaskRunner: () => ({ - async run() { - return { error, state: {} }; + run: async () => { + throw new Error(); }, }), }, @@ -1192,29 +1064,190 @@ describe('TaskManagerRunner', () => { await runner.run(); - const instance = store.update.args[0][0]; - expect(instance.status).toBe('failed'); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; + expect(instance.attempts).toEqual(3); + expect(instance.status).toEqual('idle'); + expect(instance.runAt.getTime()).toEqual( + new Date(Date.now() + intervalSeconds * 1000).getTime() + ); + }); - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming( - asTaskRunEvent( + describe('TaskEvents', () => { + test('emits TaskEvent when a task is run successfully', async () => { + const id = _.random(1, 20).toString(); + const onTaskEvent = jest.fn(); + const { runner, instance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { id, - asErr({ - error, - task: originalInstance, - result: TaskRunResult.Failed, - }) + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { state: {} }; + }, + }), + }, + }, + }); + + await runner.run(); + + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming(asTaskRunEvent(id, asOk({ task: instance, result: TaskRunResult.Success }))) + ); + }); + + test('emits TaskEvent when a recurring task is run successfully', async () => { + const id = _.random(1, 20).toString(); + const runAt = minutesFromNow(_.random(5)); + const onTaskEvent = jest.fn(); + const { runner, instance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { + id, + schedule: { interval: '1m' }, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { runAt, state: {} }; + }, + }), + }, + }, + }); + + await runner.run(); + + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming(asTaskRunEvent(id, asOk({ task: instance, result: TaskRunResult.Success }))) + ); + }); + + test('emits TaskEvent when a task run throws an error', async () => { + const id = _.random(1, 20).toString(); + const error = new Error('Dangit!'); + const onTaskEvent = jest.fn(); + const { runner, instance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { + id, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + throw error; + }, + }), + }, + }, + }); + await runner.run(); + + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming( + asTaskRunEvent( + id, + asErr({ error, task: instance, result: TaskRunResult.RetryScheduled }) + ) ) - ) - ); - expect(onTaskEvent).toHaveBeenCalledTimes(1); + ); + expect(onTaskEvent).toHaveBeenCalledTimes(1); + }); + + test('emits TaskEvent when a task run returns an error', async () => { + const id = _.random(1, 20).toString(); + const error = new Error('Dangit!'); + const onTaskEvent = jest.fn(); + const { runner, instance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { + id, + schedule: { interval: '1m' }, + startedAt: new Date(), + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { error, state: {} }; + }, + }), + }, + }, + }); + + await runner.run(); + + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming( + asTaskRunEvent( + id, + asErr({ error, task: instance, result: TaskRunResult.RetryScheduled }) + ) + ) + ); + expect(onTaskEvent).toHaveBeenCalledTimes(1); + }); + + test('emits TaskEvent when a task returns an error and is marked as failed', async () => { + const id = _.random(1, 20).toString(); + const error = new Error('Dangit!'); + const onTaskEvent = jest.fn(); + const { runner, store, instance: originalInstance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { + id, + startedAt: new Date(), + }, + definitions: { + bar: { + title: 'Bar!', + getRetry: () => false, + createTaskRunner: () => ({ + async run() { + return { error, state: {} }; + }, + }), + }, + }, + }); + + await runner.run(); + + const instance = store.update.mock.calls[0][0]; + expect(instance.status).toBe('failed'); + + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming( + asTaskRunEvent( + id, + asErr({ + error, + task: originalInstance, + result: TaskRunResult.Failed, + }) + ) + ) + ); + expect(onTaskEvent).toHaveBeenCalledTimes(1); + }); }); }); interface TestOpts { instance?: Partial; definitions?: TaskDefinitionRegistry; - onTaskEvent?: (event: TaskEvent) => void; + onTaskEvent?: jest.Mock<(event: TaskEvent) => void>; } function withAnyTiming(taskRun: TaskRun) { @@ -1247,20 +1280,16 @@ describe('TaskManagerRunner', () => { ); } - function testOpts(opts: TestOpts) { + async function testOpts(stage: TaskRunningStage, opts: TestOpts) { const callCluster = sinon.stub(); const createTaskRunner = sinon.stub(); const logger = mockLogger(); const instance = mockInstance(opts.instance); - const store = { - update: sinon.stub(), - remove: sinon.stub(), - maxAttempts: 5, - }; + const store = taskStoreMock.create(); - store.update.returns(instance); + store.update.mockResolvedValue(instance); const definitions = new TaskTypeDictionary(logger); definitions.registerTaskDefinitions({ @@ -1274,6 +1303,7 @@ describe('TaskManagerRunner', () => { } const runner = new TaskManagerRunner({ + defaultMaxAttempts: 5, beforeRun: (context) => Promise.resolve(context), beforeMarkRunning: (context) => Promise.resolve(context), logger, @@ -1283,6 +1313,15 @@ describe('TaskManagerRunner', () => { onTaskEvent: opts.onTaskEvent, }); + if (stage === TaskRunningStage.READY_TO_RUN) { + await runner.markTaskAsRunning(); + // as we're testing the ReadyToRun stage specifically, clear mocks cakked by setup + store.update.mockClear(); + if (opts.onTaskEvent) { + opts.onTaskEvent.mockClear(); + } + } + return { callCluster, createTaskRunner, diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.ts index ad5a2e11409ec..8e061eae46028 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.ts @@ -63,11 +63,22 @@ export interface TaskRunner { markTaskAsRunning: () => Promise; run: () => Promise>; id: string; + stage: string; toString: () => string; } +export enum TaskRunningStage { + PENDING = 'PENDING', + READY_TO_RUN = 'READY_TO_RUN', + RAN = 'RAN', +} +export interface TaskRunning { + timestamp: Date; + stage: Stage; + task: Instance; +} + export interface Updatable { - readonly maxAttempts: number; update(doc: ConcreteTaskInstance): Promise; remove(id: string): Promise; } @@ -78,6 +89,7 @@ type Opts = { instance: ConcreteTaskInstance; store: Updatable; onTaskEvent?: (event: TaskRun | TaskMarkRunning) => void; + defaultMaxAttempts: number; } & Pick; export enum TaskRunResult { @@ -91,6 +103,16 @@ export enum TaskRunResult { Failed = 'Failed', } +// A ConcreteTaskInstance which we *know* has a `startedAt` Date on it +type ConcreteTaskInstanceWithStartedAt = ConcreteTaskInstance & { startedAt: Date }; + +// The three possible stages for a Task Runner - Pending -> ReadyToRun -> Ran +type PendingTask = TaskRunning; +type ReadyToRunTask = TaskRunning; +type RanTask = TaskRunning; + +type TaskRunningInstance = PendingTask | ReadyToRunTask | RanTask; + /** * Runs a background task, ensures that errors are properly handled, * allows for cancellation. @@ -101,13 +123,14 @@ export enum TaskRunResult { */ export class TaskManagerRunner implements TaskRunner { private task?: CancellableTask; - private instance: ConcreteTaskInstance; + private instance: TaskRunningInstance; private definitions: TaskTypeDictionary; private logger: Logger; private bufferedTaskStore: Updatable; private beforeRun: Middleware['beforeRun']; private beforeMarkRunning: Middleware['beforeMarkRunning']; private onTaskEvent: (event: TaskRun | TaskMarkRunning) => void; + private defaultMaxAttempts: number; /** * Creates an instance of TaskManagerRunner. @@ -126,29 +149,38 @@ export class TaskManagerRunner implements TaskRunner { store, beforeRun, beforeMarkRunning, + defaultMaxAttempts, onTaskEvent = identity, }: Opts) { - this.instance = sanitizeInstance(instance); + this.instance = asPending(sanitizeInstance(instance)); this.definitions = definitions; this.logger = logger; this.bufferedTaskStore = store; this.beforeRun = beforeRun; this.beforeMarkRunning = beforeMarkRunning; this.onTaskEvent = onTaskEvent; + this.defaultMaxAttempts = defaultMaxAttempts; } /** * Gets the id of this task instance. */ public get id() { - return this.instance.id; + return this.instance.task.id; } /** * Gets the task type of this task instance. */ public get taskType() { - return this.instance.taskType; + return this.instance.task.taskType; + } + + /** + * Get the stage this TaskRunner is at + */ + public get stage() { + return this.instance.stage; } /** @@ -162,14 +194,21 @@ export class TaskManagerRunner implements TaskRunner { * Gets the time at which this task will expire. */ public get expiration() { - return intervalFromDate(this.instance.startedAt!, this.definition.timeout)!; + return intervalFromDate( + // if the task is running, use it's started at, otherwise use the timestamp at + // which it was last updated + // this allows us to catch tasks that remain in Pending/Finalizing without being + // cleaned up + isReadyToRun(this.instance) ? this.instance.task.startedAt : this.instance.timestamp, + this.definition.timeout + )!; } /** * Gets the duration of the current task run */ public get startedAt() { - return this.instance.startedAt; + return this.instance.task.startedAt; } /** @@ -195,9 +234,16 @@ export class TaskManagerRunner implements TaskRunner { * @returns {Promise>} */ public async run(): Promise> { + if (!isReadyToRun(this.instance)) { + throw new Error( + `Running task ${this} failed as it ${ + isPending(this.instance) ? `isn't ready to be ran` : `has already been ran` + }` + ); + } this.logger.debug(`Running task ${this}`); const modifiedContext = await this.beforeRun({ - taskInstance: this.instance, + taskInstance: this.instance.task, }); const stopTaskTimer = startTaskTimer(); @@ -230,10 +276,16 @@ export class TaskManagerRunner implements TaskRunner { * @returns {Promise} */ public async markTaskAsRunning(): Promise { + if (!isPending(this.instance)) { + throw new Error( + `Marking task ${this} as running has failed as it ${ + isReadyToRun(this.instance) ? `is already running` : `has already been ran` + }` + ); + } performance.mark('markTaskAsRunning_start'); const apmTrans = apm.startTransaction(`taskManager markTaskAsRunning`, 'taskManager'); - apmTrans?.addLabels({ taskType: this.taskType, }); @@ -241,7 +293,7 @@ export class TaskManagerRunner implements TaskRunner { const now = new Date(); try { const { taskInstance } = await this.beforeMarkRunning({ - taskInstance: this.instance, + taskInstance: this.instance.task, }); const attempts = taskInstance.attempts + 1; @@ -258,22 +310,29 @@ export class TaskManagerRunner implements TaskRunner { ); } - this.instance = await this.bufferedTaskStore.update({ - ...taskInstance, - status: TaskStatus.Running, - startedAt: now, - attempts, - retryAt: - (this.instance.schedule - ? maxIntervalFromDate(now, this.instance.schedule!.interval, this.definition.timeout) - : this.getRetryDelay({ - attempts, - // Fake an error. This allows retry logic when tasks keep timing out - // and lets us set a proper "retryAt" value each time. - error: new Error('Task timeout'), - addDuration: this.definition.timeout, - })) ?? null, - }); + this.instance = asReadyToRun( + (await this.bufferedTaskStore.update({ + ...taskInstance, + status: TaskStatus.Running, + startedAt: now, + attempts, + retryAt: + (this.instance.task.schedule + ? maxIntervalFromDate( + now, + this.instance.task.schedule.interval, + this.definition.timeout + ) + : this.getRetryDelay({ + attempts, + // Fake an error. This allows retry logic when tasks keep timing out + // and lets us set a proper "retryAt" value each time. + error: new Error('Task timeout'), + addDuration: this.definition.timeout, + })) ?? null, + // This is a safe convertion as we're setting the startAt above + })) as ConcreteTaskInstanceWithStartedAt + ); const timeUntilClaimExpiresAfterUpdate = howManyMsUntilOwnershipClaimExpires( ownershipClaimedUntil @@ -288,7 +347,7 @@ export class TaskManagerRunner implements TaskRunner { if (apmTrans) apmTrans.end('success'); performanceStopMarkingTaskAsRunning(); - this.onTaskEvent(asTaskMarkRunningEvent(this.id, asOk(this.instance))); + this.onTaskEvent(asTaskMarkRunningEvent(this.id, asOk(this.instance.task))); return true; } catch (error) { if (apmTrans) apmTrans.end('failure'); @@ -299,7 +358,7 @@ export class TaskManagerRunner implements TaskRunner { // try to release claim as an unknown failure prevented us from marking as running mapErr((errReleaseClaim: Error) => { this.logger.error( - `[Task Runner] Task ${this.instance.id} failed to release claim after failure: ${errReleaseClaim}` + `[Task Runner] Task ${this.id} failed to release claim after failure: ${errReleaseClaim}` ); }, await this.releaseClaimAndIncrementAttempts()); } @@ -336,9 +395,9 @@ export class TaskManagerRunner implements TaskRunner { private async releaseClaimAndIncrementAttempts(): Promise> { return promiseResult( this.bufferedTaskStore.update({ - ...this.instance, + ...this.instance.task, status: TaskStatus.Idle, - attempts: this.instance.attempts + 1, + attempts: this.instance.task.attempts + 1, startedAt: null, retryAt: null, ownerId: null, @@ -347,12 +406,12 @@ export class TaskManagerRunner implements TaskRunner { } private shouldTryToScheduleRetry(): boolean { - if (this.instance.schedule) { + if (this.instance.task.schedule) { return true; } - const maxAttempts = this.definition.maxAttempts || this.bufferedTaskStore.maxAttempts; - return this.instance.attempts < maxAttempts; + const maxAttempts = this.definition.maxAttempts || this.defaultMaxAttempts; + return this.instance.task.attempts < maxAttempts; } private rescheduleFailedRun = ( @@ -361,7 +420,7 @@ export class TaskManagerRunner implements TaskRunner { const { state, error } = failureResult; if (this.shouldTryToScheduleRetry() && !isUnrecoverableError(error)) { // if we're retrying, keep the number of attempts - const { schedule, attempts } = this.instance; + const { schedule, attempts } = this.instance.task; const reschedule = failureResult.runAt ? { runAt: failureResult.runAt } @@ -399,7 +458,7 @@ export class TaskManagerRunner implements TaskRunner { // if retrying is possible (new runAt) or this is an recurring task - reschedule mapOk( ({ runAt, schedule: reschedule, state, attempts = 0 }: Partial) => { - const { startedAt, schedule } = this.instance; + const { startedAt, schedule } = this.instance.task; return asOk({ runAt: runAt || intervalFromDate(startedAt!, reschedule?.interval ?? schedule?.interval)!, @@ -413,16 +472,18 @@ export class TaskManagerRunner implements TaskRunner { unwrap )(result); - await this.bufferedTaskStore.update( - defaults( - { - ...fieldUpdates, - // reset fields that track the lifecycle of the concluded `task run` - startedAt: null, - retryAt: null, - ownerId: null, - }, - this.instance + this.instance = asRan( + await this.bufferedTaskStore.update( + defaults( + { + ...fieldUpdates, + // reset fields that track the lifecycle of the concluded `task run` + startedAt: null, + retryAt: null, + ownerId: null, + }, + this.instance.task + ) ) ); @@ -436,7 +497,8 @@ export class TaskManagerRunner implements TaskRunner { private async processResultWhenDone(): Promise { // not a recurring task: clean up by removing the task instance from store try { - await this.bufferedTaskStore.remove(this.instance.id); + await this.bufferedTaskStore.remove(this.id); + this.instance = asRan(this.instance.task); } catch (err) { if (err.statusCode === 404) { this.logger.warn(`Task cleanup of ${this} failed in processing. Was remove called twice?`); @@ -451,7 +513,7 @@ export class TaskManagerRunner implements TaskRunner { result: Result, taskTiming: TaskTiming ): Promise> { - const task = this.instance; + const { task } = this.instance; await eitherAsync( result, async ({ runAt, schedule }: SuccessfulRunResult) => { @@ -528,3 +590,38 @@ function performanceStopMarkingTaskAsRunning() { 'markTaskAsRunning_stop' ); } + +// A type that extracts the Instance type out of TaskRunningStage +// This helps us to better communicate to the developer what the expected "stage" +// in a specific place in the code might be +type InstanceOf = T extends TaskRunning ? I : never; + +function isPending(taskRunning: TaskRunningInstance): taskRunning is PendingTask { + return taskRunning.stage === TaskRunningStage.PENDING; +} +function asPending(task: InstanceOf): PendingTask { + return { + timestamp: new Date(), + stage: TaskRunningStage.PENDING, + task, + }; +} +function isReadyToRun(taskRunning: TaskRunningInstance): taskRunning is ReadyToRunTask { + return taskRunning.stage === TaskRunningStage.READY_TO_RUN; +} +function asReadyToRun( + task: InstanceOf +): ReadyToRunTask { + return { + timestamp: new Date(), + stage: TaskRunningStage.READY_TO_RUN, + task, + }; +} +function asRan(task: InstanceOf): RanTask { + return { + timestamp: new Date(), + stage: TaskRunningStage.RAN, + task, + }; +} diff --git a/x-pack/plugins/task_manager/server/task_scheduling.test.ts b/x-pack/plugins/task_manager/server/task_scheduling.test.ts index e495d416d5ab8..b142f2091291e 100644 --- a/x-pack/plugins/task_manager/server/task_scheduling.test.ts +++ b/x-pack/plugins/task_manager/server/task_scheduling.test.ts @@ -7,13 +7,14 @@ import _ from 'lodash'; import { Subject } from 'rxjs'; -import { none } from 'fp-ts/lib/Option'; +import { none, some } from 'fp-ts/lib/Option'; import { asTaskMarkRunningEvent, asTaskRunEvent, asTaskClaimEvent, asTaskRunRequestEvent, + TaskClaimErrorType, } from './task_events'; import { TaskLifecycleEvent } from './polling_lifecycle'; import { taskPollingLifecycleMock } from './polling_lifecycle.mock'; @@ -24,17 +25,28 @@ import { createInitialMiddleware } from './lib/middleware'; import { taskStoreMock } from './task_store.mock'; import { TaskRunResult } from './task_running'; import { mockLogger } from './test_utils'; +import { TaskTypeDictionary } from './task_type_dictionary'; describe('TaskScheduling', () => { const mockTaskStore = taskStoreMock.create({}); const mockTaskManager = taskPollingLifecycleMock.create({}); + const definitions = new TaskTypeDictionary(mockLogger()); const taskSchedulingOpts = { taskStore: mockTaskStore, taskPollingLifecycle: mockTaskManager, logger: mockLogger(), middleware: createInitialMiddleware(), + definitions, }; + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + beforeEach(() => { jest.resetAllMocks(); }); @@ -114,7 +126,7 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - const task = { id } as ConcreteTaskInstance; + const task = mockTask({ id }); events$.next(asTaskRunEvent(id, asOk({ task, result: TaskRunResult.Success }))); return expect(result).resolves.toEqual({ id }); @@ -131,7 +143,7 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - const task = { id } as ConcreteTaskInstance; + const task = mockTask({ id }); events$.next(asTaskClaimEvent(id, asOk(task))); events$.next(asTaskMarkRunningEvent(id, asOk(task))); events$.next( @@ -161,7 +173,7 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - const task = { id } as ConcreteTaskInstance; + const task = mockTask({ id }); events$.next(asTaskClaimEvent(id, asOk(task))); events$.next(asTaskMarkRunningEvent(id, asErr(new Error('some thing gone wrong')))); @@ -183,7 +195,12 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - events$.next(asTaskClaimEvent(id, asErr(none))); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ); await expect(result).rejects.toEqual( new Error(`Failed to run task "${id}" as it does not exist`) @@ -192,6 +209,34 @@ describe('TaskScheduling', () => { expect(mockTaskStore.getLifecycle).toHaveBeenCalledWith(id); }); + test('when a task claim due to insufficient capacity we return an explciit message', async () => { + const events$ = new Subject(); + const id = '01ddff11-e88a-4d13-bc4e-256164e755e2'; + + mockTaskStore.getLifecycle.mockResolvedValue(TaskLifecycleResult.NotFound); + + const taskScheduling = new TaskScheduling({ + ...taskSchedulingOpts, + taskPollingLifecycle: taskPollingLifecycleMock.create({ events$ }), + }); + + const result = taskScheduling.runNow(id); + + const task = mockTask({ id, taskType: 'foo' }); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: some(task), errorType: TaskClaimErrorType.CLAIMED_BY_ID_OUT_OF_CAPACITY }) + ) + ); + + await expect(result).rejects.toEqual( + new Error( + `Failed to run task "${id}" as we would exceed the max concurrency of "${task.taskType}" which is 2. Rescheduled the task to ensure it is picked up as soon as possible.` + ) + ); + }); + test('when a task claim fails we ensure the task isnt already claimed', async () => { const events$ = new Subject(); const id = '01ddff11-e88a-4d13-bc4e-256164e755e2'; @@ -205,7 +250,12 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - events$.next(asTaskClaimEvent(id, asErr(none))); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ); await expect(result).rejects.toEqual( new Error(`Failed to run task "${id}" as it is currently running`) @@ -227,7 +277,12 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - events$.next(asTaskClaimEvent(id, asErr(none))); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ); await expect(result).rejects.toEqual( new Error(`Failed to run task "${id}" as it is currently running`) @@ -270,7 +325,12 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - events$.next(asTaskClaimEvent(id, asErr(none))); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ); await expect(result).rejects.toMatchInlineSnapshot( `[Error: Failed to run task "01ddff11-e88a-4d13-bc4e-256164e755e2" for unknown reason (Current Task Lifecycle is "idle")]` @@ -292,7 +352,12 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - events$.next(asTaskClaimEvent(id, asErr(none))); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ); await expect(result).rejects.toMatchInlineSnapshot( `[Error: Failed to run task "01ddff11-e88a-4d13-bc4e-256164e755e2" for unknown reason (Current Task Lifecycle is "failed")]` @@ -313,7 +378,7 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - const task = { id } as ConcreteTaskInstance; + const task = mockTask({ id }); const otherTask = { id: differentTask } as ConcreteTaskInstance; events$.next(asTaskClaimEvent(id, asOk(task))); events$.next(asTaskClaimEvent(differentTask, asOk(otherTask))); @@ -338,3 +403,23 @@ describe('TaskScheduling', () => { }); }); }); + +function mockTask(overrides: Partial = {}): ConcreteTaskInstance { + return { + id: 'claimed-by-id', + runAt: new Date(), + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: '', + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + ...overrides, + }; +} diff --git a/x-pack/plugins/task_manager/server/task_scheduling.ts b/x-pack/plugins/task_manager/server/task_scheduling.ts index 8ccedb85c560d..29e83ec911b79 100644 --- a/x-pack/plugins/task_manager/server/task_scheduling.ts +++ b/x-pack/plugins/task_manager/server/task_scheduling.ts @@ -8,7 +8,7 @@ import { filter } from 'rxjs/operators'; import { pipe } from 'fp-ts/lib/pipeable'; -import { Option, map as mapOptional, getOrElse } from 'fp-ts/lib/Option'; +import { Option, map as mapOptional, getOrElse, isSome } from 'fp-ts/lib/Option'; import { Logger } from '../../../../src/core/server'; import { asOk, either, map, mapErr, promiseResult } from './lib/result_type'; @@ -20,6 +20,8 @@ import { ErroredTask, OkResultOf, ErrResultOf, + ClaimTaskErr, + TaskClaimErrorType, } from './task_events'; import { Middleware } from './lib/middleware'; import { @@ -33,6 +35,7 @@ import { import { TaskStore } from './task_store'; import { ensureDeprecatedFieldsAreCorrected } from './lib/correct_deprecated_fields'; import { TaskLifecycleEvent, TaskPollingLifecycle } from './polling_lifecycle'; +import { TaskTypeDictionary } from './task_type_dictionary'; const VERSION_CONFLICT_STATUS = 409; @@ -41,6 +44,7 @@ export interface TaskSchedulingOpts { taskStore: TaskStore; taskPollingLifecycle: TaskPollingLifecycle; middleware: Middleware; + definitions: TaskTypeDictionary; } interface RunNowResult { @@ -52,6 +56,7 @@ export class TaskScheduling { private taskPollingLifecycle: TaskPollingLifecycle; private logger: Logger; private middleware: Middleware; + private definitions: TaskTypeDictionary; /** * Initializes the task manager, preventing any further addition of middleware, @@ -63,6 +68,7 @@ export class TaskScheduling { this.middleware = opts.middleware; this.taskPollingLifecycle = opts.taskPollingLifecycle; this.store = opts.taskStore; + this.definitions = opts.definitions; } /** @@ -122,10 +128,27 @@ export class TaskScheduling { .pipe(filter(({ id }: TaskLifecycleEvent) => id === taskId)) .subscribe((taskEvent: TaskLifecycleEvent) => { if (isTaskClaimEvent(taskEvent)) { - mapErr(async (error: Option) => { + mapErr(async (error: ClaimTaskErr) => { // reject if any error event takes place for the requested task subscription.unsubscribe(); - return reject(await this.identifyTaskFailureReason(taskId, error)); + if ( + isSome(error.task) && + error.errorType === TaskClaimErrorType.CLAIMED_BY_ID_OUT_OF_CAPACITY + ) { + const task = error.task.value; + const definition = this.definitions.get(task.taskType); + return reject( + new Error( + `Failed to run task "${taskId}" as we would exceed the max concurrency of "${ + definition?.title ?? task.taskType + }" which is ${ + definition?.maxConcurrency + }. Rescheduled the task to ensure it is picked up as soon as possible.` + ) + ); + } else { + return reject(await this.identifyTaskFailureReason(taskId, error.task)); + } }, taskEvent.event); } else { either, ErrResultOf>( diff --git a/x-pack/plugins/task_manager/server/task_store.mock.ts b/x-pack/plugins/task_manager/server/task_store.mock.ts index d4f863af6fe3b..38d570f96220b 100644 --- a/x-pack/plugins/task_manager/server/task_store.mock.ts +++ b/x-pack/plugins/task_manager/server/task_store.mock.ts @@ -5,38 +5,27 @@ * 2.0. */ -import { Observable, Subject } from 'rxjs'; -import { TaskClaim } from './task_events'; - import { TaskStore } from './task_store'; interface TaskStoreOptions { - maxAttempts?: number; index?: string; taskManagerId?: string; - events?: Observable; } export const taskStoreMock = { - create({ - maxAttempts = 0, - index = '', - taskManagerId = '', - events = new Subject(), - }: TaskStoreOptions) { + create({ index = '', taskManagerId = '' }: TaskStoreOptions = {}) { const mocked = ({ + convertToSavedObjectIds: jest.fn(), update: jest.fn(), remove: jest.fn(), schedule: jest.fn(), - claimAvailableTasks: jest.fn(), bulkUpdate: jest.fn(), get: jest.fn(), getLifecycle: jest.fn(), fetch: jest.fn(), aggregate: jest.fn(), - maxAttempts, + updateByQuery: jest.fn(), index, taskManagerId, - events, } as unknown) as jest.Mocked; return mocked; }, diff --git a/x-pack/plugins/task_manager/server/task_store.test.ts b/x-pack/plugins/task_manager/server/task_store.test.ts index dbf13a5f27281..25ee8cb0e2374 100644 --- a/x-pack/plugins/task_manager/server/task_store.test.ts +++ b/x-pack/plugins/task_manager/server/task_store.test.ts @@ -6,19 +6,16 @@ */ import _ from 'lodash'; -import uuid from 'uuid'; -import { filter, take, first } from 'rxjs/operators'; -import { Option, some, none } from 'fp-ts/lib/Option'; +import { first } from 'rxjs/operators'; import { TaskInstance, TaskStatus, TaskLifecycleResult, SerializedConcreteTaskInstance, - ConcreteTaskInstance, } from './task'; import { elasticsearchServiceMock } from '../../../../src/core/server/mocks'; -import { StoreOpts, OwnershipClaimingOpts, TaskStore, SearchOpts } from './task_store'; +import { TaskStore, SearchOpts } from './task_store'; import { savedObjectsRepositoryMock } from 'src/core/server/mocks'; import { SavedObjectsSerializer, @@ -26,12 +23,8 @@ import { SavedObjectAttributes, SavedObjectsErrorHelpers, } from 'src/core/server'; -import { asTaskClaimEvent, TaskEvent } from './task_events'; -import { asOk, asErr } from './lib/result_type'; import { TaskTypeDictionary } from './task_type_dictionary'; import { RequestEvent } from '@elastic/elasticsearch/lib/Transport'; -import { Search, UpdateByQuery } from '@elastic/elasticsearch/api/requestParams'; -import { BoolClauseWithAnyCondition, TermFilter } from './queries/query_clauses'; import { mockLogger } from './test_utils'; const savedObjectsClient = savedObjectsRepositoryMock.create(); @@ -76,7 +69,6 @@ describe('TaskStore', () => { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -209,7 +201,6 @@ describe('TaskStore', () => { taskManagerId: '', serializer, esClient, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -265,809 +256,6 @@ describe('TaskStore', () => { }); }); - describe('claimAvailableTasks', () => { - async function testClaimAvailableTasks({ - opts = {}, - hits = generateFakeTasks(1), - claimingOpts, - versionConflicts = 2, - }: { - opts: Partial; - hits?: unknown[]; - claimingOpts: OwnershipClaimingOpts; - versionConflicts?: number; - }) { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.search.mockResolvedValue(asApiResponse({ hits: { hits } })); - esClient.updateByQuery.mockResolvedValue( - asApiResponse({ - total: hits.length + versionConflicts, - updated: hits.length, - version_conflicts: versionConflicts, - }) - ); - - const store = new TaskStore({ - esClient, - maxAttempts: 2, - definitions: taskDefinitions, - serializer, - savedObjectsRepository: savedObjectsClient, - taskManagerId: '', - index: '', - ...opts, - }); - - const result = await store.claimAvailableTasks(claimingOpts); - - expect(esClient.updateByQuery.mock.calls[0][0]).toMatchObject({ - max_docs: claimingOpts.size, - }); - expect(esClient.search.mock.calls[0][0]).toMatchObject({ body: { size: claimingOpts.size } }); - return { - result, - args: { - search: esClient.search.mock.calls[0][0]! as Search<{ - query: BoolClauseWithAnyCondition; - size: number; - sort: string | string[]; - }>, - updateByQuery: esClient.updateByQuery.mock.calls[0][0]! as UpdateByQuery<{ - query: BoolClauseWithAnyCondition; - size: number; - sort: string | string[]; - script: object; - }>, - }, - }; - } - - test('it returns normally with no tasks when the index does not exist.', async () => { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.updateByQuery.mockResolvedValue( - asApiResponse({ - total: 0, - updated: 0, - }) - ); - const store = new TaskStore({ - index: 'tasky', - taskManagerId: '', - serializer, - esClient, - definitions: taskDefinitions, - maxAttempts: 2, - savedObjectsRepository: savedObjectsClient, - }); - const { docs } = await store.claimAvailableTasks({ - claimOwnershipUntil: new Date(), - size: 10, - }); - expect(esClient.updateByQuery.mock.calls[0][0]).toMatchObject({ - ignore_unavailable: true, - max_docs: 10, - }); - expect(docs.length).toBe(0); - }); - - test('it filters claimed tasks down by supported types, maxAttempts, status, and runAt', async () => { - const maxAttempts = _.random(2, 43); - const customMaxAttempts = _.random(44, 100); - - const definitions = new TaskTypeDictionary(mockLogger()); - definitions.registerTaskDefinitions({ - foo: { - title: 'foo', - createTaskRunner: jest.fn(), - }, - bar: { - title: 'bar', - maxAttempts: customMaxAttempts, - createTaskRunner: jest.fn(), - }, - }); - - const { - args: { - updateByQuery: { body: { query, sort } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - maxAttempts, - definitions, - }, - claimingOpts: { claimOwnershipUntil: new Date(), size: 10 }, - }); - expect(query).toMatchObject({ - bool: { - must: [ - { term: { type: 'task' } }, - { - bool: { - must: [ - { - bool: { - must: [ - { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'task.status': 'idle' } }, - { range: { 'task.runAt': { lte: 'now' } } }, - ], - }, - }, - { - bool: { - must: [ - { - bool: { - should: [ - { term: { 'task.status': 'running' } }, - { term: { 'task.status': 'claiming' } }, - ], - }, - }, - { range: { 'task.retryAt': { lte: 'now' } } }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - ], - filter: [ - { - bool: { - must_not: [ - { - bool: { - should: [ - { term: { 'task.status': 'running' } }, - { term: { 'task.status': 'claiming' } }, - ], - must: { range: { 'task.retryAt': { gt: 'now' } } }, - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - }); - expect(sort).toMatchObject([ - { - _script: { - type: 'number', - order: 'asc', - script: { - lang: 'painless', - source: ` -if (doc['task.retryAt'].size()!=0) { - return doc['task.retryAt'].value.toInstant().toEpochMilli(); -} -if (doc['task.runAt'].size()!=0) { - return doc['task.runAt'].value.toInstant().toEpochMilli(); -} - `, - }, - }, - }, - ]); - }); - - test('it supports claiming specific tasks by id', async () => { - const maxAttempts = _.random(2, 43); - const customMaxAttempts = _.random(44, 100); - const definitions = new TaskTypeDictionary(mockLogger()); - const taskManagerId = uuid.v1(); - const fieldUpdates = { - ownerId: taskManagerId, - retryAt: new Date(Date.now()), - }; - definitions.registerTaskDefinitions({ - foo: { - title: 'foo', - createTaskRunner: jest.fn(), - }, - bar: { - title: 'bar', - maxAttempts: customMaxAttempts, - createTaskRunner: jest.fn(), - }, - }); - const { - args: { - updateByQuery: { body: { query, script, sort } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - maxAttempts, - definitions, - }, - claimingOpts: { - claimOwnershipUntil: new Date(), - size: 10, - claimTasksById: [ - '33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', - 'a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', - ], - }, - }); - - expect(query).toMatchObject({ - bool: { - must: [ - { term: { type: 'task' } }, - { - bool: { - must: [ - { - pinned: { - ids: [ - 'task:33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', - 'task:a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', - ], - organic: { - bool: { - must: [ - { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'task.status': 'idle' } }, - { range: { 'task.runAt': { lte: 'now' } } }, - ], - }, - }, - { - bool: { - must: [ - { - bool: { - should: [ - { term: { 'task.status': 'running' } }, - { term: { 'task.status': 'claiming' } }, - ], - }, - }, - { range: { 'task.retryAt': { lte: 'now' } } }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - }, - }, - ], - filter: [ - { - bool: { - must_not: [ - { - bool: { - should: [ - { term: { 'task.status': 'running' } }, - { term: { 'task.status': 'claiming' } }, - ], - must: { range: { 'task.retryAt': { gt: 'now' } } }, - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - }); - - expect(script).toMatchObject({ - source: ` - if (params.registeredTaskTypes.contains(ctx._source.task.taskType)) { - if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { - ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) - .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) - .join(' ')} - } else { - ctx._source.task.status = "failed"; - } - } else { - ctx._source.task.status = "unrecognized"; - } - `, - lang: 'painless', - params: { - fieldUpdates, - claimTasksById: [ - 'task:33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', - 'task:a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', - ], - registeredTaskTypes: ['foo', 'bar'], - taskMaxAttempts: { - bar: customMaxAttempts, - foo: maxAttempts, - }, - }, - }); - - expect(sort).toMatchObject([ - '_score', - { - _script: { - type: 'number', - order: 'asc', - script: { - lang: 'painless', - source: ` -if (doc['task.retryAt'].size()!=0) { - return doc['task.retryAt'].value.toInstant().toEpochMilli(); -} -if (doc['task.runAt'].size()!=0) { - return doc['task.runAt'].value.toInstant().toEpochMilli(); -} - `, - }, - }, - }, - ]); - }); - - test('it claims tasks by setting their ownerId, status and retryAt', async () => { - const taskManagerId = uuid.v1(); - const claimOwnershipUntil = new Date(Date.now()); - const fieldUpdates = { - ownerId: taskManagerId, - retryAt: claimOwnershipUntil, - }; - const { - args: { - updateByQuery: { body: { script } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - }, - claimingOpts: { - claimOwnershipUntil, - size: 10, - }, - }); - expect(script).toMatchObject({ - source: ` - if (params.registeredTaskTypes.contains(ctx._source.task.taskType)) { - if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { - ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) - .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) - .join(' ')} - } else { - ctx._source.task.status = "failed"; - } - } else { - ctx._source.task.status = "unrecognized"; - } - `, - lang: 'painless', - params: { - fieldUpdates, - claimTasksById: [], - registeredTaskTypes: ['report', 'dernstraight', 'yawn'], - taskMaxAttempts: { - dernstraight: 2, - report: 2, - yawn: 2, - }, - }, - }); - }); - - test('it filters out running tasks', async () => { - const taskManagerId = uuid.v1(); - const claimOwnershipUntil = new Date(Date.now()); - const runAt = new Date(); - const tasks = [ - { - _id: 'task:aaa', - _source: { - type: 'task', - task: { - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming', - params: '{ "hello": "world" }', - state: '{ "baby": "Henhen" }', - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - }, - }, - _seq_no: 1, - _primary_term: 2, - sort: ['a', 1], - }, - { - // this is invalid as it doesn't have the `type` prefix - _id: 'bbb', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'claiming', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - ]; - const { - result: { docs }, - args: { - search: { body: { query } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - }, - claimingOpts: { - claimOwnershipUntil, - size: 10, - }, - hits: tasks, - }); - - expect(query?.bool?.must).toContainEqual({ - bool: { - must: [ - { - term: { - 'task.ownerId': taskManagerId, - }, - }, - { term: { 'task.status': 'claiming' } }, - ], - }, - }); - - expect(docs).toMatchObject([ - { - attempts: 0, - id: 'aaa', - schedule: undefined, - params: { hello: 'world' }, - runAt, - scope: ['reporting'], - state: { baby: 'Henhen' }, - status: 'claiming', - taskType: 'foo', - user: 'jimbo', - ownerId: taskManagerId, - }, - ]); - }); - - test('it filters out invalid tasks that arent SavedObjects', async () => { - const taskManagerId = uuid.v1(); - const claimOwnershipUntil = new Date(Date.now()); - const runAt = new Date(); - const tasks = [ - { - _id: 'task:aaa', - _source: { - type: 'task', - task: { - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming', - params: '{ "hello": "world" }', - state: '{ "baby": "Henhen" }', - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - }, - }, - _seq_no: 1, - _primary_term: 2, - sort: ['a', 1], - }, - { - _id: 'task:bbb', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'running', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - ]; - const { - result: { docs } = {}, - args: { - search: { body: { query } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - }, - claimingOpts: { - claimOwnershipUntil, - size: 10, - }, - hits: tasks, - }); - - expect(query?.bool?.must).toContainEqual({ - bool: { - must: [ - { - term: { - 'task.ownerId': taskManagerId, - }, - }, - { term: { 'task.status': 'claiming' } }, - ], - }, - }); - - expect(docs).toMatchObject([ - { - attempts: 0, - id: 'aaa', - schedule: undefined, - params: { hello: 'world' }, - runAt, - scope: ['reporting'], - state: { baby: 'Henhen' }, - status: 'claiming', - taskType: 'foo', - user: 'jimbo', - ownerId: taskManagerId, - }, - ]); - }); - - test('it returns task objects', async () => { - const taskManagerId = uuid.v1(); - const claimOwnershipUntil = new Date(Date.now()); - const runAt = new Date(); - const tasks = [ - { - _id: 'task:aaa', - _source: { - type: 'task', - task: { - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming', - params: '{ "hello": "world" }', - state: '{ "baby": "Henhen" }', - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - }, - }, - _seq_no: 1, - _primary_term: 2, - sort: ['a', 1], - }, - { - _id: 'task:bbb', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'claiming', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - ]; - const { - result: { docs } = {}, - args: { - search: { body: { query } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - }, - claimingOpts: { - claimOwnershipUntil, - size: 10, - }, - hits: tasks, - }); - - expect(query?.bool?.must).toContainEqual({ - bool: { - must: [ - { - term: { - 'task.ownerId': taskManagerId, - }, - }, - { term: { 'task.status': 'claiming' } }, - ], - }, - }); - - expect(docs).toMatchObject([ - { - attempts: 0, - id: 'aaa', - schedule: undefined, - params: { hello: 'world' }, - runAt, - scope: ['reporting'], - state: { baby: 'Henhen' }, - status: 'claiming', - taskType: 'foo', - user: 'jimbo', - ownerId: taskManagerId, - }, - { - attempts: 2, - id: 'bbb', - schedule: { interval: '5m' }, - params: { shazm: 1 }, - runAt, - scope: ['reporting', 'ceo'], - state: { henry: 'The 8th' }, - status: 'claiming', - taskType: 'bar', - user: 'dabo', - ownerId: taskManagerId, - }, - ]); - }); - - test('it returns version_conflicts that do not include conflicts that were proceeded against', async () => { - const taskManagerId = uuid.v1(); - const claimOwnershipUntil = new Date(Date.now()); - const runAt = new Date(); - const tasks = [ - { - _id: 'task:aaa', - _source: { - type: 'task', - task: { - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming', - params: '{ "hello": "world" }', - state: '{ "baby": "Henhen" }', - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - }, - }, - _seq_no: 1, - _primary_term: 2, - sort: ['a', 1], - }, - { - _id: 'task:bbb', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'claiming', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - ]; - const maxDocs = 10; - const { - result: { stats: { tasksUpdated, tasksConflicted, tasksClaimed } = {} } = {}, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - }, - claimingOpts: { - claimOwnershipUntil, - size: maxDocs, - }, - hits: tasks, - // assume there were 20 version conflists, but thanks to `conflicts="proceed"` - // we proceeded to claim tasks - versionConflicts: 20, - }); - - expect(tasksUpdated).toEqual(2); - // ensure we only count conflicts that *may* have counted against max_docs, no more than that - expect(tasksConflicted).toEqual(10 - tasksUpdated!); - expect(tasksClaimed).toEqual(2); - }); - - test('pushes error from saved objects client to errors$', async () => { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - const store = new TaskStore({ - index: 'tasky', - taskManagerId: '', - serializer, - esClient, - definitions: taskDefinitions, - maxAttempts: 2, - savedObjectsRepository: savedObjectsClient, - }); - - const firstErrorPromise = store.errors$.pipe(first()).toPromise(); - esClient.updateByQuery.mockRejectedValue(new Error('Failure')); - await expect( - store.claimAvailableTasks({ - claimOwnershipUntil: new Date(), - size: 10, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"Failure"`); - expect(await firstErrorPromise).toMatchInlineSnapshot(`[Error: Failure]`); - }); - }); - describe('update', () => { let store: TaskStore; let esClient: ReturnType['asInternalUser']; @@ -1079,7 +267,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1179,7 +366,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1219,7 +405,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1251,7 +436,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1335,7 +519,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1355,7 +538,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1373,7 +555,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1381,283 +562,8 @@ if (doc['task.runAt'].size()!=0) { return expect(store.getLifecycle(randomId())).rejects.toThrow('Bad Request'); }); }); - - describe('task events', () => { - function generateTasks() { - const taskManagerId = uuid.v1(); - const runAt = new Date(); - const tasks = [ - { - _id: 'task:claimed-by-id', - _source: { - type: 'task', - task: { - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming', - params: '{ "hello": "world" }', - state: '{ "baby": "Henhen" }', - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }, - }, - _seq_no: 1, - _primary_term: 2, - sort: ['a', 1], - }, - { - _id: 'task:claimed-by-schedule', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'claiming', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - { - _id: 'task:already-running', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'running', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - ]; - - return { taskManagerId, runAt, tasks }; - } - - function instantiateStoreWithMockedApiResponses() { - const { taskManagerId, runAt, tasks } = generateTasks(); - - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.search.mockResolvedValue(asApiResponse({ hits: { hits: tasks } })); - esClient.updateByQuery.mockResolvedValue( - asApiResponse({ - total: tasks.length, - updated: tasks.length, - }) - ); - - const store = new TaskStore({ - esClient, - maxAttempts: 2, - definitions: taskDefinitions, - serializer, - savedObjectsRepository: savedObjectsClient, - taskManagerId, - index: '', - }); - - return { taskManagerId, runAt, store }; - } - - test('emits an event when a task is succesfully claimed by id', async () => { - const { taskManagerId, runAt, store } = instantiateStoreWithMockedApiResponses(); - - const promise = store.events - .pipe( - filter( - (event: TaskEvent>) => - event.id === 'claimed-by-id' - ), - take(1) - ) - .toPromise(); - - await store.claimAvailableTasks({ - claimTasksById: ['claimed-by-id'], - claimOwnershipUntil: new Date(), - size: 10, - }); - - const event = await promise; - expect(event).toMatchObject( - asTaskClaimEvent( - 'claimed-by-id', - asOk({ - id: 'claimed-by-id', - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming' as TaskStatus, - params: { hello: 'world' }, - state: { baby: 'Henhen' }, - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }) - ) - ); - }); - - test('emits an event when a task is succesfully by scheduling', async () => { - const { taskManagerId, runAt, store } = instantiateStoreWithMockedApiResponses(); - - const promise = store.events - .pipe( - filter( - (event: TaskEvent>) => - event.id === 'claimed-by-schedule' - ), - take(1) - ) - .toPromise(); - - await store.claimAvailableTasks({ - claimTasksById: ['claimed-by-id'], - claimOwnershipUntil: new Date(), - size: 10, - }); - - const event = await promise; - expect(event).toMatchObject( - asTaskClaimEvent( - 'claimed-by-schedule', - asOk({ - id: 'claimed-by-schedule', - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'claiming' as TaskStatus, - params: { shazm: 1 }, - state: { henry: 'The 8th' }, - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }) - ) - ); - }); - - test('emits an event when the store fails to claim a required task by id', async () => { - const { taskManagerId, runAt, store } = instantiateStoreWithMockedApiResponses(); - - const promise = store.events - .pipe( - filter( - (event: TaskEvent>) => - event.id === 'already-running' - ), - take(1) - ) - .toPromise(); - - await store.claimAvailableTasks({ - claimTasksById: ['already-running'], - claimOwnershipUntil: new Date(), - size: 10, - }); - - const event = await promise; - expect(event).toMatchObject( - asTaskClaimEvent( - 'already-running', - asErr( - some({ - id: 'already-running', - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'running' as TaskStatus, - params: { shazm: 1 }, - state: { henry: 'The 8th' }, - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }) - ) - ) - ); - }); - - test('emits an event when the store fails to find a task which was required by id', async () => { - const { store } = instantiateStoreWithMockedApiResponses(); - - const promise = store.events - .pipe( - filter( - (event: TaskEvent>) => - event.id === 'unknown-task' - ), - take(1) - ) - .toPromise(); - - await store.claimAvailableTasks({ - claimTasksById: ['unknown-task'], - claimOwnershipUntil: new Date(), - size: 10, - }); - - const event = await promise; - expect(event).toMatchObject(asTaskClaimEvent('unknown-task', asErr(none))); - }); - }); }); -function generateFakeTasks(count: number = 1) { - return _.times(count, (index) => ({ - _id: `task:id-${index}`, - _source: { - type: 'task', - task: {}, - }, - _seq_no: _.random(1, 5), - _primary_term: _.random(1, 5), - sort: ['a', _.random(1, 5)], - })); -} - const asApiResponse = (body: T): RequestEvent => ({ body, diff --git a/x-pack/plugins/task_manager/server/task_store.ts b/x-pack/plugins/task_manager/server/task_store.ts index b72f1826b813b..0b54f2779065f 100644 --- a/x-pack/plugins/task_manager/server/task_store.ts +++ b/x-pack/plugins/task_manager/server/task_store.ts @@ -8,13 +8,9 @@ /* * This module contains helpers for managing the task manager storage layer. */ -import apm from 'elastic-apm-node'; -import { Subject, Observable } from 'rxjs'; -import { omit, difference, partition, map, defaults } from 'lodash'; - -import { some, none } from 'fp-ts/lib/Option'; - -import { SearchResponse, UpdateDocumentByQueryResponse } from 'elasticsearch'; +import { Subject } from 'rxjs'; +import { omit, defaults } from 'lodash'; +import { ReindexResponseBase, SearchResponse, UpdateDocumentByQueryResponse } from 'elasticsearch'; import { SavedObject, SavedObjectsSerializer, @@ -32,38 +28,15 @@ import { TaskLifecycle, TaskLifecycleResult, SerializedConcreteTaskInstance, - TaskStatus, } from './task'; -import { TaskClaim, asTaskClaimEvent } from './task_events'; - -import { - asUpdateByQuery, - shouldBeOneOf, - mustBeAllOf, - filterDownBy, - asPinnedQuery, - matchesClauses, - SortOptions, -} from './queries/query_clauses'; - -import { - updateFieldsAndMarkAsFailed, - IdleTaskWithExpiredRunAt, - InactiveTasks, - RunningOrClaimingTaskWithExpiredRetryAt, - SortByRunAtAndRetryAt, - tasksClaimedByOwner, -} from './queries/mark_available_tasks_as_claimed'; import { TaskTypeDictionary } from './task_type_dictionary'; - import { ESSearchResponse, ESSearchBody } from '../../../typings/elasticsearch'; export interface StoreOpts { esClient: ElasticsearchClient; index: string; taskManagerId: string; - maxAttempts: number; definitions: TaskTypeDictionary; savedObjectsRepository: ISavedObjectsRepository; serializer: SavedObjectsSerializer; @@ -88,25 +61,10 @@ export interface UpdateByQueryOpts extends SearchOpts { max_docs?: number; } -export interface OwnershipClaimingOpts { - claimOwnershipUntil: Date; - claimTasksById?: string[]; - size: number; -} - export interface FetchResult { docs: ConcreteTaskInstance[]; } -export interface ClaimOwnershipResult { - stats: { - tasksUpdated: number; - tasksConflicted: number; - tasksClaimed: number; - }; - docs: ConcreteTaskInstance[]; -} - export type BulkUpdateResult = Result< ConcreteTaskInstance, { entity: ConcreteTaskInstance; error: Error } @@ -123,7 +81,6 @@ export interface UpdateByQueryResult { * interface into the index. */ export class TaskStore { - public readonly maxAttempts: number; public readonly index: string; public readonly taskManagerId: string; public readonly errors$ = new Subject(); @@ -132,14 +89,12 @@ export class TaskStore { private definitions: TaskTypeDictionary; private savedObjectsRepository: ISavedObjectsRepository; private serializer: SavedObjectsSerializer; - private events$: Subject; /** * Constructs a new TaskStore. * @param {StoreOpts} opts * @prop {esClient} esClient - An elasticsearch client * @prop {string} index - The name of the task manager index - * @prop {number} maxAttempts - The maximum number of attempts before a task will be abandoned * @prop {TaskDefinition} definition - The definition of the task being run * @prop {serializer} - The saved object serializer * @prop {savedObjectsRepository} - An instance to the saved objects repository @@ -148,21 +103,22 @@ export class TaskStore { this.esClient = opts.esClient; this.index = opts.index; this.taskManagerId = opts.taskManagerId; - this.maxAttempts = opts.maxAttempts; this.definitions = opts.definitions; this.serializer = opts.serializer; this.savedObjectsRepository = opts.savedObjectsRepository; - this.events$ = new Subject(); } - public get events(): Observable { - return this.events$; + /** + * Convert ConcreteTaskInstance Ids to match their SavedObject format as serialized + * in Elasticsearch + * @param tasks - The task being scheduled. + */ + public convertToSavedObjectIds( + taskIds: Array + ): Array { + return taskIds.map((id) => this.serializer.generateRawId(undefined, 'task', id)); } - private emitEvents = (events: TaskClaim[]) => { - events.forEach((event) => this.events$.next(event)); - }; - /** * Schedules a task. * @@ -201,144 +157,6 @@ export class TaskStore { }); } - /** - * Claims available tasks from the index, which are ready to be run. - * - runAt is now or past - * - is not currently claimed by any instance of Kibana - * - has a type that is in our task definitions - * - * @param {OwnershipClaimingOpts} options - * @returns {Promise} - */ - public claimAvailableTasks = async ({ - claimOwnershipUntil, - claimTasksById = [], - size, - }: OwnershipClaimingOpts): Promise => { - const claimTasksByIdWithRawIds = claimTasksById.map((id) => - this.serializer.generateRawId(undefined, 'task', id) - ); - - const { - updated: tasksUpdated, - version_conflicts: tasksConflicted, - } = await this.markAvailableTasksAsClaimed(claimOwnershipUntil, claimTasksByIdWithRawIds, size); - - const docs = - tasksUpdated > 0 ? await this.sweepForClaimedTasks(claimTasksByIdWithRawIds, size) : []; - - const [documentsReturnedById, documentsClaimedBySchedule] = partition(docs, (doc) => - claimTasksById.includes(doc.id) - ); - - const [documentsClaimedById, documentsRequestedButNotClaimed] = partition( - documentsReturnedById, - // we filter the schduled tasks down by status is 'claiming' in the esearch, - // but we do not apply this limitation on tasks claimed by ID so that we can - // provide more detailed error messages when we fail to claim them - (doc) => doc.status === TaskStatus.Claiming - ); - - const documentsRequestedButNotReturned = difference( - claimTasksById, - map(documentsReturnedById, 'id') - ); - - this.emitEvents([ - ...documentsClaimedById.map((doc) => asTaskClaimEvent(doc.id, asOk(doc))), - ...documentsClaimedBySchedule.map((doc) => asTaskClaimEvent(doc.id, asOk(doc))), - ...documentsRequestedButNotClaimed.map((doc) => asTaskClaimEvent(doc.id, asErr(some(doc)))), - ...documentsRequestedButNotReturned.map((id) => asTaskClaimEvent(id, asErr(none))), - ]); - - return { - stats: { - tasksUpdated, - tasksConflicted, - tasksClaimed: documentsClaimedById.length + documentsClaimedBySchedule.length, - }, - docs: docs.filter((doc) => doc.status === TaskStatus.Claiming), - }; - }; - - private async markAvailableTasksAsClaimed( - claimOwnershipUntil: OwnershipClaimingOpts['claimOwnershipUntil'], - claimTasksById: OwnershipClaimingOpts['claimTasksById'], - size: OwnershipClaimingOpts['size'] - ): Promise { - const registeredTaskTypes = this.definitions.getAllTypes(); - const taskMaxAttempts = [...this.definitions].reduce((accumulator, [type, { maxAttempts }]) => { - return { ...accumulator, [type]: maxAttempts || this.maxAttempts }; - }, {}); - const queryForScheduledTasks = mustBeAllOf( - // Either a task with idle status and runAt <= now or - // status running or claiming with a retryAt <= now. - shouldBeOneOf(IdleTaskWithExpiredRunAt, RunningOrClaimingTaskWithExpiredRetryAt) - ); - - // The documents should be sorted by runAt/retryAt, unless there are pinned - // tasks being queried, in which case we want to sort by score first, and then - // the runAt/retryAt. That way we'll get the pinned tasks first. Note that - // the score seems to favor newer documents rather than older documents, so - // if there are not pinned tasks being queried, we do NOT want to sort by score - // at all, just by runAt/retryAt. - const sort: SortOptions = [SortByRunAtAndRetryAt]; - if (claimTasksById && claimTasksById.length) { - sort.unshift('_score'); - } - - const apmTrans = apm.startTransaction(`taskManager markAvailableTasksAsClaimed`, 'taskManager'); - const result = await this.updateByQuery( - asUpdateByQuery({ - query: matchesClauses( - mustBeAllOf( - claimTasksById && claimTasksById.length - ? asPinnedQuery(claimTasksById, queryForScheduledTasks) - : queryForScheduledTasks - ), - filterDownBy(InactiveTasks) - ), - update: updateFieldsAndMarkAsFailed( - { - ownerId: this.taskManagerId, - retryAt: claimOwnershipUntil, - }, - claimTasksById || [], - registeredTaskTypes, - taskMaxAttempts - ), - sort, - }), - { - max_docs: size, - } - ); - - if (apmTrans) apmTrans.end(); - return result; - } - - /** - * Fetches tasks from the index, which are owned by the current Kibana instance - */ - private async sweepForClaimedTasks( - claimTasksById: OwnershipClaimingOpts['claimTasksById'], - size: OwnershipClaimingOpts['size'] - ): Promise { - const claimedTasksQuery = tasksClaimedByOwner(this.taskManagerId); - const { docs } = await this.search({ - query: - claimTasksById && claimTasksById.length - ? asPinnedQuery(claimTasksById, claimedTasksQuery) - : claimedTasksQuery, - size, - sort: SortByRunAtAndRetryAt, - seq_no_primary_term: true, - }); - - return docs; - } - /** * Updates the specified doc in the index, returning the doc * with its version up to date. @@ -527,7 +345,7 @@ export class TaskStore { return body; } - private async updateByQuery( + public async updateByQuery( opts: UpdateByQuerySearchOpts = {}, // eslint-disable-next-line @typescript-eslint/naming-convention { max_docs: max_docs }: UpdateByQueryOpts = {} @@ -549,17 +367,11 @@ export class TaskStore { }, }); - /** - * When we run updateByQuery with conflicts='proceed', it's possible for the `version_conflicts` - * to count against the specified `max_docs`, as per https://github.com/elastic/elasticsearch/issues/63671 - * In order to correct for that happening, we only count `version_conflicts` if we haven't updated as - * many docs as we could have. - * This is still no more than an estimation, as there might have been less docuemnt to update that the - * `max_docs`, but we bias in favour of over zealous `version_conflicts` as that's the best indicator we - * have for an unhealthy cluster distribution of Task Manager polling intervals - */ - const conflictsCorrectedForContinuation = - max_docs && version_conflicts + updated > max_docs ? max_docs - updated : version_conflicts; + const conflictsCorrectedForContinuation = correctVersionConflictsForContinuation( + updated, + version_conflicts, + max_docs + ); return { total, @@ -572,6 +384,22 @@ export class TaskStore { } } } +/** + * When we run updateByQuery with conflicts='proceed', it's possible for the `version_conflicts` + * to count against the specified `max_docs`, as per https://github.com/elastic/elasticsearch/issues/63671 + * In order to correct for that happening, we only count `version_conflicts` if we haven't updated as + * many docs as we could have. + * This is still no more than an estimation, as there might have been less docuemnt to update that the + * `max_docs`, but we bias in favour of over zealous `version_conflicts` as that's the best indicator we + * have for an unhealthy cluster distribution of Task Manager polling intervals + */ +export function correctVersionConflictsForContinuation( + updated: ReindexResponseBase['updated'], + versionConflicts: ReindexResponseBase['version_conflicts'], + maxDocs?: number +) { + return maxDocs && versionConflicts + updated > maxDocs ? maxDocs - updated : versionConflicts; +} function taskInstanceToAttributes(doc: TaskInstance): SerializedConcreteTaskInstance { return { diff --git a/x-pack/plugins/task_manager/server/task_type_dictionary.ts b/x-pack/plugins/task_manager/server/task_type_dictionary.ts index 4230eb9ce4b73..63a0548d79d32 100644 --- a/x-pack/plugins/task_manager/server/task_type_dictionary.ts +++ b/x-pack/plugins/task_manager/server/task_type_dictionary.ts @@ -28,6 +28,10 @@ export class TaskTypeDictionary { return [...this.definitions.keys()]; } + public getAllDefinitions() { + return [...this.definitions.values()]; + } + public has(type: string) { return this.definitions.has(type); } diff --git a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts index 2878d7d5f8220..57beb40b16459 100644 --- a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts +++ b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts @@ -218,10 +218,9 @@ export function initRoutes( await ensureIndexIsRefreshed(); const taskManager = await taskManagerStart; return res.ok({ body: await taskManager.get(req.params.taskId) }); - } catch (err) { - return res.ok({ body: err }); + } catch ({ isBoom, output, message }) { + return res.ok({ body: isBoom ? output.payload : { message } }); } - return res.ok({ body: {} }); } ); @@ -251,6 +250,7 @@ export function initRoutes( res: KibanaResponseFactory ): Promise> { try { + await ensureIndexIsRefreshed(); let tasksFound = 0; const taskManager = await taskManagerStart; do { @@ -261,8 +261,8 @@ export function initRoutes( await Promise.all(tasks.map((task) => taskManager.remove(task.id))); } while (tasksFound > 0); return res.ok({ body: 'OK' }); - } catch (err) { - return res.ok({ body: err }); + } catch ({ isBoom, output, message }) { + return res.ok({ body: isBoom ? output.payload : { message } }); } } ); diff --git a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts index 3aee35ed0bff3..2031551410894 100644 --- a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts +++ b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts @@ -105,6 +105,20 @@ export class SampleTaskManagerFixturePlugin // fail after the first failed run maxAttempts: 1, }, + sampleTaskWithSingleConcurrency: { + ...defaultSampleTaskConfig, + title: 'Sample Task With Single Concurrency', + maxConcurrency: 1, + timeout: '60s', + description: 'A sample task that can only have one concurrent instance.', + }, + sampleTaskWithLimitedConcurrency: { + ...defaultSampleTaskConfig, + title: 'Sample Task With Max Concurrency of 2', + maxConcurrency: 2, + timeout: '60s', + description: 'A sample task that can only have two concurrent instance.', + }, sampleRecurringTaskTimingOut: { title: 'Sample Recurring Task that Times Out', description: 'A sample task that times out each run.', diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/health_route.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/health_route.ts index 231150a814835..d99c1dac9a25e 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/health_route.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/health_route.ts @@ -34,6 +34,7 @@ interface MonitoringStats { timestamp: string; value: { drift: Record; + drift_by_type: Record>; load: Record; execution: { duration: Record>; @@ -43,6 +44,7 @@ interface MonitoringStats { last_successful_poll: string; last_polling_delay: string; duration: Record; + claim_duration: Record; result_frequency_percent_as_number: Record; }; }; @@ -174,7 +176,8 @@ export default function ({ getService }: FtrProviderContext) { const { runtime: { - value: { drift, load, polling, execution }, + // eslint-disable-next-line @typescript-eslint/naming-convention + value: { drift, drift_by_type, load, polling, execution }, }, } = (await getHealth()).stats; @@ -192,11 +195,21 @@ export default function ({ getService }: FtrProviderContext) { expect(typeof polling.duration.p95).to.eql('number'); expect(typeof polling.duration.p99).to.eql('number'); + expect(typeof polling.claim_duration.p50).to.eql('number'); + expect(typeof polling.claim_duration.p90).to.eql('number'); + expect(typeof polling.claim_duration.p95).to.eql('number'); + expect(typeof polling.claim_duration.p99).to.eql('number'); + expect(typeof drift.p50).to.eql('number'); expect(typeof drift.p90).to.eql('number'); expect(typeof drift.p95).to.eql('number'); expect(typeof drift.p99).to.eql('number'); + expect(typeof drift_by_type.sampleTask.p50).to.eql('number'); + expect(typeof drift_by_type.sampleTask.p90).to.eql('number'); + expect(typeof drift_by_type.sampleTask.p95).to.eql('number'); + expect(typeof drift_by_type.sampleTask.p99).to.eql('number'); + expect(typeof load.p50).to.eql('number'); expect(typeof load.p90).to.eql('number'); expect(typeof load.p95).to.eql('number'); diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management.ts index 353be5e872aed..26333ecabd505 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management.ts @@ -51,7 +51,7 @@ type SerializedConcreteTaskInstance = Omit< }; export default function ({ getService }: FtrProviderContext) { - const es = getService('legacyEs'); + const es = getService('es'); const log = getService('log'); const retry = getService('retry'); const config = getService('config'); @@ -59,30 +59,46 @@ export default function ({ getService }: FtrProviderContext) { const supertest = supertestAsPromised(url.format(config.get('servers.kibana'))); describe('scheduling and running tasks', () => { - beforeEach( - async () => await supertest.delete('/api/sample_tasks').set('kbn-xsrf', 'xxx').expect(200) - ); + beforeEach(async () => { + // clean up before each test + return await supertest.delete('/api/sample_tasks').set('kbn-xsrf', 'xxx').expect(200); + }); beforeEach(async () => { const exists = await es.indices.exists({ index: testHistoryIndex }); - if (exists) { + if (exists.body) { await es.deleteByQuery({ index: testHistoryIndex, - q: 'type:task', refresh: true, + body: { query: { term: { type: 'task' } } }, }); } else { await es.indices.create({ index: testHistoryIndex, body: { mappings: { - properties: taskManagerIndexMapping, + properties: { + type: { + type: 'keyword', + }, + taskId: { + type: 'keyword', + }, + params: taskManagerIndexMapping.params, + state: taskManagerIndexMapping.state, + runAt: taskManagerIndexMapping.runAt, + }, }, }, }); } }); + after(async () => { + // clean up after last test + return await supertest.delete('/api/sample_tasks').set('kbn-xsrf', 'xxx').expect(200); + }); + function currentTasks(): Promise<{ docs: Array>; }> { @@ -98,7 +114,27 @@ export default function ({ getService }: FtrProviderContext) { return supertest .get(`/api/sample_tasks/task/${task}`) .send({ task }) - .expect(200) + .expect((response) => { + expect(response.status).to.eql(200); + expect(typeof JSON.parse(response.text).id).to.eql(`string`); + }) + .then((response) => response.body); + } + + function currentTaskError( + task: string + ): Promise<{ + statusCode: number; + error: string; + message: string; + }> { + return supertest + .get(`/api/sample_tasks/task/${task}`) + .send({ task }) + .expect(function (response) { + expect(response.status).to.eql(200); + expect(typeof JSON.parse(response.text).message).to.eql(`string`); + }) .then((response) => response.body); } @@ -106,13 +142,21 @@ export default function ({ getService }: FtrProviderContext) { return supertest.get(`/api/ensure_tasks_index_refreshed`).send({}).expect(200); } - function historyDocs(taskId?: string): Promise { + async function historyDocs(taskId?: string): Promise { return es .search({ index: testHistoryIndex, - q: taskId ? `taskId:${taskId}` : 'type:task', + body: { + query: { + term: { type: 'task' }, + }, + }, }) - .then((result: SearchResults) => result.hits.hits); + .then((result) => + ((result.body as unknown) as SearchResults).hits.hits.filter((task) => + taskId ? task._source?.taskId === taskId : true + ) + ); } function scheduleTask( @@ -123,7 +167,10 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ task }) .expect(200) - .then((response: { body: SerializedConcreteTaskInstance }) => response.body); + .then((response: { body: SerializedConcreteTaskInstance }) => { + log.debug(`Task Scheduled: ${response.body.id}`); + return response.body; + }); } function runTaskNow(task: { id: string }) { @@ -252,8 +299,7 @@ export default function ({ getService }: FtrProviderContext) { }); await retry.try(async () => { - const [scheduledTask] = (await currentTasks()).docs; - expect(scheduledTask.id).to.eql(task.id); + const scheduledTask = await currentTask(task.id); expect(scheduledTask.attempts).to.be.greaterThan(0); expect(Date.parse(scheduledTask.runAt)).to.be.greaterThan( Date.parse(task.runAt) + 5 * 60 * 1000 @@ -271,8 +317,7 @@ export default function ({ getService }: FtrProviderContext) { }); await retry.try(async () => { - const [scheduledTask] = (await currentTasks()).docs; - expect(scheduledTask.id).to.eql(task.id); + const scheduledTask = await currentTask(task.id); const retryAt = Date.parse(scheduledTask.retryAt!); expect(isNaN(retryAt)).to.be(false); @@ -296,7 +341,7 @@ export default function ({ getService }: FtrProviderContext) { await retry.try(async () => { expect((await historyDocs(originalTask.id)).length).to.eql(1); - const [task] = (await currentTasks<{ count: number }>()).docs; + const task = await currentTask<{ count: number }>(originalTask.id); expect(task.attempts).to.eql(0); expect(task.state.count).to.eql(count + 1); @@ -467,6 +512,134 @@ export default function ({ getService }: FtrProviderContext) { }); }); + it('should only run as many instances of a task as its maxConcurrency will allow', async () => { + // should run as there's only one and maxConcurrency on this TaskType is 1 + const firstWithSingleConcurrency = await scheduleTask({ + taskType: 'sampleTaskWithSingleConcurrency', + params: { + waitForEvent: 'releaseFirstWaveOfTasks', + }, + }); + + // should run as there's only two and maxConcurrency on this TaskType is 2 + const [firstLimitedConcurrency, secondLimitedConcurrency] = await Promise.all([ + scheduleTask({ + taskType: 'sampleTaskWithLimitedConcurrency', + params: { + waitForEvent: 'releaseFirstWaveOfTasks', + }, + }), + scheduleTask({ + taskType: 'sampleTaskWithLimitedConcurrency', + params: { + waitForEvent: 'releaseSecondWaveOfTasks', + }, + }), + ]); + + await retry.try(async () => { + expect((await historyDocs(firstWithSingleConcurrency.id)).length).to.eql(1); + expect((await historyDocs(firstLimitedConcurrency.id)).length).to.eql(1); + expect((await historyDocs(secondLimitedConcurrency.id)).length).to.eql(1); + }); + + // should not run as there one running and maxConcurrency on this TaskType is 1 + const secondWithSingleConcurrency = await scheduleTask({ + taskType: 'sampleTaskWithSingleConcurrency', + params: { + waitForEvent: 'releaseSecondWaveOfTasks', + }, + }); + + // should not run as there are two running and maxConcurrency on this TaskType is 2 + const thirdWithLimitedConcurrency = await scheduleTask({ + taskType: 'sampleTaskWithLimitedConcurrency', + params: { + waitForEvent: 'releaseSecondWaveOfTasks', + }, + }); + + // schedule a task that should get picked up before the two blocked tasks + const taskWithUnlimitedConcurrency = await scheduleTask({ + taskType: 'sampleTask', + params: {}, + }); + + await retry.try(async () => { + expect((await historyDocs(taskWithUnlimitedConcurrency.id)).length).to.eql(1); + expect((await currentTask(secondWithSingleConcurrency.id)).status).to.eql('idle'); + expect((await currentTask(thirdWithLimitedConcurrency.id)).status).to.eql('idle'); + }); + + // release the running SingleConcurrency task and only one of the LimitedConcurrency tasks + await releaseTasksWaitingForEventToComplete('releaseFirstWaveOfTasks'); + + await retry.try(async () => { + // ensure the completed tasks were deleted + expect((await currentTaskError(firstWithSingleConcurrency.id)).message).to.eql( + `Saved object [task/${firstWithSingleConcurrency.id}] not found` + ); + expect((await currentTaskError(firstLimitedConcurrency.id)).message).to.eql( + `Saved object [task/${firstLimitedConcurrency.id}] not found` + ); + + // ensure blocked tasks is still running + expect((await currentTask(secondLimitedConcurrency.id)).status).to.eql('running'); + + // ensure the blocked tasks begin running + expect((await currentTask(secondWithSingleConcurrency.id)).status).to.eql('running'); + expect((await currentTask(thirdWithLimitedConcurrency.id)).status).to.eql('running'); + }); + + // release blocked task + await releaseTasksWaitingForEventToComplete('releaseSecondWaveOfTasks'); + }); + + it('should return a task run error result when RunNow is called at a time that would cause the task to exceed its maxConcurrency', async () => { + // should run as there's only one and maxConcurrency on this TaskType is 1 + const firstWithSingleConcurrency = await scheduleTask({ + taskType: 'sampleTaskWithSingleConcurrency', + // include a schedule so that the task isn't deleted after completion + schedule: { interval: `30m` }, + params: { + waitForEvent: 'releaseRunningTaskWithSingleConcurrency', + }, + }); + + // should not run as the first is running + const secondWithSingleConcurrency = await scheduleTask({ + taskType: 'sampleTaskWithSingleConcurrency', + params: { + waitForEvent: 'releaseRunningTaskWithSingleConcurrency', + }, + }); + + // run the first tasks once just so that we can be sure it runs in response to our + // runNow callm, rather than the initial execution + await retry.try(async () => { + expect((await historyDocs(firstWithSingleConcurrency.id)).length).to.eql(1); + }); + await releaseTasksWaitingForEventToComplete('releaseRunningTaskWithSingleConcurrency'); + + // wait for second task to stall + await retry.try(async () => { + expect((await historyDocs(secondWithSingleConcurrency.id)).length).to.eql(1); + }); + + // run the first task again using runNow - should fail due to concurrency concerns + const failedRunNowResult = await runTaskNow({ + id: firstWithSingleConcurrency.id, + }); + + expect(failedRunNowResult).to.eql({ + id: firstWithSingleConcurrency.id, + error: `Error: Failed to run task "${firstWithSingleConcurrency.id}" as we would exceed the max concurrency of "Sample Task With Single Concurrency" which is 1. Rescheduled the task to ensure it is picked up as soon as possible.`, + }); + + // release the second task + await releaseTasksWaitingForEventToComplete('releaseRunningTaskWithSingleConcurrency'); + }); + it('should return a task run error result when running a task now fails', async () => { const originalTask = await scheduleTask({ taskType: 'sampleTask', From 8aaf168347410baec8e9626bfa9be19c4ed4ceb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Thu, 11 Feb 2021 16:41:22 +0000 Subject: [PATCH 17/53] [7.x] [Plugins Discovery] Enforce camelCase plugin IDs (#90752) (#91140) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../discovery/plugin_manifest_parser.test.ts | 77 ++++++++----------- .../discovery/plugin_manifest_parser.ts | 11 +-- .../plugins/discovery/plugins_discovery.ts | 2 +- .../fixtures/plugins/newsfeed/kibana.json | 2 +- .../plugins/kbn_tp_run_pipeline/kibana.json | 2 +- .../plugins/app_link_test/kibana.json | 2 +- .../plugins/core_app_status/kibana.json | 2 +- .../plugins/core_plugin_a/kibana.json | 2 +- .../plugins/core_plugin_appleave/kibana.json | 2 +- .../plugins/core_plugin_b/kibana.json | 6 +- .../plugins/core_plugin_b/public/plugin.tsx | 4 +- .../core_plugin_chromeless/kibana.json | 2 +- .../plugins/core_plugin_helpmenu/kibana.json | 2 +- .../core_plugin_route_timeouts/kibana.json | 2 +- .../plugins/core_provider_plugin/kibana.json | 4 +- .../plugins/data_search/kibana.json | 2 +- .../elasticsearch_client_plugin/kibana.json | 2 +- .../plugins/index_patterns/kibana.json | 2 +- .../kbn_sample_panel_action/kibana.json | 2 +- .../plugins/kbn_top_nav/kibana.json | 4 +- .../kbn_tp_custom_visualizations/kibana.json | 2 +- .../management_test_plugin/kibana.json | 2 +- .../plugins/rendering_plugin/kibana.json | 2 +- .../plugins/session_notifications/kibana.json | 4 +- .../plugins/ui_settings_plugin/kibana.json | 2 +- .../test_suites/core_plugins/ui_plugins.ts | 6 +- .../common/fixtures/plugins/aad/kibana.json | 2 +- .../plugins/actions_simulators/kibana.json | 2 +- .../plugins/task_manager_fixture/kibana.json | 2 +- .../plugins/kibana_cors_test/kibana.json | 2 +- .../plugins/iframe_embedded/kibana.json | 2 +- .../fixtures/plugins/alerts/kibana.json | 2 +- .../plugins/elasticsearch_client/kibana.json | 2 +- .../plugins/event_log/kibana.json | 2 +- .../plugins/feature_usage_test/kibana.json | 2 +- .../plugins/sample_task_plugin/kibana.json | 2 +- .../task_manager_performance/kibana.json | 2 +- .../plugins/resolver_test/kibana.json | 2 +- .../fixtures/oidc/oidc_provider/kibana.json | 2 +- .../fixtures/saml/saml_provider/kibana.json | 2 +- .../fixtures/plugins/foo_plugin/kibana.json | 2 +- .../stack_management_usage_test/kibana.json | 4 +- 42 files changed, 86 insertions(+), 100 deletions(-) diff --git a/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts b/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts index 4dc912680ec63..f3a92c896b014 100644 --- a/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts +++ b/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts @@ -9,12 +9,10 @@ import { mockReadFile } from './plugin_manifest_parser.test.mocks'; import { PluginDiscoveryErrorType } from './plugin_discovery_error'; -import { loggingSystemMock } from '../../logging/logging_system.mock'; import { resolve } from 'path'; import { parseManifest } from './plugin_manifest_parser'; -const logger = loggingSystemMock.createLogger(); const pluginPath = resolve('path', 'existent-dir'); const pluginManifestPath = resolve(pluginPath, 'kibana.json'); const packageInfo = { @@ -34,7 +32,7 @@ test('return error when manifest is empty', async () => { cb(null, Buffer.from('')); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Unexpected end of JSON input (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -46,7 +44,7 @@ test('return error when manifest content is null', async () => { cb(null, Buffer.from('null')); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin manifest must contain a JSON encoded object. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -58,7 +56,7 @@ test('return error when manifest content is not a valid JSON', async () => { cb(null, Buffer.from('not-json')); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Unexpected token o in JSON at position 1 (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -70,7 +68,7 @@ test('return error when plugin id is missing', async () => { cb(null, Buffer.from(JSON.stringify({ version: 'some-version' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin manifest must contain an "id" property. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -82,37 +80,24 @@ test('return error when plugin id includes `.` characters', async () => { cb(null, Buffer.from(JSON.stringify({ id: 'some.name', version: 'some-version' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin "id" must not include \`.\` characters. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, }); }); -test('logs warning if pluginId is not in camelCase format', async () => { +test('return error when pluginId is not in camelCase format', async () => { + expect.assertions(1); mockReadFile.mockImplementation((path, cb) => { cb(null, Buffer.from(JSON.stringify({ id: 'some_name', version: 'kibana', server: true }))); }); - expect(loggingSystemMock.collect(logger).warn).toHaveLength(0); - await parseManifest(pluginPath, packageInfo, logger); - expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` - Array [ - Array [ - "Expect plugin \\"id\\" in camelCase, but found: some_name", - ], - ] - `); -}); - -test('does not log pluginId format warning in dist mode', async () => { - mockReadFile.mockImplementation((path, cb) => { - cb(null, Buffer.from(JSON.stringify({ id: 'some_name', version: 'kibana', server: true }))); + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ + message: `Plugin "id" must be camelCase, but found: some_name. (invalid-manifest, ${pluginManifestPath})`, + type: PluginDiscoveryErrorType.InvalidManifest, + path: pluginManifestPath, }); - - expect(loggingSystemMock.collect(logger).warn).toHaveLength(0); - await parseManifest(pluginPath, { ...packageInfo, dist: true }, logger); - expect(loggingSystemMock.collect(logger).warn.length).toBe(0); }); test('return error when plugin version is missing', async () => { @@ -120,7 +105,7 @@ test('return error when plugin version is missing', async () => { cb(null, Buffer.from(JSON.stringify({ id: 'someId' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin manifest for "someId" must contain a "version" property. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -132,7 +117,7 @@ test('return error when plugin expected Kibana version is lower than actual vers cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '6.4.2' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin "someId" is only compatible with Kibana version "6.4.2", but used Kibana version is "7.0.0-alpha1". (incompatible-version, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.IncompatibleVersion, path: pluginManifestPath, @@ -147,7 +132,7 @@ test('return error when plugin expected Kibana version cannot be interpreted as ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin "someId" is only compatible with Kibana version "non-sem-ver", but used Kibana version is "7.0.0-alpha1". (incompatible-version, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.IncompatibleVersion, path: pluginManifestPath, @@ -159,7 +144,7 @@ test('return error when plugin config path is not a string', async () => { cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '7.0.0', configPath: 2 }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `The "configPath" in plugin manifest for "someId" should either be a string or an array of strings. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -174,7 +159,7 @@ test('return error when plugin config path is an array that contains non-string ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `The "configPath" in plugin manifest for "someId" should either be a string or an array of strings. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -186,7 +171,7 @@ test('return error when plugin expected Kibana version is higher than actual ver cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '7.0.1' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin "someId" is only compatible with Kibana version "7.0.1", but used Kibana version is "7.0.0-alpha1". (incompatible-version, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.IncompatibleVersion, path: pluginManifestPath, @@ -198,7 +183,7 @@ test('return error when both `server` and `ui` are set to `false` or missing', a cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '7.0.0' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Both "server" and "ui" are missing or set to "false" in plugin manifest for "someId", but at least one of these must be set to "true". (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -211,7 +196,7 @@ test('return error when both `server` and `ui` are set to `false` or missing', a ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Both "server" and "ui" are missing or set to "false" in plugin manifest for "someId", but at least one of these must be set to "true". (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -234,7 +219,7 @@ test('return error when manifest contains unrecognized properties', async () => ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Manifest for plugin "someId" contains the following unrecognized properties: unknownOne,unknownTwo. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -247,20 +232,20 @@ describe('configPath', () => { cb(null, Buffer.from(JSON.stringify({ id: 'plugin', version: '7.0.0', server: true }))); }); - const manifest = await parseManifest(pluginPath, packageInfo, logger); + const manifest = await parseManifest(pluginPath, packageInfo); expect(manifest.configPath).toBe(manifest.id); }); test('falls back to plugin id in snakeCase format', async () => { mockReadFile.mockImplementation((path, cb) => { - cb(null, Buffer.from(JSON.stringify({ id: 'SomeId', version: '7.0.0', server: true }))); + cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '7.0.0', server: true }))); }); - const manifest = await parseManifest(pluginPath, packageInfo, logger); + const manifest = await parseManifest(pluginPath, packageInfo); expect(manifest.configPath).toBe('some_id'); }); - test('not formated to snakeCase if defined explicitly as string', async () => { + test('not formatted to snakeCase if defined explicitly as string', async () => { mockReadFile.mockImplementation((path, cb) => { cb( null, @@ -270,11 +255,11 @@ describe('configPath', () => { ); }); - const manifest = await parseManifest(pluginPath, packageInfo, logger); + const manifest = await parseManifest(pluginPath, packageInfo); expect(manifest.configPath).toBe('somePath'); }); - test('not formated to snakeCase if defined explicitly as an array of strings', async () => { + test('not formatted to snakeCase if defined explicitly as an array of strings', async () => { mockReadFile.mockImplementation((path, cb) => { cb( null, @@ -284,7 +269,7 @@ describe('configPath', () => { ); }); - const manifest = await parseManifest(pluginPath, packageInfo, logger); + const manifest = await parseManifest(pluginPath, packageInfo); expect(manifest.configPath).toEqual(['somePath']); }); }); @@ -294,7 +279,7 @@ test('set defaults for all missing optional fields', async () => { cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '7.0.0', server: true }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).resolves.toEqual({ + await expect(parseManifest(pluginPath, packageInfo)).resolves.toEqual({ id: 'someId', configPath: 'some_id', version: '7.0.0', @@ -325,7 +310,7 @@ test('return all set optional fields as they are in manifest', async () => { ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).resolves.toEqual({ + await expect(parseManifest(pluginPath, packageInfo)).resolves.toEqual({ id: 'someId', configPath: ['some', 'path'], version: 'some-version', @@ -355,7 +340,7 @@ test('return manifest when plugin expected Kibana version matches actual version ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).resolves.toEqual({ + await expect(parseManifest(pluginPath, packageInfo)).resolves.toEqual({ id: 'someId', configPath: 'some-path', version: 'some-version', @@ -385,7 +370,7 @@ test('return manifest when plugin expected Kibana version is `kibana`', async () ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).resolves.toEqual({ + await expect(parseManifest(pluginPath, packageInfo)).resolves.toEqual({ id: 'someId', configPath: 'some_id', version: 'some-version', diff --git a/src/core/server/plugins/discovery/plugin_manifest_parser.ts b/src/core/server/plugins/discovery/plugin_manifest_parser.ts index 9db68bcaa4cce..eae0e73e86c46 100644 --- a/src/core/server/plugins/discovery/plugin_manifest_parser.ts +++ b/src/core/server/plugins/discovery/plugin_manifest_parser.ts @@ -12,7 +12,6 @@ import { coerce } from 'semver'; import { promisify } from 'util'; import { snakeCase } from 'lodash'; import { isConfigPath, PackageInfo } from '../../config'; -import { Logger } from '../../logging'; import { PluginManifest } from '../types'; import { PluginDiscoveryError } from './plugin_discovery_error'; import { isCamelCase } from './is_camel_case'; @@ -63,8 +62,7 @@ const KNOWN_MANIFEST_FIELDS = (() => { */ export async function parseManifest( pluginPath: string, - packageInfo: PackageInfo, - log: Logger + packageInfo: PackageInfo ): Promise { const manifestPath = resolve(pluginPath, MANIFEST_FILE_NAME); @@ -105,8 +103,11 @@ export async function parseManifest( ); } - if (!packageInfo.dist && !isCamelCase(manifest.id)) { - log.warn(`Expect plugin "id" in camelCase, but found: ${manifest.id}`); + if (!isCamelCase(manifest.id)) { + throw PluginDiscoveryError.invalidManifest( + manifestPath, + new Error(`Plugin "id" must be camelCase, but found: ${manifest.id}.`) + ); } if (!manifest.version || typeof manifest.version !== 'string') { diff --git a/src/core/server/plugins/discovery/plugins_discovery.ts b/src/core/server/plugins/discovery/plugins_discovery.ts index 61eccff982593..368795968a7cb 100644 --- a/src/core/server/plugins/discovery/plugins_discovery.ts +++ b/src/core/server/plugins/discovery/plugins_discovery.ts @@ -179,7 +179,7 @@ function createPlugin$( coreContext: CoreContext, instanceInfo: InstanceInfo ) { - return from(parseManifest(path, coreContext.env.packageInfo, log)).pipe( + return from(parseManifest(path, coreContext.env.packageInfo)).pipe( map((manifest) => { log.debug(`Successfully discovered plugin "${manifest.id}" at "${path}"`); const opaqueId = Symbol(manifest.id); diff --git a/test/common/fixtures/plugins/newsfeed/kibana.json b/test/common/fixtures/plugins/newsfeed/kibana.json index 110b53fc6b2e9..0fbd24f45b684 100644 --- a/test/common/fixtures/plugins/newsfeed/kibana.json +++ b/test/common/fixtures/plugins/newsfeed/kibana.json @@ -1,5 +1,5 @@ { - "id": "newsfeed-fixtures", + "id": "newsfeedFixtures", "version": "kibana", "server": true, "ui": false diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json index 084cee2fddf08..2fd2a9e5144d4 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json @@ -1,5 +1,5 @@ { - "id": "kbn_tp_run_pipeline", + "id": "kbnTpRunPipeline", "version": "0.0.1", "kibanaVersion": "kibana", "requiredPlugins": [ diff --git a/test/plugin_functional/plugins/app_link_test/kibana.json b/test/plugin_functional/plugins/app_link_test/kibana.json index 5384d4fee1508..c37eae274460c 100644 --- a/test/plugin_functional/plugins/app_link_test/kibana.json +++ b/test/plugin_functional/plugins/app_link_test/kibana.json @@ -1,5 +1,5 @@ { - "id": "app_link_test", + "id": "appLinkTest", "version": "0.0.1", "kibanaVersion": "kibana", "server": false, diff --git a/test/plugin_functional/plugins/core_app_status/kibana.json b/test/plugin_functional/plugins/core_app_status/kibana.json index 91d8e6fd8f9e1..eb825cf9990c9 100644 --- a/test/plugin_functional/plugins/core_app_status/kibana.json +++ b/test/plugin_functional/plugins/core_app_status/kibana.json @@ -1,5 +1,5 @@ { - "id": "core_app_status", + "id": "coreAppStatus", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["core_app_status"], diff --git a/test/plugin_functional/plugins/core_plugin_a/kibana.json b/test/plugin_functional/plugins/core_plugin_a/kibana.json index 0989595c49a58..9a153011bdc70 100644 --- a/test/plugin_functional/plugins/core_plugin_a/kibana.json +++ b/test/plugin_functional/plugins/core_plugin_a/kibana.json @@ -1,5 +1,5 @@ { - "id": "core_plugin_a", + "id": "corePluginA", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["core_plugin_a"], diff --git a/test/plugin_functional/plugins/core_plugin_appleave/kibana.json b/test/plugin_functional/plugins/core_plugin_appleave/kibana.json index 95343cbcf2804..f9337fcc226f2 100644 --- a/test/plugin_functional/plugins/core_plugin_appleave/kibana.json +++ b/test/plugin_functional/plugins/core_plugin_appleave/kibana.json @@ -1,5 +1,5 @@ { - "id": "core_plugin_appleave", + "id": "corePluginAppleave", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["core_plugin_appleave"], diff --git a/test/plugin_functional/plugins/core_plugin_b/kibana.json b/test/plugin_functional/plugins/core_plugin_b/kibana.json index 7c6aa597c82fa..d132e714ea31d 100644 --- a/test/plugin_functional/plugins/core_plugin_b/kibana.json +++ b/test/plugin_functional/plugins/core_plugin_b/kibana.json @@ -1,10 +1,10 @@ { - "id": "core_plugin_b", + "id": "corePluginB", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["core_plugin_b"], "server": true, "ui": true, - "requiredPlugins": ["core_plugin_a"], - "optionalPlugins": ["core_plugin_c"] + "requiredPlugins": ["corePluginA"], + "optionalPlugins": ["corePluginC"] } diff --git a/test/plugin_functional/plugins/core_plugin_b/public/plugin.tsx b/test/plugin_functional/plugins/core_plugin_b/public/plugin.tsx index 48c8d85b21dac..5bab0275439df 100644 --- a/test/plugin_functional/plugins/core_plugin_b/public/plugin.tsx +++ b/test/plugin_functional/plugins/core_plugin_b/public/plugin.tsx @@ -16,7 +16,7 @@ declare global { } export interface CorePluginBDeps { - core_plugin_a: CorePluginAPluginSetup; + corePluginA: CorePluginAPluginSetup; } export class CorePluginBPlugin @@ -37,7 +37,7 @@ export class CorePluginBPlugin return { sayHi() { - return `Plugin A said: ${deps.core_plugin_a.getGreeting()}`; + return `Plugin A said: ${deps.corePluginA.getGreeting()}`; }, }; } diff --git a/test/plugin_functional/plugins/core_plugin_chromeless/kibana.json b/test/plugin_functional/plugins/core_plugin_chromeless/kibana.json index a8a5616627726..61863781b8f32 100644 --- a/test/plugin_functional/plugins/core_plugin_chromeless/kibana.json +++ b/test/plugin_functional/plugins/core_plugin_chromeless/kibana.json @@ -1,5 +1,5 @@ { - "id": "core_plugin_chromeless", + "id": "corePluginChromeless", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["core_plugin_chromeless"], diff --git a/test/plugin_functional/plugins/core_plugin_helpmenu/kibana.json b/test/plugin_functional/plugins/core_plugin_helpmenu/kibana.json index 984b96a8bcba1..1b0f477ef34ae 100644 --- a/test/plugin_functional/plugins/core_plugin_helpmenu/kibana.json +++ b/test/plugin_functional/plugins/core_plugin_helpmenu/kibana.json @@ -1,5 +1,5 @@ { - "id": "core_plugin_helpmenu", + "id": "corePluginHelpmenu", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["core_plugin_helpmenu"], diff --git a/test/plugin_functional/plugins/core_plugin_route_timeouts/kibana.json b/test/plugin_functional/plugins/core_plugin_route_timeouts/kibana.json index 6fbddad22b764..000f8e38a1035 100644 --- a/test/plugin_functional/plugins/core_plugin_route_timeouts/kibana.json +++ b/test/plugin_functional/plugins/core_plugin_route_timeouts/kibana.json @@ -1,5 +1,5 @@ { - "id": "core_plugin_route_timeouts", + "id": "corePluginRouteTimeouts", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["core_plugin_route_timeouts"], diff --git a/test/plugin_functional/plugins/core_provider_plugin/kibana.json b/test/plugin_functional/plugins/core_provider_plugin/kibana.json index 8d9b30acab893..c55f62762e233 100644 --- a/test/plugin_functional/plugins/core_provider_plugin/kibana.json +++ b/test/plugin_functional/plugins/core_provider_plugin/kibana.json @@ -1,8 +1,8 @@ { - "id": "core_provider_plugin", + "id": "coreProviderPlugin", "version": "0.0.1", "kibanaVersion": "kibana", - "optionalPlugins": ["core_plugin_a", "core_plugin_b", "licensing", "globalSearchTest"], + "optionalPlugins": ["corePluginA", "corePluginB", "licensing", "globalSearchTest"], "server": false, "ui": true } diff --git a/test/plugin_functional/plugins/data_search/kibana.json b/test/plugin_functional/plugins/data_search/kibana.json index 3acbe9f97d8f0..28f7eb9996fc5 100644 --- a/test/plugin_functional/plugins/data_search/kibana.json +++ b/test/plugin_functional/plugins/data_search/kibana.json @@ -1,5 +1,5 @@ { - "id": "data_search_plugin", + "id": "dataSearchPlugin", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["data_search_test_plugin"], diff --git a/test/plugin_functional/plugins/elasticsearch_client_plugin/kibana.json b/test/plugin_functional/plugins/elasticsearch_client_plugin/kibana.json index a7674881e8ba0..3d934414adc2f 100644 --- a/test/plugin_functional/plugins/elasticsearch_client_plugin/kibana.json +++ b/test/plugin_functional/plugins/elasticsearch_client_plugin/kibana.json @@ -1,5 +1,5 @@ { - "id": "elasticsearch_client_plugin", + "id": "elasticsearchClientPlugin", "version": "0.0.1", "kibanaVersion": "kibana", "server": true, diff --git a/test/plugin_functional/plugins/index_patterns/kibana.json b/test/plugin_functional/plugins/index_patterns/kibana.json index e098950dc9677..3b41fa5124a45 100644 --- a/test/plugin_functional/plugins/index_patterns/kibana.json +++ b/test/plugin_functional/plugins/index_patterns/kibana.json @@ -1,5 +1,5 @@ { - "id": "index_patterns_test_plugin", + "id": "indexPatternsTestPlugin", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["index_patterns_test_plugin"], diff --git a/test/plugin_functional/plugins/kbn_sample_panel_action/kibana.json b/test/plugin_functional/plugins/kbn_sample_panel_action/kibana.json index 08ce182aa0293..51a254016b650 100644 --- a/test/plugin_functional/plugins/kbn_sample_panel_action/kibana.json +++ b/test/plugin_functional/plugins/kbn_sample_panel_action/kibana.json @@ -1,5 +1,5 @@ { - "id": "kbn_sample_panel_action", + "id": "kbnSamplePanelAction", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["kbn_sample_panel_action"], diff --git a/test/plugin_functional/plugins/kbn_top_nav/kibana.json b/test/plugin_functional/plugins/kbn_top_nav/kibana.json index b274e80b9ef65..a656eae476b87 100644 --- a/test/plugin_functional/plugins/kbn_top_nav/kibana.json +++ b/test/plugin_functional/plugins/kbn_top_nav/kibana.json @@ -1,9 +1,9 @@ { - "id": "kbn_top_nav", + "id": "kbnTopNav", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["kbn_top_nav"], "server": false, "ui": true, "requiredPlugins": ["navigation"] -} \ No newline at end of file +} diff --git a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/kibana.json b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/kibana.json index 33c8f3238dc47..3e2d1c9e98fee 100644 --- a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/kibana.json +++ b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/kibana.json @@ -1,5 +1,5 @@ { - "id": "kbn_tp_custom_visualizations", + "id": "kbnTpCustomVisualizations", "version": "0.0.1", "kibanaVersion": "kibana", "requiredPlugins": [ diff --git a/test/plugin_functional/plugins/management_test_plugin/kibana.json b/test/plugin_functional/plugins/management_test_plugin/kibana.json index e52b60b3a4e31..f07c2ae997221 100644 --- a/test/plugin_functional/plugins/management_test_plugin/kibana.json +++ b/test/plugin_functional/plugins/management_test_plugin/kibana.json @@ -1,5 +1,5 @@ { - "id": "management_test_plugin", + "id": "managementTestPlugin", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["management_test_plugin"], diff --git a/test/plugin_functional/plugins/rendering_plugin/kibana.json b/test/plugin_functional/plugins/rendering_plugin/kibana.json index 886eca2bdde1d..f5f218db3c184 100644 --- a/test/plugin_functional/plugins/rendering_plugin/kibana.json +++ b/test/plugin_functional/plugins/rendering_plugin/kibana.json @@ -1,5 +1,5 @@ { - "id": "rendering_plugin", + "id": "renderingPlugin", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["rendering_plugin"], diff --git a/test/plugin_functional/plugins/session_notifications/kibana.json b/test/plugin_functional/plugins/session_notifications/kibana.json index 0b80b531d2f84..939a96e3f21d6 100644 --- a/test/plugin_functional/plugins/session_notifications/kibana.json +++ b/test/plugin_functional/plugins/session_notifications/kibana.json @@ -1,9 +1,9 @@ { - "id": "session_notifications", + "id": "sessionNotifications", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["session_notifications"], "server": false, "ui": true, "requiredPlugins": ["data", "navigation"] -} \ No newline at end of file +} diff --git a/test/plugin_functional/plugins/ui_settings_plugin/kibana.json b/test/plugin_functional/plugins/ui_settings_plugin/kibana.json index 35e4c35490e2f..459d995333eca 100644 --- a/test/plugin_functional/plugins/ui_settings_plugin/kibana.json +++ b/test/plugin_functional/plugins/ui_settings_plugin/kibana.json @@ -1,5 +1,5 @@ { - "id": "ui_settings_plugin", + "id": "uiSettingsPlugin", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["ui_settings_plugin"], diff --git a/test/plugin_functional/test_suites/core_plugins/ui_plugins.ts b/test/plugin_functional/test_suites/core_plugins/ui_plugins.ts index 4015b8959ece6..1d6b33e41b772 100644 --- a/test/plugin_functional/test_suites/core_plugins/ui_plugins.ts +++ b/test/plugin_functional/test_suites/core_plugins/ui_plugins.ts @@ -24,7 +24,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide it('should run the new platform plugins', async () => { expect( await browser.execute(() => { - return window._coreProvider.setup.plugins.core_plugin_b.sayHi(); + return window._coreProvider.setup.plugins.corePluginB.sayHi(); }) ).to.be('Plugin A said: Hello from Plugin A!'); }); @@ -65,7 +65,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide it('should send kbn-system-request header when asSystemRequest: true', async () => { expect( await browser.executeAsync(async (cb) => { - window._coreProvider.start.plugins.core_plugin_b.sendSystemRequest(true).then(cb); + window._coreProvider.start.plugins.corePluginB.sendSystemRequest(true).then(cb); }) ).to.be('/core_plugin_b/system_request says: "System request? true"'); }); @@ -73,7 +73,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide it('should not send kbn-system-request header when asSystemRequest: false', async () => { expect( await browser.executeAsync(async (cb) => { - window._coreProvider.start.plugins.core_plugin_b.sendSystemRequest(false).then(cb); + window._coreProvider.start.plugins.corePluginB.sendSystemRequest(false).then(cb); }) ).to.be('/core_plugin_b/system_request says: "System request? false"'); }); diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/aad/kibana.json b/x-pack/test/alerting_api_integration/common/fixtures/plugins/aad/kibana.json index 9a7bedbb5c6d5..6a43c7c74ad8c 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/aad/kibana.json +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/aad/kibana.json @@ -1,5 +1,5 @@ { - "id": "aad-fixtures", + "id": "aadFixtures", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/kibana.json b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/kibana.json index 5f92b9e5479e8..f63d6ef0d45ac 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/kibana.json +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/kibana.json @@ -1,5 +1,5 @@ { - "id": "actions_simulators", + "id": "actionsSimulators", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager_fixture/kibana.json b/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager_fixture/kibana.json index 8f606276998f5..2f8117163471d 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager_fixture/kibana.json +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager_fixture/kibana.json @@ -1,5 +1,5 @@ { - "id": "task_manager_fixture", + "id": "taskManagerFixture", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/functional_cors/plugins/kibana_cors_test/kibana.json b/x-pack/test/functional_cors/plugins/kibana_cors_test/kibana.json index 9c94f2006b7f8..a0ebde9bff4b7 100644 --- a/x-pack/test/functional_cors/plugins/kibana_cors_test/kibana.json +++ b/x-pack/test/functional_cors/plugins/kibana_cors_test/kibana.json @@ -1,5 +1,5 @@ { - "id": "kibana_cors_test", + "id": "kibanaCorsTest", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["test", "cors"], diff --git a/x-pack/test/functional_embedded/plugins/iframe_embedded/kibana.json b/x-pack/test/functional_embedded/plugins/iframe_embedded/kibana.json index ea9f55bd21c6e..919b7f69d28b9 100644 --- a/x-pack/test/functional_embedded/plugins/iframe_embedded/kibana.json +++ b/x-pack/test/functional_embedded/plugins/iframe_embedded/kibana.json @@ -1,5 +1,5 @@ { - "id": "iframe_embedded", + "id": "iframeEmbedded", "version": "1.0.0", "kibanaVersion": "kibana", "server": true, diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json index 784a766e608bc..11a8fb977cd78 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json @@ -1,5 +1,5 @@ { - "id": "alerting_fixture", + "id": "alertingFixture", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/kibana.json b/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/kibana.json index 37ec33c168e76..5f4cb3f7f7eb2 100644 --- a/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/kibana.json +++ b/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/kibana.json @@ -1,5 +1,5 @@ { - "id": "elasticsearch_client_xpack", + "id": "elasticsearchClientXpack", "version": "1.0.0", "kibanaVersion": "kibana", "server": true, diff --git a/x-pack/test/plugin_api_integration/plugins/event_log/kibana.json b/x-pack/test/plugin_api_integration/plugins/event_log/kibana.json index 4b467ce975012..4c940ffec1463 100644 --- a/x-pack/test/plugin_api_integration/plugins/event_log/kibana.json +++ b/x-pack/test/plugin_api_integration/plugins/event_log/kibana.json @@ -1,5 +1,5 @@ { - "id": "event_log_fixture", + "id": "eventLogFixture", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/plugin_api_integration/plugins/feature_usage_test/kibana.json b/x-pack/test/plugin_api_integration/plugins/feature_usage_test/kibana.json index b11b7ada24a57..b81f96362e9f5 100644 --- a/x-pack/test/plugin_api_integration/plugins/feature_usage_test/kibana.json +++ b/x-pack/test/plugin_api_integration/plugins/feature_usage_test/kibana.json @@ -1,5 +1,5 @@ { - "id": "feature_usage_test", + "id": "featureUsageTest", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "feature_usage_test"], diff --git a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/kibana.json b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/kibana.json index 416ef7fa34591..6a8a2221b48d3 100644 --- a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/kibana.json +++ b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/kibana.json @@ -1,5 +1,5 @@ { - "id": "sample_task_plugin", + "id": "sampleTaskPlugin", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/kibana.json b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/kibana.json index 1fa480cd53c48..387f392c8db98 100644 --- a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/kibana.json +++ b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/kibana.json @@ -1,5 +1,5 @@ { - "id": "task_manager_performance", + "id": "taskManagerPerformance", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/plugin_functional/plugins/resolver_test/kibana.json b/x-pack/test/plugin_functional/plugins/resolver_test/kibana.json index 499983561e89d..a203705e13ed6 100644 --- a/x-pack/test/plugin_functional/plugins/resolver_test/kibana.json +++ b/x-pack/test/plugin_functional/plugins/resolver_test/kibana.json @@ -1,5 +1,5 @@ { - "id": "resolver_test", + "id": "resolverTest", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "resolverTest"], diff --git a/x-pack/test/security_api_integration/fixtures/oidc/oidc_provider/kibana.json b/x-pack/test/security_api_integration/fixtures/oidc/oidc_provider/kibana.json index faaa0b9165828..aa7cd499a173a 100644 --- a/x-pack/test/security_api_integration/fixtures/oidc/oidc_provider/kibana.json +++ b/x-pack/test/security_api_integration/fixtures/oidc/oidc_provider/kibana.json @@ -1,5 +1,5 @@ { - "id": "oidc_provider_plugin", + "id": "oidcProviderPlugin", "version": "8.0.0", "kibanaVersion": "kibana", "server": true, diff --git a/x-pack/test/security_api_integration/fixtures/saml/saml_provider/kibana.json b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/kibana.json index 3cbd37e38bb2d..81ec23fc3d2f3 100644 --- a/x-pack/test/security_api_integration/fixtures/saml/saml_provider/kibana.json +++ b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/kibana.json @@ -1,5 +1,5 @@ { - "id": "saml_provider_plugin", + "id": "samlProviderPlugin", "version": "8.0.0", "kibanaVersion": "kibana", "server": true, diff --git a/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/kibana.json b/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/kibana.json index cec1640fbb047..912cf5d70e16b 100644 --- a/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/kibana.json +++ b/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/kibana.json @@ -1,5 +1,5 @@ { - "id": "foo_plugin", + "id": "fooPlugin", "version": "1.0.0", "kibanaVersion": "kibana", "requiredPlugins": ["features"], diff --git a/x-pack/test/usage_collection/plugins/stack_management_usage_test/kibana.json b/x-pack/test/usage_collection/plugins/stack_management_usage_test/kibana.json index b586de3fa4d79..c41fe744ca946 100644 --- a/x-pack/test/usage_collection/plugins/stack_management_usage_test/kibana.json +++ b/x-pack/test/usage_collection/plugins/stack_management_usage_test/kibana.json @@ -1,8 +1,8 @@ { - "id": "StackManagementUsageTest", + "id": "stackManagementUsageTest", "version": "1.0.0", "kibanaVersion": "kibana", - "configPath": ["xpack", "StackManagementUsageTest"], + "configPath": ["xpack", "stackManagementUsageTest"], "requiredPlugins": [], "server": false, "ui": true From 942f6d05684f5ba77bb3fa1bdc498a7a12734e02 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Thu, 11 Feb 2021 13:01:24 -0500 Subject: [PATCH 18/53] [Security Solution] Use sourcerer selected indices in resolver (#90727) (#91008) * Use sourcer indices * Add indices to panel requests * Use a separate indices selector for resolver events * Use valid timeline id in tests * Update TimelineId type usage, make selector test clearer * Update tests to use TimelineId type --- .../common/types/timeline/index.ts | 1 + .../events_viewer/events_viewer.test.tsx | 15 ++-- .../events_viewer/events_viewer.tsx | 2 +- .../components/events_viewer/index.test.tsx | 3 +- .../common/components/events_viewer/index.tsx | 3 +- .../public/resolver/store/data/reducer.ts | 2 + .../resolver/store/data/selectors.test.ts | 81 +++++++++++++++++++ .../public/resolver/store/data/selectors.ts | 27 ++++++- .../current_related_event_fetcher.ts | 2 +- .../store/middleware/node_data_fetcher.ts | 2 +- .../middleware/related_events_fetcher.ts | 2 +- .../public/resolver/store/selectors.ts | 7 +- .../public/resolver/types.ts | 2 + .../components/flyout/index.test.tsx | 3 +- .../timelines/components/flyout/index.tsx | 2 +- .../components/flyout/pane/index.test.tsx | 3 +- .../components/flyout/pane/index.tsx | 3 +- .../components/graph_overlay/index.test.tsx | 10 ++- .../components/graph_overlay/index.tsx | 30 ++++--- .../timeline/graph_tab_content/index.tsx | 3 +- .../components/timeline/index.test.tsx | 6 +- .../timelines/components/timeline/index.tsx | 4 +- .../timeline/tabs_content/index.tsx | 4 +- 23 files changed, 172 insertions(+), 45 deletions(-) diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 1ea9b5752e0cc..26a30e7c8f239 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -280,6 +280,7 @@ export enum TimelineId { active = 'timeline-1', casePage = 'timeline-case', test = 'test', // Reserved for testing purposes + test2 = 'test2', } export const TimelineIdLiteralRt = runtimeTypes.union([ diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 4364ca2d3465d..6dad6c439ce46 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -119,7 +119,7 @@ describe('EventsViewer', () => { let testProps = { defaultModel: eventsDefaultModel, end: to, - id: 'test-stateful-events-viewer', + id: TimelineId.test, start: from, scopeId: SourcererScopeName.timeline, }; @@ -155,7 +155,7 @@ describe('EventsViewer', () => { indexName: 'auditbeat-7.10.1-2020.12.18-000001', }, tabType: 'query', - timelineId: 'test-stateful-events-viewer', + timelineId: TimelineId.test, }, type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT', }); @@ -199,17 +199,22 @@ describe('EventsViewer', () => { defaultHeaders.forEach((header) => { test(`it renders the ${header.id} default EventsViewer column header`, () => { + testProps = { + ...testProps, + // Update with a new id, to force columns back to default. + id: TimelineId.test2, + }; const wrapper = mount( ); - defaultHeaders.forEach((h) => + defaultHeaders.forEach((h) => { expect(wrapper.find(`[data-test-subj="header-text-${header.id}"]`).first().exists()).toBe( true - ) - ); + ); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 77573dbab0a53..254309aee906b 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -117,7 +117,7 @@ interface Props { filters: Filter[]; headerFilterGroup?: React.ReactNode; height?: number; - id: string; + id: TimelineId; indexNames: string[]; indexPattern: IIndexPattern; isLive: boolean; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx index c2fbfdb666e04..5004c23f9111c 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx @@ -16,6 +16,7 @@ import { useMountAppended } from '../../utils/use_mount_appended'; import { mockEventViewerResponse } from './mock'; import { StatefulEventsViewer } from '.'; import { eventsDefaultModel } from './default_model'; +import { TimelineId } from '../../../../common/types/timeline'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useTimelineEvents } from '../../../timelines/containers'; @@ -36,7 +37,7 @@ const testProps = { defaultModel: eventsDefaultModel, end: to, indexNames: [], - id: 'test-stateful-events-viewer', + id: TimelineId.test, scopeId: SourcererScopeName.default, start: from, }; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 526bc312172b0..2b5420674b89c 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -12,6 +12,7 @@ import styled from 'styled-components'; import { inputsModel, inputsSelectors, State } from '../../store'; import { inputsActions } from '../../store/actions'; +import { TimelineId } from '../../../../common/types/timeline'; import { timelineSelectors, timelineActions } from '../../../timelines/store/timeline'; import { SubsetTimelineModel, TimelineModel } from '../../../timelines/store/timeline/model'; import { Filter } from '../../../../../../../src/plugins/data/public'; @@ -34,7 +35,7 @@ const FullScreenContainer = styled.div<{ $isFullScreen: boolean }>` export interface OwnProps { defaultModel: SubsetTimelineModel; end: string; - id: string; + id: TimelineId; scopeId: SourcererScopeName; start: string; headerFilterGroup?: React.ReactNode; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts index 8df0c92ca83e5..b5864a0a83cf2 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts @@ -19,6 +19,7 @@ const initialState: DataState = { data: null, }, resolverComponentInstanceID: undefined, + indices: [], }; /* eslint-disable complexity */ export const dataReducer: Reducer = (state = initialState, action) => { @@ -35,6 +36,7 @@ export const dataReducer: Reducer = (state = initialS }, resolverComponentInstanceID: action.payload.resolverComponentInstanceID, locationSearch: action.payload.locationSearch, + indices: action.payload.indices, }; const panelViewAndParameters = selectors.panelViewAndParameters(nextState); return { diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts index c372c98c6e060..b864bb254a5fc 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts @@ -664,4 +664,85 @@ describe('data state', () => { `); }); }); + describe('when the resolver tree response is complete, still use non-default indices', () => { + beforeEach(() => { + const { resolverTree } = mockTreeWithNoAncestorsAnd2Children({ + originID: 'a', + firstChildID: 'b', + secondChildID: 'c', + }); + const { schema, dataSource } = endpointSourceSchema(); + actions = [ + { + type: 'serverReturnedResolverData', + payload: { + result: resolverTree, + dataSource, + schema, + parameters: { + databaseDocumentID: '', + indices: ['someNonDefaultIndex'], + filters: {}, + }, + }, + }, + ]; + }); + it('should have an empty array for tree parameter indices, and a non empty array for event indices', () => { + const treeParameterIndices = selectors.treeParameterIndices(state()); + expect(treeParameterIndices.length).toBe(0); + const eventIndices = selectors.eventIndices(state()); + expect(eventIndices.length).toBe(1); + }); + }); + describe('when the resolver tree response is pending use the same indices the user is currently looking at data from', () => { + beforeEach(() => { + const { resolverTree } = mockTreeWithNoAncestorsAnd2Children({ + originID: 'a', + firstChildID: 'b', + secondChildID: 'c', + }); + const { schema, dataSource } = endpointSourceSchema(); + actions = [ + { + type: 'serverReturnedResolverData', + payload: { + result: resolverTree, + dataSource, + schema, + parameters: { + databaseDocumentID: '', + indices: ['defaultIndex'], + filters: {}, + }, + }, + }, + { + type: 'appReceivedNewExternalProperties', + payload: { + databaseDocumentID: '', + resolverComponentInstanceID: '', + locationSearch: '', + indices: ['someNonDefaultIndex', 'someOtherIndex'], + shouldUpdate: false, + filters: {}, + }, + }, + { + type: 'appRequestedResolverData', + payload: { + databaseDocumentID: '', + indices: ['someNonDefaultIndex', 'someOtherIndex'], + filters: {}, + }, + }, + ]; + }); + it('should have an empty array for tree parameter indices, and the same set of indices as the last tree response', () => { + const treeParameterIndices = selectors.treeParameterIndices(state()); + expect(treeParameterIndices.length).toBe(0); + const eventIndices = selectors.eventIndices(state()); + expect(eventIndices.length).toBe(1); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index a39aa4f0cd983..fb6fb6073d7cf 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -63,6 +63,13 @@ export function resolverComponentInstanceID(state: DataState): string { return state.resolverComponentInstanceID ? state.resolverComponentInstanceID : ''; } +/** + * The indices resolver should use, passed in as external props. + */ +const currentIndices = (state: DataState): string[] => { + return state.indices; +}; + /** * The last NewResolverTree we received, if any. It may be stale (it might not be for the same databaseDocumentID that * we're currently interested in. @@ -71,6 +78,12 @@ const resolverTreeResponse = (state: DataState): NewResolverTree | undefined => return state.tree?.lastResponse?.successful ? state.tree?.lastResponse.result : undefined; }; +const lastResponseIndices = (state: DataState): string[] | undefined => { + return state.tree?.lastResponse?.successful + ? state.tree?.lastResponse?.parameters?.indices + : undefined; +}; + /** * If we received a NewResolverTree, return the schema associated with that tree, otherwise return undefined. * As of writing, this is only used for the info popover in the graph_controls panel @@ -336,10 +349,22 @@ export const timeRangeFilters = createSelector( /** * The indices to use for the requests with the backend. */ -export const treeParamterIndices = createSelector(treeParametersToFetch, (parameters) => { +export const treeParameterIndices = createSelector(treeParametersToFetch, (parameters) => { return parameters?.indices ?? []; }); +/** + * Panel requests should not use indices derived from the tree parameter selector, as this is only defined briefly while the resolver_tree_fetcher middleware is running. + * Instead, panel requests should use the indices used by the last good request, falling back to the indices passed as external props. + */ +export const eventIndices = createSelector( + lastResponseIndices, + currentIndices, + function eventIndices(lastIndices, current): string[] { + return lastIndices ?? current ?? []; + } +); + export const layout: (state: DataState) => IsometricTaxiLayout = createSelector( tree, originID, diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/current_related_event_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/current_related_event_fetcher.ts index 3b8389182e990..33772dddd676e 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/current_related_event_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/current_related_event_fetcher.ts @@ -32,7 +32,7 @@ export function CurrentRelatedEventFetcher( const state = api.getState(); const newParams = selectors.panelViewAndParameters(state); - const indices = selectors.treeParameterIndices(state); + const indices = selectors.eventIndices(state); const oldParams = last; last = newParams; diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/node_data_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/node_data_fetcher.ts index 696e7f921673b..074fdf7535790 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/node_data_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/node_data_fetcher.ts @@ -38,7 +38,7 @@ export function NodeDataFetcher( * This gets the visible nodes that we haven't already requested or received data for */ const newIDsToRequest: Set = selectors.newIDsToRequest(state)(Number.POSITIVE_INFINITY); - const indices = selectors.treeParameterIndices(state); + const indices = selectors.eventIndices(state); if (newIDsToRequest.size <= 0) { return; diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts index fbce03caf64d8..19a11e07a9d87 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts @@ -27,7 +27,7 @@ export function RelatedEventsFetcher( const newParams = selectors.panelViewAndParameters(state); const isLoadingMoreEvents = selectors.isLoadingMoreNodeEventsInCategory(state); - const indices = selectors.treeParameterIndices(state); + const indices = selectors.eventIndices(state); const oldParams = last; const timeRangeFilters = selectors.timeRangeFilters(state); diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index a845de57bbdc6..4c088a8be4ed9 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -80,9 +80,14 @@ export const treeRequestParametersToAbort = composeSelectors( */ export const treeParameterIndices = composeSelectors( dataStateSelector, - dataSelectors.treeParamterIndices + dataSelectors.treeParameterIndices ); +/** + * An array of indices to use for resolver panel requests. + */ +export const eventIndices = composeSelectors(dataStateSelector, dataSelectors.eventIndices); + export const resolverComponentInstanceID = composeSelectors( dataStateSelector, dataSelectors.resolverComponentInstanceID diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index d3ddc51429ccd..e6a004938a267 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -376,6 +376,8 @@ export interface DataState { */ readonly resolverComponentInstanceID?: string; + readonly indices: string[]; + /** * The `search` part of the URL. */ diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx index 3783f5591c43e..f57ce42e7e079 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx @@ -18,6 +18,7 @@ import { kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; +import { TimelineId } from '../../../../common/types/timeline'; import { createStore, State } from '../../../common/store'; import * as timelineActions from '../../store/timeline/actions'; @@ -43,7 +44,7 @@ describe('Flyout', () => { const { storage } = createSecuritySolutionStorageMock(); const props = { onAppLeave: jest.fn(), - timelineId: 'test', + timelineId: TimelineId.test, }; beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx index f7518c2c34f66..bd7c7fbd1941f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx @@ -26,7 +26,7 @@ const Visible = styled.div<{ show?: boolean }>` Visible.displayName = 'Visible'; interface OwnProps { - timelineId: string; + timelineId: TimelineId; onAppLeave: (handler: AppLeaveHandler) => void; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.test.tsx index e16cec78cf13b..4ccc7ef5b5bc5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.test.tsx @@ -9,13 +9,14 @@ import { shallow } from 'enzyme'; import React from 'react'; import { TestProviders } from '../../../../common/mock'; +import { TimelineId } from '../../../../../common/types/timeline'; import { Pane } from '.'; describe('Pane', () => { test('renders correctly against snapshot', () => { const EmptyComponent = shallow( - + ); expect(EmptyComponent.find('Pane')).toMatchSnapshot(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx index a4d85bd76b105..e63ffedf3da7c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx @@ -11,12 +11,13 @@ import styled from 'styled-components'; import { useDispatch } from 'react-redux'; import { StatefulTimeline } from '../../timeline'; +import { TimelineId } from '../../../../../common/types/timeline'; import * as i18n from './translations'; import { timelineActions } from '../../../store/timeline'; import { focusActiveTimelineButton } from '../../timeline/helpers'; interface FlyoutPaneComponentProps { - timelineId: string; + timelineId: TimelineId; } const EuiFlyoutContainer = styled.div` diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx index c0e1a54faa8dd..1286208bff9e6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx @@ -15,7 +15,6 @@ import { } from '../../../common/containers/use_full_screen'; import { mockTimelineModel, TestProviders } from '../../../common/mock'; import { TimelineId } from '../../../../common/types/timeline'; - import { GraphOverlay } from '.'; jest.mock('../../../common/hooks/use_selector', () => ({ @@ -28,6 +27,10 @@ jest.mock('../../../common/containers/use_full_screen', () => ({ useTimelineFullScreen: jest.fn(), })); +jest.mock('../../../resolver/view/use_resolver_query_params_cleaner'); +jest.mock('../../../resolver/view/use_state_syncing_actions'); +jest.mock('../../../resolver/view/use_sync_selected_node'); + describe('GraphOverlay', () => { beforeEach(() => { (useGlobalFullScreen as jest.Mock).mockReturnValue({ @@ -42,12 +45,11 @@ describe('GraphOverlay', () => { describe('when used in an events viewer (i.e. in the Detections view, or the Host > Events view)', () => { const isEventViewer = true; - const timelineId = 'used-as-an-events-viewer'; test('it has 100% width when isEventViewer is true and NOT in full screen mode', async () => { const wrapper = mount( - + ); @@ -69,7 +71,7 @@ describe('GraphOverlay', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index 1b3a0c21ef683..9c9c56461609d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -20,7 +20,7 @@ import styled from 'styled-components'; import { FULL_SCREEN } from '../timeline/body/column_headers/translations'; import { EXIT_FULL_SCREEN } from '../../../common/components/exit_full_screen/translations'; -import { DEFAULT_INDEX_KEY, FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; +import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; import { useGlobalFullScreen, useTimelineFullScreen, @@ -30,6 +30,8 @@ import { TimelineId } from '../../../../common/types/timeline'; import { timelineSelectors } from '../../store/timeline'; import { timelineDefaults } from '../../store/timeline/defaults'; import { isFullScreen } from '../timeline/body/column_headers'; +import { useSourcererScope } from '../../../common/containers/sourcerer'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { updateTimelineGraphEventId } from '../../../timelines/store/timeline/actions'; import { Resolver } from '../../../resolver/view'; import { @@ -38,8 +40,6 @@ import { endSelector, } from '../../../common/components/super_date_picker/selectors'; import * as i18n from './translations'; -import { useUiSetting$ } from '../../../common/lib/kibana'; -import { useSignalIndex } from '../../../detections/containers/detection_engine/alerts/use_signal_index'; const OverlayContainer = styled.div` ${({ $restrictWidth }: { $restrictWidth: boolean }) => @@ -61,14 +61,14 @@ const FullScreenButtonIcon = styled(EuiButtonIcon)` interface OwnProps { isEventViewer: boolean; - timelineId: string; + timelineId: TimelineId; } interface NavigationProps { fullScreen: boolean; globalFullScreen: boolean; onCloseOverlay: () => void; - timelineId: string; + timelineId: TimelineId; timelineFullScreen: boolean; toggleFullScreen: () => void; } @@ -169,16 +169,14 @@ const GraphOverlayComponent: React.FC = ({ isEventViewer, timelineId } globalFullScreen, ]); - const { signalIndexName } = useSignalIndex(); - const [siemDefaultIndices] = useUiSetting$(DEFAULT_INDEX_KEY); - const indices: string[] | null = useMemo(() => { - if (signalIndexName === null) { - return null; - } else { - return [...siemDefaultIndices, signalIndexName]; - } - }, [signalIndexName, siemDefaultIndices]); + let sourcereScope = SourcererScopeName.default; + if ([TimelineId.detectionsRulesDetailsPage, TimelineId.detectionsPage].includes(timelineId)) { + sourcereScope = SourcererScopeName.detections; + } else if (timelineId === TimelineId.active) { + sourcereScope = SourcererScopeName.timeline; + } + const { selectedPatterns } = useSourcererScope(sourcereScope); return ( = ({ isEventViewer, timelineId } - {graphEventId !== undefined && indices !== null ? ( + {graphEventId !== undefined ? ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/graph_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/graph_tab_content/index.tsx index db4867e1abfe7..1678a92c4cdaa 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/graph_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/graph_tab_content/index.tsx @@ -9,10 +9,11 @@ import React, { useMemo } from 'react'; import { timelineSelectors } from '../../../store/timeline'; import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; +import { TimelineId } from '../../../../../common/types/timeline'; import { GraphOverlay } from '../../graph_overlay'; interface GraphTabContentProps { - timelineId: string; + timelineId: TimelineId; } const GraphTabContentComponent: React.FC = ({ timelineId }) => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx index 219d32f147b60..e7422e32805a9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx @@ -12,7 +12,7 @@ import useResizeObserver from 'use-resize-observer/polyfilled'; import { DragDropContextWrapper } from '../../../common/components/drag_and_drop/drag_drop_context_wrapper'; import '../../../common/mock/match_media'; import { mockBrowserFields, mockDocValueFields } from '../../../common/containers/source/mock'; - +import { TimelineId } from '../../../../common/types/timeline'; import { mockIndexNames, mockIndexPattern, TestProviders } from '../../../common/mock'; import { StatefulTimeline, Props as StatefulTimelineOwnProps } from './index'; @@ -55,7 +55,7 @@ jest.mock('../../../common/containers/sourcerer', () => { }); describe('StatefulTimeline', () => { const props: StatefulTimelineOwnProps = { - timelineId: 'timeline-test', + timelineId: TimelineId.test, }; beforeEach(() => { @@ -91,7 +91,7 @@ describe('StatefulTimeline', () => { ); expect( wrapper - .find(`[data-timeline-id="timeline-test"].${SELECTOR_TIMELINE_GLOBAL_CONTAINER}`) + .find(`[data-timeline-id="test"].${SELECTOR_TIMELINE_GLOBAL_CONTAINER}`) .first() .exists() ).toEqual(true); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index 3f91f78f56383..6825940f93389 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -18,7 +18,7 @@ import { isTab } from '../../../common/components/accessibility/helpers'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { FlyoutHeader, FlyoutHeaderPanel } from '../flyout/header'; -import { TimelineType, TimelineTabs } from '../../../../common/types/timeline'; +import { TimelineType, TimelineTabs, TimelineId } from '../../../../common/types/timeline'; import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { activeTimeline } from '../../containers/active_timeline_context'; import { EVENTS_COUNT_BUTTON_CLASS_NAME, onTimelineTabKeyPressed } from './helpers'; @@ -35,7 +35,7 @@ const TimelineTemplateBadge = styled.div` `; export interface Props { - timelineId: string; + timelineId: TimelineId; } const TimelineSavingProgressComponent: React.FC = ({ timelineId }) => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx index 9f6bfcf7e320c..ca70e4ae64686 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx @@ -9,7 +9,7 @@ import { EuiBadge, EuiLoadingContent, EuiTabs, EuiTab } from '@elastic/eui'; import React, { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; -import { TimelineTabs } from '../../../../../common/types/timeline'; +import { TimelineTabs, TimelineId } from '../../../../../common/types/timeline'; import { useShallowEqualSelector, @@ -42,7 +42,7 @@ const NotesTabContent = lazy(() => import('../notes_tab_content')); const PinnedTabContent = lazy(() => import('../pinned_tab_content')); interface BasicTimelineTab { - timelineId: string; + timelineId: TimelineId; graphEventId?: string; } From 48803ac15f07e025d50d4e6b33e885e742232f31 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Thu, 11 Feb 2021 13:18:06 -0500 Subject: [PATCH 19/53] [Fleet] Restrict integration changes for managed policies (#90675) (#91150) ## Summary - [x] Integrations cannot be added ~~, unless with a force flag~~ - [x] API - [x] UI - [x] tests - [x] Integrations cannot be removed ~~, unless with a force flag~~ - [x] API - [x] UI - [x] tests closes https://github.com/elastic/kibana/issues/90445 refs https://github.com/elastic/kibana/issues/89617 ### Cannot add integrations to managed policy Screen Shot 2021-02-08 at 1 56 32 PM ### Cannot delete integrations from managed policy Screen Shot 2021-02-08 at 3 05 16 PM ### 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 --- .../server/routes/package_policy/handlers.ts | 5 +- .../fleet/server/services/agent_policy.ts | 14 +- .../fleet/server/services/package_policy.ts | 30 +++-- .../test/fleet_api_integration/apis/index.js | 1 + .../apis/package_policy/create.ts | 48 ++++++- .../apis/package_policy/delete.ts | 127 ++++++++++++++++++ .../apis/package_policy/update.ts | 48 ++++++- 7 files changed, 257 insertions(+), 16 deletions(-) create mode 100644 x-pack/test/fleet_api_integration/apis/package_policy/delete.ts diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts index ef0c34ee56393..6b35f74b3febc 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts @@ -79,11 +79,10 @@ export const createPackagePolicyHandler: RequestHandler< const esClient = context.core.elasticsearch.client.asCurrentUser; const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; - let newData = { ...request.body }; try { - newData = await packagePolicyService.runExternalCallbacks( + const newData = await packagePolicyService.runExternalCallbacks( 'packagePolicyCreate', - newData, + { ...request.body }, context, request ); diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 9800ddf95f7b2..31e9a63175d18 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -36,7 +36,11 @@ import { FleetServerPolicy, AGENT_POLICY_INDEX, } from '../../common'; -import { AgentPolicyNameExistsError, AgentPolicyDeletionError } from '../errors'; +import { + AgentPolicyNameExistsError, + AgentPolicyDeletionError, + IngestManagerError, +} from '../errors'; import { createAgentPolicyAction, listAgents } from './agents'; import { packagePolicyService } from './package_policy'; import { outputService } from './output'; @@ -382,6 +386,10 @@ class AgentPolicyService { throw new Error('Agent policy not found'); } + if (oldAgentPolicy.is_managed) { + throw new IngestManagerError(`Cannot update integrations of managed policy ${id}`); + } + return await this._update( soClient, esClient, @@ -409,6 +417,10 @@ class AgentPolicyService { throw new Error('Agent policy not found'); } + if (oldAgentPolicy.is_managed) { + throw new IngestManagerError(`Cannot remove integrations of managed policy ${id}`); + } + return await this._update( soClient, esClient, diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 4b04014b20969..8d1ac90f3ec15 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -25,6 +25,7 @@ import { doesAgentPolicyAlreadyIncludePackage, } from '../../common'; import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../constants'; +import { IngestManagerError, ingestErrorToResponseOptions } from '../errors'; import { NewPackagePolicy, UpdatePackagePolicy, @@ -63,15 +64,20 @@ class PackagePolicyService { const parentAgentPolicy = await agentPolicyService.get(soClient, packagePolicy.policy_id); if (!parentAgentPolicy) { throw new Error('Agent policy not found'); - } else { - if ( - (parentAgentPolicy.package_policies as PackagePolicy[]).find( - (siblingPackagePolicy) => siblingPackagePolicy.name === packagePolicy.name - ) - ) { - throw new Error('There is already a package with the same name on this agent policy'); - } } + if (parentAgentPolicy.is_managed) { + throw new IngestManagerError( + `Cannot add integrations to managed policy ${parentAgentPolicy.id}` + ); + } + if ( + (parentAgentPolicy.package_policies as PackagePolicy[]).find( + (siblingPackagePolicy) => siblingPackagePolicy.name === packagePolicy.name + ) + ) { + throw new Error('There is already a package with the same name on this agent policy'); + } + // Add ids to stream const packagePolicyId = options?.id || uuid.v4(); let inputs: PackagePolicyInput[] = packagePolicy.inputs.map((input) => @@ -285,6 +291,9 @@ class PackagePolicyService { if (!parentAgentPolicy) { throw new Error('Agent policy not found'); } else { + if (parentAgentPolicy.is_managed) { + throw new IngestManagerError(`Cannot update integrations of managed policy ${id}`); + } if ( (parentAgentPolicy.package_policies as PackagePolicy[]).find( (siblingPackagePolicy) => @@ -295,7 +304,7 @@ class PackagePolicyService { } } - let inputs = await restOfPackagePolicy.inputs.map((input) => + let inputs = restOfPackagePolicy.inputs.map((input) => assignStreamIdToInput(oldPackagePolicy.id, input) ); @@ -363,10 +372,11 @@ class PackagePolicyService { name: packagePolicy.name, success: true, }); - } catch (e) { + } catch (error) { result.push({ id, success: false, + ...ingestErrorToResponseOptions(error), }); } } diff --git a/x-pack/test/fleet_api_integration/apis/index.js b/x-pack/test/fleet_api_integration/apis/index.js index 8c66db9c418ea..44431795a34ba 100644 --- a/x-pack/test/fleet_api_integration/apis/index.js +++ b/x-pack/test/fleet_api_integration/apis/index.js @@ -37,6 +37,7 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./package_policy/create')); loadTestFile(require.resolve('./package_policy/update')); loadTestFile(require.resolve('./package_policy/get')); + loadTestFile(require.resolve('./package_policy/delete')); // Agent policies loadTestFile(require.resolve('./agent_policy/index')); diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/create.ts b/x-pack/test/fleet_api_integration/apis/package_policy/create.ts index 8e339bc78b087..c9c871e280f16 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/create.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/create.ts @@ -4,7 +4,7 @@ * 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 '../../../api_integration/ftr_provider_context'; import { warnAndSkipTest } from '../../helpers'; @@ -39,6 +39,52 @@ export default function ({ getService }: FtrProviderContext) { .send({ agentPolicyId }); }); + it('should fail for managed agent policies', async function () { + if (server.enabled) { + // get a managed policy + const { + body: { item: managedPolicy }, + } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Managed policy from ${Date.now()}`, + namespace: 'default', + is_managed: true, + }); + + // try to add an integration to the managed policy + const { body } = await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: 'default', + policy_id: managedPolicy.id, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(400); + + expect(body.statusCode).to.be(400); + expect(body.message).to.contain('Cannot add integrations to managed policy'); + + // delete policy we just made + await supertest.post(`/api/fleet/agent_policies/delete`).set('kbn-xsrf', 'xxxx').send({ + agentPolicyId: managedPolicy.id, + }); + } else { + warnAndSkipTest(this, log); + } + }); + it('should work with valid values', async function () { if (server.enabled) { await supertest diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts b/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts new file mode 100644 index 0000000000000..e64ba8580d145 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts @@ -0,0 +1,127 @@ +/* + * 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 '../../../api_integration/ftr_provider_context'; +import { skipIfNoDockerRegistry } from '../../helpers'; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const supertest = getService('supertest'); + + // use function () {} and not () => {} here + // because `this` has to point to the Mocha context + // see https://mochajs.org/#arrow-functions + + describe('Package Policy - delete', async function () { + skipIfNoDockerRegistry(providerContext); + let agentPolicy: any; + let packagePolicy: any; + + before(async function () { + let agentPolicyResponse = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Test policy', + namespace: 'default', + is_managed: false, + }); + + // if one already exists, re-use that + if (agentPolicyResponse.body.statusCode === 409) { + const errorRegex = /^agent policy \'(?[\w,\-]+)\' already exists/i; + const result = errorRegex.exec(agentPolicyResponse.body.message); + if (result?.groups?.id) { + agentPolicyResponse = await supertest + .put(`/api/fleet/agent_policies/${result.groups.id}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Test policy', + namespace: 'default', + is_managed: false, + }); + } + } + agentPolicy = agentPolicyResponse.body.item; + + const { body: packagePolicyResponse } = await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: 'default', + policy_id: agentPolicy.id, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }); + packagePolicy = packagePolicyResponse.item; + }); + + after(async function () { + await supertest + .post(`/api/fleet/agent_policies/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ agentPolicyId: agentPolicy.id }); + + await supertest + .post(`/api/fleet/package_policies/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ packagePolicyIds: [packagePolicy.id] }); + }); + + it('should fail on managed agent policies', async function () { + // update existing policy to managed + await supertest + .put(`/api/fleet/agent_policies/${agentPolicy.id}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: agentPolicy.name, + namespace: agentPolicy.namespace, + is_managed: true, + }) + .expect(200); + + // try to delete + const { body: results } = await supertest + .post(`/api/fleet/package_policies/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ packagePolicyIds: [packagePolicy.id] }) + .expect(200); + + // delete always succeeds (returns 200) with Array<{success: boolean}> + expect(Array.isArray(results)); + expect(results.length).to.be(1); + expect(results[0].success).to.be(false); + expect(results[0].body.message).to.contain('Cannot remove integrations of managed policy'); + + // revert existing policy to unmanaged + await supertest + .put(`/api/fleet/agent_policies/${agentPolicy.id}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: agentPolicy.name, + namespace: agentPolicy.namespace, + is_managed: false, + }) + .expect(200); + }); + + it('should work for unmanaged policies', async function () { + await supertest + .post(`/api/fleet/package_policies/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ packagePolicyIds: [packagePolicy.id] }); + }); + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/update.ts b/x-pack/test/fleet_api_integration/apis/package_policy/update.ts index e0dc1a5d96b4b..9a70c6ad004dd 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/update.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/update.ts @@ -4,7 +4,7 @@ * 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 '../../../api_integration/ftr_provider_context'; import { skipIfNoDockerRegistry } from '../../helpers'; @@ -21,6 +21,7 @@ export default function (providerContext: FtrProviderContext) { describe('Package Policy - update', async function () { skipIfNoDockerRegistry(providerContext); let agentPolicyId: string; + let managedAgentPolicyId: string; let packagePolicyId: string; let packagePolicyId2: string; @@ -35,8 +36,30 @@ export default function (providerContext: FtrProviderContext) { name: 'Test policy', namespace: 'default', }); + agentPolicyId = agentPolicyResponse.item.id; + const { body: managedAgentPolicyResponse } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Test managed policy', + namespace: 'default', + is_managed: true, + }); + + // if one already exists, re-use that + const managedExists = managedAgentPolicyResponse.statusCode === 409; + if (managedExists) { + const errorRegex = /^agent policy \'(?[\w,\-]+)\' already exists/i; + const result = errorRegex.exec(managedAgentPolicyResponse.message); + if (result?.groups?.id) { + managedAgentPolicyId = result.groups.id; + } + } else { + managedAgentPolicyId = managedAgentPolicyResponse.item.id; + } + const { body: packagePolicyResponse } = await supertest .post(`/api/fleet/package_policies`) .set('kbn-xsrf', 'xxxx') @@ -83,6 +106,29 @@ export default function (providerContext: FtrProviderContext) { .send({ agentPolicyId }); }); + it('should fail on managed agent policies', async function () { + const { body } = await supertest + .put(`/api/fleet/package_policies/${packagePolicyId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: 'updated_namespace', + policy_id: managedAgentPolicyId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(400); + + expect(body.message).to.contain('Cannot update integrations of managed policy'); + }); + it('should work with valid values', async function () { await supertest .put(`/api/fleet/package_policies/${packagePolicyId}`) From 0d2f1838448518c1361176048ab1f41ae6b5c910 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Thu, 11 Feb 2021 12:34:10 -0600 Subject: [PATCH 20/53] [Workplace Search] Break out MVP from in-progress app (#91034) (#91156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Create a copy of the existing overview as mvp No files were changed here; only a copy * Update index to point to MVP copy * Wrap server calls in try/catch Jest was complaining about this and it’s a good practice to have anyway * Remove MVP temp EuiPage wrapper * Add route and link in navigation * Remove Launch Workplace Search button This not needed in a post-MVP world. We have had discussions about giving the users the ability to relaunch the legacy app in the beta (pre-8.0) world, but that will be in a callout or some other element. * Refactor onboarding card to use internal routing I simplified this by not trying to recreate shared props and typecast them, but just create 2 variable components that fall back to an unclickable button that is disabled in the UI * Refactor onboarding steps to use internal routing * Refactor statistic card to use internal routing * Refactor recent activity to use internal routing --- .../components/layout/nav.test.tsx | 4 +- .../components/layout/nav.tsx | 3 +- .../workplace_search/index.test.tsx | 4 +- .../applications/workplace_search/index.tsx | 9 +- .../applications/workplace_search/routes.ts | 1 + .../views/overview/onboarding_card.test.tsx | 12 +- .../views/overview/onboarding_card.tsx | 46 ++--- .../views/overview/onboarding_steps.tsx | 21 +- .../views/overview/overview.tsx | 27 +-- .../views/overview/overview_logic.ts | 9 +- .../views/overview/recent_activity.test.tsx | 6 +- .../views/overview/recent_activity.tsx | 22 +-- .../views/overview/statistic_card.test.tsx | 4 +- .../views/overview/statistic_card.tsx | 49 ++--- .../views/overview_mvp/__mocks__/index.ts | 8 + .../__mocks__/overview_logic.mock.ts | 37 ++++ .../views/overview_mvp/index.ts | 8 + .../overview_mvp/onboarding_card.test.tsx | 55 ++++++ .../views/overview_mvp/onboarding_card.tsx | 92 +++++++++ .../overview_mvp/onboarding_steps.test.tsx | 136 +++++++++++++ .../views/overview_mvp/onboarding_steps.tsx | 182 ++++++++++++++++++ .../overview_mvp/organization_stats.test.tsx | 35 ++++ .../views/overview_mvp/organization_stats.tsx | 79 ++++++++ .../views/overview_mvp/overview.test.tsx | 66 +++++++ .../views/overview_mvp/overview.tsx | 93 +++++++++ .../views/overview_mvp/overview_logic.test.ts | 72 +++++++ .../views/overview_mvp/overview_logic.ts | 114 +++++++++++ .../views/overview_mvp/recent_activity.scss | 38 ++++ .../overview_mvp/recent_activity.test.tsx | 80 ++++++++ .../views/overview_mvp/recent_activity.tsx | 126 ++++++++++++ .../overview_mvp/statistic_card.test.tsx | 34 ++++ .../views/overview_mvp/statistic_card.tsx | 45 +++++ 32 files changed, 1405 insertions(+), 112 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/overview_logic.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx index 8f37f608f4e28..bac27bddf075a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx @@ -13,6 +13,8 @@ import { shallow } from 'enzyme'; import { SideNav, SideNavLink } from '../../../shared/layout'; +import { ALPHA_PATH } from '../../routes'; + import { WorkplaceSearchNav } from './'; describe('WorkplaceSearchNav', () => { @@ -20,7 +22,7 @@ describe('WorkplaceSearchNav', () => { const wrapper = shallow(); expect(wrapper.find(SideNav)).toHaveLength(1); - expect(wrapper.find(SideNavLink).first().prop('to')).toEqual('/'); + expect(wrapper.find(SideNavLink).first().prop('to')).toEqual(ALPHA_PATH); expect(wrapper.find(SideNavLink)).toHaveLength(6); }); }); 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 c184247b253d6..16722c1554ddf 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 @@ -14,6 +14,7 @@ import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; import { SideNav, SideNavLink } from '../../../shared/layout'; import { NAV } from '../../constants'; import { + ALPHA_PATH, SOURCES_PATH, SECURITY_PATH, ROLE_MAPPINGS_PATH, @@ -33,7 +34,7 @@ export const WorkplaceSearchNav: React.FC = ({ settingsSubNav, }) => ( - + {NAV.OVERVIEW} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx index 5678ad545d50d..ceb1a82446132 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -17,7 +17,7 @@ import { Layout } from '../shared/layout'; import { WorkplaceSearchHeaderActions } from './components/layout'; import { ErrorState } from './views/error_state'; -import { Overview } from './views/overview'; +import { Overview as OverviewMVP } from './views/overview_mvp'; import { SetupGuide } from './views/setup_guide'; import { WorkplaceSearch, WorkplaceSearchUnconfigured, WorkplaceSearchConfigured } from './'; @@ -60,7 +60,7 @@ describe('WorkplaceSearchConfigured', () => { const wrapper = shallow(); expect(wrapper.find(Layout).first().prop('readOnlyMode')).toBeFalsy(); - expect(wrapper.find(Overview)).toHaveLength(1); + expect(wrapper.find(OverviewMVP)).toHaveLength(1); expect(mockKibanaValues.renderHeaderActions).toHaveBeenCalledWith(WorkplaceSearchHeaderActions); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index d690dee4dc98c..c469e5ef5ce98 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -20,6 +20,7 @@ import { NotFound } from '../shared/not_found'; import { AppLogic } from './app_logic'; import { WorkplaceSearchNav, WorkplaceSearchHeaderActions } from './components/layout'; import { + ALPHA_PATH, GROUPS_PATH, SETUP_GUIDE_PATH, SOURCES_PATH, @@ -33,6 +34,7 @@ import { ErrorState } from './views/error_state'; import { GroupsRouter } from './views/groups'; import { GroupSubNav } from './views/groups/components/group_sub_nav'; import { Overview } from './views/overview'; +import { Overview as OverviewMVP } from './views/overview_mvp'; import { Security } from './views/security'; import { SettingsRouter } from './views/settings'; import { SettingsSubNav } from './views/settings/components/settings_sub_nav'; @@ -78,7 +80,7 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { - {errorConnecting ? : } + {errorConnecting ? : } {/* TODO: replace Layout with PrivateSourcesLayout (needs to be created) */} @@ -95,6 +97,11 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { + + } restrictWidth readOnlyMode={readOnlyMode}> + + + } />} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index aaaf8cbd7cfe5..462f89abd6143 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -57,6 +57,7 @@ export const GROUPS_PATH = '/groups'; export const GROUP_PATH = `${GROUPS_PATH}/:groupId`; export const GROUP_SOURCE_PRIORITIZATION_PATH = `${GROUPS_PATH}/:groupId/source_prioritization`; +export const ALPHA_PATH = '/alpha'; export const SOURCES_PATH = '/sources'; export const PERSONAL_SOURCES_PATH = `${PERSONAL_PATH}${SOURCES_PATH}`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx index 68dece976a09c..2b9dc98b03567 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx @@ -13,7 +13,9 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiEmptyPrompt, EuiButton, EuiButtonEmpty } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiButtonEmpty } from '@elastic/eui'; + +import { EuiButtonTo, EuiButtonEmptyTo } from '../../../shared/react_router_helpers'; import { OnboardingCard } from './onboarding_card'; @@ -35,11 +37,11 @@ describe('OnboardingCard', () => { const wrapper = shallow(); const prompt = wrapper.find(EuiEmptyPrompt).dive(); - expect(prompt.find(EuiButton)).toHaveLength(1); - expect(prompt.find(EuiButtonEmpty)).toHaveLength(0); + expect(prompt.find(EuiButtonTo)).toHaveLength(1); + expect(prompt.find(EuiButtonEmptyTo)).toHaveLength(0); const button = prompt.find('[data-test-subj="actionButton"]'); - expect(button.prop('href')).toBe('http://localhost:3002/ws/some_path'); + expect(button.prop('to')).toBe('/some_path'); button.simulate('click'); expect(mockTelemetryActions.sendWorkplaceSearchTelemetry).toHaveBeenCalled(); @@ -49,7 +51,7 @@ describe('OnboardingCard', () => { const wrapper = shallow(); const prompt = wrapper.find(EuiEmptyPrompt).dive(); - expect(prompt.find(EuiButton)).toHaveLength(0); + expect(prompt.find(EuiButtonTo)).toHaveLength(0); expect(prompt.find(EuiButtonEmpty)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx index 2f8d06b71fc27..2d9e5580c6f40 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx @@ -16,12 +16,9 @@ import { EuiPanel, EuiEmptyPrompt, IconType, - EuiButtonProps, - EuiButtonEmptyProps, - EuiLinkProps, } from '@elastic/eui'; -import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; +import { EuiButtonTo, EuiButtonEmptyTo } from '../../../shared/react_router_helpers'; import { TelemetryLogic } from '../../../shared/telemetry'; interface OnboardingCardProps { @@ -50,25 +47,22 @@ export const OnboardingCard: React.FC = ({ action: 'clicked', metric: 'onboarding_card_button', }); - const buttonActionProps = actionPath - ? { - onClick, - href: getWorkplaceSearchUrl(actionPath), - target: '_blank', - 'data-test-subj': testSubj, - } - : { - 'data-test-subj': testSubj, - }; - const emptyButtonProps = { - ...buttonActionProps, - } as EuiButtonEmptyProps & EuiLinkProps; - const fillButtonProps = { - ...buttonActionProps, - color: 'secondary', - fill: true, - } as EuiButtonProps & EuiLinkProps; + const completeButton = actionPath ? ( + + {actionTitle} + + ) : ( + {actionTitle} + ); + + const incompleteButton = actionPath ? ( + + {actionTitle} + + ) : ( + {actionTitle} + ); return ( @@ -78,13 +72,7 @@ export const OnboardingCard: React.FC = ({ iconColor={complete ? 'secondary' : 'subdued'} title={

{title}

} body={description} - actions={ - complete ? ( - {actionTitle} - ) : ( - {actionTitle} - ) - } + actions={complete ? completeButton : incompleteButton} />
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx index fc3998fcdfeec..9f07196b2e9fd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx @@ -11,20 +11,17 @@ import { useValues, useActions } from 'kea'; import { EuiSpacer, - EuiButtonEmpty, EuiTitle, EuiPanel, EuiIcon, EuiFlexGrid, EuiFlexItem, EuiFlexGroup, - EuiButtonEmptyProps, - EuiLinkProps, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; +import { EuiButtonEmptyTo } from '../../../shared/react_router_helpers'; import { TelemetryLogic } from '../../../shared/telemetry'; import { AppLogic } from '../../app_logic'; import sharedSourcesIcon from '../../components/shared/assets/source_icons/share_circle.svg'; @@ -144,14 +141,6 @@ export const OrgNameOnboarding: React.FC = () => { metric: 'org_name_change_button', }); - const buttonProps = { - onClick, - target: '_blank', - color: 'primary', - href: getWorkplaceSearchUrl(ORG_SETTINGS_PATH), - 'data-test-subj': 'orgNameChangeButton', - } as EuiButtonEmptyProps & EuiLinkProps; - return ( @@ -169,12 +158,16 @@ export const OrgNameOnboarding: React.FC = () => { - + - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx index 6bf84b585da80..0f8f4b6def46c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx @@ -5,20 +5,17 @@ * 2.0. */ -// TODO: Remove EuiPage & EuiPageBody before exposing full app - import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { Loading } from '../../../shared/loading'; import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { AppLogic } from '../../app_logic'; -import { ProductButton } from '../../components/shared/product_button'; import { ViewContentHeader } from '../../components/shared/view_content_header'; import { OnboardingSteps } from './onboarding_steps'; @@ -72,22 +69,16 @@ export const Overview: React.FC = () => { const headerDescription = hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION; return ( - + <> - - } - /> - {!hideOnboarding && } - - - - - - + + {!hideOnboarding && } + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts index 75513cfba3a09..7d8bc95529483 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts @@ -7,6 +7,7 @@ import { kea, MakeLogicType } from 'kea'; +import { flashAPIErrors } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; import { FeedActivity } from './recent_activity'; @@ -102,8 +103,12 @@ export const OverviewLogic = kea> }, listeners: ({ actions }) => ({ initializeOverview: async () => { - const response = await HttpLogic.values.http.get('/api/workplace_search/overview'); - actions.setServerData(response); + try { + const response = await HttpLogic.values.http.get('/api/workplace_search/overview'); + actions.setServerData(response); + } catch (e) { + flashAPIErrors(e); + } }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx index 0b62207afc520..9ab7b908ad3cd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx @@ -13,9 +13,11 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiEmptyPrompt, EuiLink } from '@elastic/eui'; +import { EuiEmptyPrompt } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiLinkTo } from '../../../shared/react_router_helpers'; + import { setMockValues } from './__mocks__'; import { RecentActivity, RecentActivityItem } from './recent_activity'; @@ -60,7 +62,7 @@ describe('RecentActivity', () => { expect(wrapper.find('.activity--error')).toHaveLength(1); expect(wrapper.find('.activity--error__label')).toHaveLength(1); - expect(wrapper.find(EuiLink).prop('color')).toEqual('danger'); + expect(wrapper.find(EuiLinkTo).prop('color')).toEqual('danger'); }); it('renders recent activity message for default org name', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx index 43d3f880feef4..62b96442b9ba0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx @@ -10,10 +10,10 @@ import React from 'react'; import { useValues, useActions } from 'kea'; import moment from 'moment'; -import { EuiEmptyPrompt, EuiLink, EuiPanel, EuiSpacer, EuiLinkProps } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiPanel, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; +import { EuiLinkTo } from '../../../shared/react_router_helpers'; import { TelemetryLogic } from '../../../shared/telemetry'; import { AppLogic } from '../../app_logic'; import { ContentSection } from '../../components/shared/content_section'; @@ -95,19 +95,15 @@ export const RecentActivityItem: React.FC = ({ metric: 'recent_activity_source_details_link', }); - const linkProps = { - onClick, - target: '_blank', - href: getWorkplaceSearchUrl(getContentSourcePath(SOURCE_DETAILS_PATH, sourceId, true)), - external: true, - color: status === 'error' ? 'danger' : 'primary', - 'data-test-subj': 'viewSourceDetailsLink', - } as EuiLinkProps; - return (
- + {id} {message} {status === 'error' && ( @@ -118,7 +114,7 @@ export const RecentActivityItem: React.FC = ({ /> )} - +
{moment.utc(timestamp).fromNow()}
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.test.tsx index ff1d69e406830..c81d933ca38ca 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.test.tsx @@ -13,6 +13,8 @@ import { shallow } from 'enzyme'; import { EuiCard } from '@elastic/eui'; +import { EuiCardTo } from '../../../shared/react_router_helpers'; + import { StatisticCard } from './statistic_card'; const props = { @@ -29,6 +31,6 @@ describe('StatisticCard', () => { it('renders clickable card', () => { const wrapper = shallow(); - expect(wrapper.find(EuiCard).prop('href')).toBe('http://localhost:3002/ws/foo'); + expect(wrapper.find(EuiCardTo).prop('to')).toBe('/foo'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx index 346debb1c5251..136901f840b89 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { EuiCard, EuiFlexItem, EuiTitle, EuiTextColor } from '@elastic/eui'; -import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; +import { EuiCardTo } from '../../../shared/react_router_helpers'; interface StatisticCardProps { title: string; @@ -18,28 +18,31 @@ interface StatisticCardProps { } export const StatisticCard: React.FC = ({ title, count = 0, actionPath }) => { - const linkProps = actionPath - ? { - href: getWorkplaceSearchUrl(actionPath), - target: '_blank', - rel: 'noopener', + const linkableCard = ( + + {count} + } - : {}; - // TODO: When we port this destination to Kibana, we'll want to create a EuiReactRouterCard component (see shared/react_router_helpers/eui_link.tsx) - - return ( - - - {count} - - } - /> - + /> + ); + const card = ( + + {count} + + } + /> ); + + return {actionPath ? linkableCard : card}; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/index.ts new file mode 100644 index 0000000000000..3a1bbfcae75ba --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { setMockValues, mockOverviewValues, mockActions } from './overview_logic.mock'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/overview_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/overview_logic.mock.ts new file mode 100644 index 0000000000000..787354974cb31 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/overview_logic.mock.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 { DEFAULT_INITIAL_APP_DATA } from '../../../../../../common/__mocks__'; +import { setMockValues as setMockKeaValues, setMockActions } from '../../../../__mocks__/kea.mock'; + +const { workplaceSearch: mockAppValues } = DEFAULT_INITIAL_APP_DATA; + +export const mockOverviewValues = { + accountsCount: 0, + activityFeed: [], + canCreateContentSources: false, + hasOrgSources: false, + hasUsers: false, + isOldAccount: false, + pendingInvitationsCount: 0, + personalSourcesCount: 0, + sourcesCount: 0, + dataLoading: true, +}; + +export const mockActions = { + initializeOverview: jest.fn(() => ({})), +}; + +const mockValues = { ...mockOverviewValues, ...mockAppValues, isFederatedAuth: true }; + +setMockActions({ ...mockActions }); +setMockKeaValues({ ...mockValues }); + +export const setMockValues = (values: object) => { + setMockKeaValues({ ...mockValues, ...values }); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/index.ts new file mode 100644 index 0000000000000..69c843fe3821e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { Overview } from './overview'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.test.tsx new file mode 100644 index 0000000000000..68dece976a09c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.test.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../__mocks__/kea.mock'; +import '../../../__mocks__/enterprise_search_url.mock'; +import { mockTelemetryActions } from '../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiButton, EuiButtonEmpty } from '@elastic/eui'; + +import { OnboardingCard } from './onboarding_card'; + +const cardProps = { + title: 'My card', + icon: 'icon', + description: 'this is a card', + actionTitle: 'action', + testSubj: 'actionButton', +}; + +describe('OnboardingCard', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); + + it('renders an action button', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + + expect(prompt.find(EuiButton)).toHaveLength(1); + expect(prompt.find(EuiButtonEmpty)).toHaveLength(0); + + const button = prompt.find('[data-test-subj="actionButton"]'); + expect(button.prop('href')).toBe('http://localhost:3002/ws/some_path'); + + button.simulate('click'); + expect(mockTelemetryActions.sendWorkplaceSearchTelemetry).toHaveBeenCalled(); + }); + + it('renders an empty button when onboarding is completed', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + + expect(prompt.find(EuiButton)).toHaveLength(0); + expect(prompt.find(EuiButtonEmpty)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.tsx new file mode 100644 index 0000000000000..2f8d06b71fc27 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.tsx @@ -0,0 +1,92 @@ +/* + * 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 { useActions } from 'kea'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexItem, + EuiPanel, + EuiEmptyPrompt, + IconType, + EuiButtonProps, + EuiButtonEmptyProps, + EuiLinkProps, +} from '@elastic/eui'; + +import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; +import { TelemetryLogic } from '../../../shared/telemetry'; + +interface OnboardingCardProps { + title: React.ReactNode; + icon: React.ReactNode; + description: React.ReactNode; + actionTitle: React.ReactNode; + testSubj: string; + actionPath?: string; + complete?: boolean; +} + +export const OnboardingCard: React.FC = ({ + title, + icon, + description, + actionTitle, + testSubj, + actionPath, + complete, +}) => { + const { sendWorkplaceSearchTelemetry } = useActions(TelemetryLogic); + + const onClick = () => + sendWorkplaceSearchTelemetry({ + action: 'clicked', + metric: 'onboarding_card_button', + }); + const buttonActionProps = actionPath + ? { + onClick, + href: getWorkplaceSearchUrl(actionPath), + target: '_blank', + 'data-test-subj': testSubj, + } + : { + 'data-test-subj': testSubj, + }; + + const emptyButtonProps = { + ...buttonActionProps, + } as EuiButtonEmptyProps & EuiLinkProps; + const fillButtonProps = { + ...buttonActionProps, + color: 'secondary', + fill: true, + } as EuiButtonProps & EuiLinkProps; + + return ( + + + {title}} + body={description} + actions={ + complete ? ( + {actionTitle} + ) : ( + {actionTitle} + ) + } + /> + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.test.tsx new file mode 100644 index 0000000000000..7a368e7d384ea --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.test.tsx @@ -0,0 +1,136 @@ +/* + * 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 { mockTelemetryActions } from '../../../__mocks__'; + +import './__mocks__/overview_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { SOURCES_PATH, USERS_PATH } from '../../routes'; + +import { setMockValues } from './__mocks__'; +import { OnboardingCard } from './onboarding_card'; +import { OnboardingSteps, OrgNameOnboarding } from './onboarding_steps'; + +const account = { + id: '1', + isAdmin: true, + canCreatePersonalSources: true, + groups: [], + isCurated: false, + canCreateInvitations: true, +}; + +describe('OnboardingSteps', () => { + describe('Shared Sources', () => { + it('renders 0 sources state', () => { + setMockValues({ canCreateContentSources: true }); + const wrapper = shallow(); + + expect(wrapper.find(OnboardingCard)).toHaveLength(1); + expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(SOURCES_PATH); + expect(wrapper.find(OnboardingCard).prop('description')).toBe( + 'Add shared sources for your organization to start searching.' + ); + }); + + it('renders completed sources state', () => { + setMockValues({ sourcesCount: 2, hasOrgSources: true }); + const wrapper = shallow(); + + expect(wrapper.find(OnboardingCard).prop('description')).toEqual( + 'You have added 2 shared sources. Happy searching.' + ); + }); + + it('disables link when the user cannot create sources', () => { + setMockValues({ canCreateContentSources: false }); + const wrapper = shallow(); + + expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(undefined); + }); + }); + + describe('Users & Invitations', () => { + it('renders 0 users when not on federated auth', () => { + setMockValues({ + isFederatedAuth: false, + account, + accountsCount: 0, + hasUsers: false, + }); + const wrapper = shallow(); + + expect(wrapper.find(OnboardingCard)).toHaveLength(2); + expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(USERS_PATH); + expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual( + 'Invite your colleagues into this organization to search with you.' + ); + }); + + it('renders completed users state', () => { + setMockValues({ + isFederatedAuth: false, + account, + accountsCount: 1, + hasUsers: true, + }); + const wrapper = shallow(); + + expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual( + 'Nice, you’ve invited colleagues to search with you.' + ); + }); + + it('disables link when the user cannot create invitations', () => { + setMockValues({ + isFederatedAuth: false, + account: { + ...account, + canCreateInvitations: false, + }, + }); + const wrapper = shallow(); + expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(undefined); + }); + }); + + describe('Org Name', () => { + it('renders button to change name', () => { + setMockValues({ + organization: { + name: 'foo', + defaultOrgName: 'foo', + }, + }); + const wrapper = shallow(); + + const button = wrapper + .find(OrgNameOnboarding) + .dive() + .find('[data-test-subj="orgNameChangeButton"]'); + + button.simulate('click'); + expect(mockTelemetryActions.sendWorkplaceSearchTelemetry).toHaveBeenCalled(); + }); + + it('hides card when name has been changed', () => { + setMockValues({ + organization: { + name: 'foo', + defaultOrgName: 'bar', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(OrgNameOnboarding)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.tsx new file mode 100644 index 0000000000000..fc3998fcdfeec --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.tsx @@ -0,0 +1,182 @@ +/* + * 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 { + EuiSpacer, + EuiButtonEmpty, + EuiTitle, + EuiPanel, + EuiIcon, + EuiFlexGrid, + EuiFlexItem, + EuiFlexGroup, + EuiButtonEmptyProps, + EuiLinkProps, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; +import { TelemetryLogic } from '../../../shared/telemetry'; +import { AppLogic } from '../../app_logic'; +import sharedSourcesIcon from '../../components/shared/assets/source_icons/share_circle.svg'; +import { ContentSection } from '../../components/shared/content_section'; +import { SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes'; + +import { OnboardingCard } from './onboarding_card'; +import { OverviewLogic } from './overview_logic'; + +const SOURCES_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingSourcesCard.title', + { defaultMessage: 'Shared sources' } +); + +const USERS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingUsersCard.title', + { defaultMessage: 'Users & invitations' } +); + +const ONBOARDING_SOURCES_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingSourcesCard.description', + { defaultMessage: 'Add shared sources for your organization to start searching.' } +); + +const USERS_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewUsersCard.title', + { defaultMessage: 'Nice, you’ve invited colleagues to search with you.' } +); + +const ONBOARDING_USERS_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingUsersCard.description', + { defaultMessage: 'Invite your colleagues into this organization to search with you.' } +); + +export const OnboardingSteps: React.FC = () => { + const { + isFederatedAuth, + organization: { name, defaultOrgName }, + account: { isCurated, canCreateInvitations }, + } = useValues(AppLogic); + + const { + hasUsers, + hasOrgSources, + canCreateContentSources, + accountsCount, + sourcesCount, + } = useValues(OverviewLogic); + + const accountsPath = + !isFederatedAuth && (canCreateInvitations || isCurated) ? USERS_PATH : undefined; + const sourcesPath = canCreateContentSources || isCurated ? SOURCES_PATH : undefined; + + const SOURCES_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sourcesOnboardingCard.description', + { + defaultMessage: + 'You have added {sourcesCount, number} shared {sourcesCount, plural, one {source} other {sources}}. Happy searching.', + values: { sourcesCount }, + } + ); + + return ( + + + 0 ? 'more' : '' }, + } + )} + actionPath={sourcesPath} + complete={hasOrgSources} + /> + {!isFederatedAuth && ( + 0 ? 'more' : '' }, + } + )} + actionPath={accountsPath} + complete={hasUsers} + /> + )} + + {name === defaultOrgName && ( + <> + + + + )} + + ); +}; + +export const OrgNameOnboarding: React.FC = () => { + const { sendWorkplaceSearchTelemetry } = useActions(TelemetryLogic); + + const onClick = () => + sendWorkplaceSearchTelemetry({ + action: 'clicked', + metric: 'org_name_change_button', + }); + + const buttonProps = { + onClick, + target: '_blank', + color: 'primary', + href: getWorkplaceSearchUrl(ORG_SETTINGS_PATH), + 'data-test-subj': 'orgNameChangeButton', + } as EuiButtonEmptyProps & EuiLinkProps; + + return ( + + + + + + + +

+ +

+
+
+ + + + + +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.test.tsx new file mode 100644 index 0000000000000..412977f18fadf --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import './__mocks__/overview_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiFlexGrid } from '@elastic/eui'; + +import { setMockValues } from './__mocks__'; +import { OrganizationStats } from './organization_stats'; +import { StatisticCard } from './statistic_card'; + +describe('OrganizationStats', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(StatisticCard)).toHaveLength(2); + expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(2); + }); + + it('renders additional cards for federated auth', () => { + setMockValues({ isFederatedAuth: false }); + const wrapper = shallow(); + + expect(wrapper.find(StatisticCard)).toHaveLength(4); + expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(4); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.tsx new file mode 100644 index 0000000000000..525035030b8cc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues } from 'kea'; + +import { EuiFlexGrid } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { AppLogic } from '../../app_logic'; +import { ContentSection } from '../../components/shared/content_section'; +import { SOURCES_PATH, USERS_PATH } from '../../routes'; + +import { OverviewLogic } from './overview_logic'; +import { StatisticCard } from './statistic_card'; + +export const OrganizationStats: React.FC = () => { + const { isFederatedAuth } = useValues(AppLogic); + + const { sourcesCount, pendingInvitationsCount, accountsCount, personalSourcesCount } = useValues( + OverviewLogic + ); + + return ( + + } + headerSpacer="m" + > + + + {!isFederatedAuth && ( + <> + + + + )} + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.test.tsx new file mode 100644 index 0000000000000..2ec2d949ff491 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.test.tsx @@ -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 '../../../__mocks__/react_router_history.mock'; +import './__mocks__/overview_logic.mock'; + +import React from 'react'; + +import { shallow, mount } from 'enzyme'; + +import { Loading } from '../../../shared/loading'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; + +import { mockActions, setMockValues } from './__mocks__'; +import { OnboardingSteps } from './onboarding_steps'; +import { OrganizationStats } from './organization_stats'; +import { Overview } from './overview'; +import { RecentActivity } from './recent_activity'; + +describe('Overview', () => { + describe('non-happy-path states', () => { + it('isLoading', () => { + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + }); + + describe('happy-path states', () => { + it('calls initialize function', async () => { + mount(); + + expect(mockActions.initializeOverview).toHaveBeenCalled(); + }); + + it('renders onboarding state', () => { + setMockValues({ dataLoading: false }); + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + expect(wrapper.find(OnboardingSteps)).toHaveLength(1); + expect(wrapper.find(OrganizationStats)).toHaveLength(1); + expect(wrapper.find(RecentActivity)).toHaveLength(1); + }); + + it('renders when onboarding complete', () => { + setMockValues({ + dataLoading: false, + hasUsers: true, + hasOrgSources: true, + isOldAccount: true, + organization: { + name: 'foo', + defaultOrgName: 'bar', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(OnboardingSteps)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.tsx new file mode 100644 index 0000000000000..6bf84b585da80 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.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. + */ + +// TODO: Remove EuiPage & EuiPageBody before exposing full app + +import React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { Loading } from '../../../shared/loading'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { AppLogic } from '../../app_logic'; +import { ProductButton } from '../../components/shared/product_button'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; + +import { OnboardingSteps } from './onboarding_steps'; +import { OrganizationStats } from './organization_stats'; +import { OverviewLogic } from './overview_logic'; +import { RecentActivity } from './recent_activity'; + +const ONBOARDING_HEADER_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.title', + { defaultMessage: 'Get started with Workplace Search' } +); + +const HEADER_TITLE = i18n.translate('xpack.enterpriseSearch.workplaceSearch.overviewHeader.title', { + defaultMessage: 'Organization overview', +}); + +const ONBOARDING_HEADER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.description', + { defaultMessage: 'Complete the following to set up your organization.' } +); + +const HEADER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewHeader.description', + { defaultMessage: "Your organizations's statistics and activity" } +); + +export const Overview: React.FC = () => { + const { + organization: { name: orgName, defaultOrgName }, + } = useValues(AppLogic); + + const { initializeOverview } = useActions(OverviewLogic); + const { dataLoading, hasUsers, hasOrgSources, isOldAccount } = useValues(OverviewLogic); + + useEffect(() => { + initializeOverview(); + }, [initializeOverview]); + + // TODO: Remove div wrapper once the Overview page is using the full Layout + if (dataLoading) { + return ( +
+ +
+ ); + } + + const hideOnboarding = hasUsers && hasOrgSources && isOldAccount && orgName !== defaultOrgName; + + const headerTitle = hideOnboarding ? HEADER_TITLE : ONBOARDING_HEADER_TITLE; + const headerDescription = hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION; + + return ( + + + + + + } + /> + {!hideOnboarding && } + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.test.ts new file mode 100644 index 0000000000000..0e84315104343 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LogicMounter, mockHttpValues } from '../../../__mocks__'; + +import { mockOverviewValues } from './__mocks__'; +import { OverviewLogic } from './overview_logic'; + +describe('OverviewLogic', () => { + const { mount } = new LogicMounter(OverviewLogic); + const { http } = mockHttpValues; + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('has expected default values', () => { + expect(OverviewLogic.values).toEqual(mockOverviewValues); + }); + + describe('setServerData', () => { + const feed = [{ foo: 'bar' }] as any; + + const data = { + accountsCount: 1, + activityFeed: feed, + canCreateContentSources: true, + hasOrgSources: true, + hasUsers: true, + isOldAccount: true, + pendingInvitationsCount: 1, + personalSourcesCount: 1, + sourcesCount: 1, + }; + + beforeEach(() => { + OverviewLogic.actions.setServerData(data); + }); + + it('will set `dataLoading` to false', () => { + expect(OverviewLogic.values.dataLoading).toEqual(false); + }); + + it('will set server values', () => { + expect(OverviewLogic.values.hasUsers).toEqual(true); + expect(OverviewLogic.values.hasOrgSources).toEqual(true); + expect(OverviewLogic.values.canCreateContentSources).toEqual(true); + expect(OverviewLogic.values.isOldAccount).toEqual(true); + expect(OverviewLogic.values.sourcesCount).toEqual(1); + expect(OverviewLogic.values.pendingInvitationsCount).toEqual(1); + expect(OverviewLogic.values.accountsCount).toEqual(1); + expect(OverviewLogic.values.personalSourcesCount).toEqual(1); + expect(OverviewLogic.values.activityFeed).toEqual(feed); + }); + }); + + describe('initializeOverview', () => { + it('calls API and sets values', async () => { + const setServerDataSpy = jest.spyOn(OverviewLogic.actions, 'setServerData'); + + await OverviewLogic.actions.initializeOverview(); + + expect(http.get).toHaveBeenCalledWith('/api/workplace_search/overview'); + expect(setServerDataSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.ts new file mode 100644 index 0000000000000..7d8bc95529483 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.ts @@ -0,0 +1,114 @@ +/* + * 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'; + +import { flashAPIErrors } from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; + +import { FeedActivity } from './recent_activity'; + +interface OverviewServerData { + hasUsers: boolean; + hasOrgSources: boolean; + canCreateContentSources: boolean; + isOldAccount: boolean; + sourcesCount: number; + pendingInvitationsCount: number; + accountsCount: number; + personalSourcesCount: number; + activityFeed: FeedActivity[]; +} + +interface OverviewActions { + setServerData(serverData: OverviewServerData): OverviewServerData; + initializeOverview(): void; +} + +interface OverviewValues extends OverviewServerData { + dataLoading: boolean; +} + +export const OverviewLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'overview_logic'], + actions: { + setServerData: (serverData) => serverData, + initializeOverview: () => null, + }, + reducers: { + hasUsers: [ + false, + { + setServerData: (_, { hasUsers }) => hasUsers, + }, + ], + hasOrgSources: [ + false, + { + setServerData: (_, { hasOrgSources }) => hasOrgSources, + }, + ], + canCreateContentSources: [ + false, + { + setServerData: (_, { canCreateContentSources }) => canCreateContentSources, + }, + ], + isOldAccount: [ + false, + { + setServerData: (_, { isOldAccount }) => isOldAccount, + }, + ], + sourcesCount: [ + 0, + { + setServerData: (_, { sourcesCount }) => sourcesCount, + }, + ], + pendingInvitationsCount: [ + 0, + { + setServerData: (_, { pendingInvitationsCount }) => pendingInvitationsCount, + }, + ], + accountsCount: [ + 0, + { + setServerData: (_, { accountsCount }) => accountsCount, + }, + ], + personalSourcesCount: [ + 0, + { + setServerData: (_, { personalSourcesCount }) => personalSourcesCount, + }, + ], + activityFeed: [ + [], + { + setServerData: (_, { activityFeed }) => activityFeed, + }, + ], + dataLoading: [ + true, + { + setServerData: () => false, + }, + ], + }, + listeners: ({ actions }) => ({ + initializeOverview: async () => { + try { + const response = await HttpLogic.values.http.get('/api/workplace_search/overview'); + actions.setServerData(response); + } catch (e) { + flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.scss new file mode 100644 index 0000000000000..822ba64c91237 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.scss @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +.activity { + display: flex; + justify-content: space-between; + padding: $euiSizeM; + font-size: $euiFontSizeS; + + &--error { + font-weight: $euiFontWeightSemiBold; + color: $euiColorDanger; + background: rgba($euiColorDanger, .1); + + &__label { + margin-left: $euiSizeS * 1.75; + font-weight: $euiFontWeightRegular; + text-decoration: underline; + opacity: .7; + } + } + + &__message { + flex-grow: 1; + } + + &__date { + flex-grow: 0; + } + + & + & { + border-top: $euiBorderThin; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.test.tsx new file mode 100644 index 0000000000000..0b62207afc520 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.test.tsx @@ -0,0 +1,80 @@ +/* + * 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 { mockTelemetryActions } from '../../../__mocks__'; + +import './__mocks__/overview_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { setMockValues } from './__mocks__'; +import { RecentActivity, RecentActivityItem } from './recent_activity'; + +const organization = { name: 'foo', defaultOrgName: 'bar' }; + +const activityFeed = [ + { + id: 'demo', + sourceId: 'd2d2d23d', + message: 'was successfully connected', + target: 'http://localhost:3002/ws/org/sources', + timestamp: '2020-06-24 16:34:16', + }, +]; + +describe('RecentActivity', () => { + it('renders with no activityFeed data', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + + // Branch coverage - renders without error for custom org name + setMockValues({ organization }); + shallow(); + }); + + it('renders an activityFeed with links', () => { + setMockValues({ activityFeed }); + const wrapper = shallow(); + const activity = wrapper.find(RecentActivityItem).dive(); + + expect(activity).toHaveLength(1); + + const link = activity.find('[data-test-subj="viewSourceDetailsLink"]'); + link.simulate('click'); + expect(mockTelemetryActions.sendWorkplaceSearchTelemetry).toHaveBeenCalled(); + }); + + it('renders activity item error state', () => { + const props = { ...activityFeed[0], status: 'error' }; + const wrapper = shallow(); + + expect(wrapper.find('.activity--error')).toHaveLength(1); + expect(wrapper.find('.activity--error__label')).toHaveLength(1); + expect(wrapper.find(EuiLink).prop('color')).toEqual('danger'); + }); + + it('renders recent activity message for default org name', () => { + setMockValues({ + organization: { + name: 'foo', + defaultOrgName: 'foo', + }, + }); + const wrapper = shallow(); + const emptyPrompt = wrapper.find(EuiEmptyPrompt).dive(); + + expect(emptyPrompt.find(FormattedMessage).prop('defaultMessage')).toEqual( + 'Your organization has no recent activity' + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.tsx new file mode 100644 index 0000000000000..43d3f880feef4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.tsx @@ -0,0 +1,126 @@ +/* + * 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 moment from 'moment'; + +import { EuiEmptyPrompt, EuiLink, EuiPanel, EuiSpacer, EuiLinkProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; +import { TelemetryLogic } from '../../../shared/telemetry'; +import { AppLogic } from '../../app_logic'; +import { ContentSection } from '../../components/shared/content_section'; +import { RECENT_ACTIVITY_TITLE } from '../../constants'; +import { SOURCE_DETAILS_PATH, getContentSourcePath } from '../../routes'; + +import { OverviewLogic } from './overview_logic'; + +import './recent_activity.scss'; + +export interface FeedActivity { + status?: string; + id: string; + message: string; + timestamp: string; + sourceId: string; +} + +export const RecentActivity: React.FC = () => { + const { + organization: { name, defaultOrgName }, + } = useValues(AppLogic); + + const { activityFeed } = useValues(OverviewLogic); + + return ( + + + {activityFeed.length > 0 ? ( + <> + {activityFeed.map((props: FeedActivity, index) => ( + + ))} + + ) : ( + <> + + + {name === defaultOrgName ? ( + + ) : ( + + )} + + } + /> + + + )} + + + ); +}; + +export const RecentActivityItem: React.FC = ({ + id, + status, + message, + timestamp, + sourceId, +}) => { + const { sendWorkplaceSearchTelemetry } = useActions(TelemetryLogic); + + const onClick = () => + sendWorkplaceSearchTelemetry({ + action: 'clicked', + metric: 'recent_activity_source_details_link', + }); + + const linkProps = { + onClick, + target: '_blank', + href: getWorkplaceSearchUrl(getContentSourcePath(SOURCE_DETAILS_PATH, sourceId, true)), + external: true, + color: status === 'error' ? 'danger' : 'primary', + 'data-test-subj': 'viewSourceDetailsLink', + } as EuiLinkProps; + + return ( +
+
+ + {id} {message} + {status === 'error' && ( + + {' '} + + + )} + +
+
{moment.utc(timestamp).fromNow()}
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.test.tsx new file mode 100644 index 0000000000000..ff1d69e406830 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../__mocks__/enterprise_search_url.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiCard } from '@elastic/eui'; + +import { StatisticCard } from './statistic_card'; + +const props = { + title: 'foo', +}; + +describe('StatisticCard', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCard)).toHaveLength(1); + }); + + it('renders clickable card', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCard).prop('href')).toBe('http://localhost:3002/ws/foo'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.tsx new file mode 100644 index 0000000000000..346debb1c5251 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiCard, EuiFlexItem, EuiTitle, EuiTextColor } from '@elastic/eui'; + +import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; + +interface StatisticCardProps { + title: string; + count?: number; + actionPath?: string; +} + +export const StatisticCard: React.FC = ({ title, count = 0, actionPath }) => { + const linkProps = actionPath + ? { + href: getWorkplaceSearchUrl(actionPath), + target: '_blank', + rel: 'noopener', + } + : {}; + // TODO: When we port this destination to Kibana, we'll want to create a EuiReactRouterCard component (see shared/react_router_helpers/eui_link.tsx) + + return ( + + + {count} + + } + /> + + ); +}; From b3d92e582b44ca612304f4c12fdeb369240008bf Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 11 Feb 2021 19:40:42 +0100 Subject: [PATCH 21/53] [UiActions] fix race condition registering actions (#90944) (#91158) --- src/plugins/data/public/plugin.ts | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 6aae8dfc94456..9488837aa4073 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -102,25 +102,10 @@ export class DataPublicPlugin }); uiActions.registerTrigger(applyFilterTrigger); - uiActions.registerAction( createFilterAction(queryService.filterManager, queryService.timefilter.timefilter) ); - uiActions.addTriggerAction( - 'SELECT_RANGE_TRIGGER', - createSelectRangeAction(() => ({ - uiActions: startServices().plugins.uiActions, - })) - ); - - uiActions.addTriggerAction( - 'VALUE_CLICK_TRIGGER', - createValueClickAction(() => ({ - uiActions: startServices().plugins.uiActions, - })) - ); - inspector.registerView( getTableViewDescription(() => ({ uiActions: startServices().plugins.uiActions, @@ -179,6 +164,20 @@ export class DataPublicPlugin const search = this.searchService.start(core, { fieldFormats, indexPatterns }); setSearchService(search); + uiActions.addTriggerAction( + 'SELECT_RANGE_TRIGGER', + createSelectRangeAction(() => ({ + uiActions, + })) + ); + + uiActions.addTriggerAction( + 'VALUE_CLICK_TRIGGER', + createValueClickAction(() => ({ + uiActions, + })) + ); + uiActions.addTriggerAction( APPLY_FILTER_TRIGGER, uiActions.getAction(ACTION_GLOBAL_APPLY_FILTER) From f5ff905d0380df9dafecf6679fc7bddeeea31e05 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 11 Feb 2021 15:16:11 -0500 Subject: [PATCH 22/53] Add custom saved-object index usage data (#91063) (#91176) * Add custom saved-object index usage data * Fixing mock and test * Updating docs Co-authored-by: Brandon Kobel --- .../core_usage_data_service.mock.ts | 1 + .../core_usage_data_service.test.ts | 1 + .../core_usage_data/core_usage_data_service.ts | 14 ++++++++++++++ src/core/server/core_usage_data/types.ts | 1 + src/core/server/server.api.md | 1 + .../server/collectors/core/core_usage_collector.ts | 1 + src/plugins/telemetry/schema/oss_plugins.json | 3 +++ 7 files changed, 22 insertions(+) diff --git a/src/core/server/core_usage_data/core_usage_data_service.mock.ts b/src/core/server/core_usage_data/core_usage_data_service.mock.ts index 1a0706917b5dd..21a599e45da01 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.mock.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.mock.ts @@ -105,6 +105,7 @@ const createStartContractMock = () => { loggersConfiguredCount: 0, }, savedObjects: { + customIndex: false, maxImportExportSizeBytes: 10000, maxImportPayloadBytes: 26214400, }, diff --git a/src/core/server/core_usage_data/core_usage_data_service.test.ts b/src/core/server/core_usage_data/core_usage_data_service.test.ts index 795700abf518a..ddd041b0f544e 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.test.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.test.ts @@ -238,6 +238,7 @@ describe('CoreUsageDataService', () => { "loggersConfiguredCount": 0, }, "savedObjects": Object { + "customIndex": false, "maxImportExportSizeBytes": 10000, "maxImportPayloadBytes": 26214400, }, diff --git a/src/core/server/core_usage_data/core_usage_data_service.ts b/src/core/server/core_usage_data/core_usage_data_service.ts index a4c6c6e8c66f4..bd5f23b1c09bc 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.ts @@ -59,6 +59,19 @@ const kibanaOrTaskManagerIndex = (index: string, kibanaConfigIndex: string) => { return index === kibanaConfigIndex ? '.kibana' : '.kibana_task_manager'; }; +/** + * This is incredibly hacky... The config service doesn't allow you to determine + * whether or not a config value has been changed from the default value, and the + * default value is defined in legacy code. + * + * This will be going away in 8.0, so please look away for a few months + * + * @param index The `kibana.index` setting from the `kibana.yml` + */ +const isCustomIndex = (index: string) => { + return index !== '.kibana'; +}; + export class CoreUsageDataService implements CoreService { private logger: Logger; private elasticsearchConfig?: ElasticsearchConfigType; @@ -220,6 +233,7 @@ export class CoreUsageDataService implements CoreService Date: Thu, 11 Feb 2021 21:16:59 +0100 Subject: [PATCH 23/53] TS config cleanup (#90492) (#91173) * exclude all the plugins from src/plugins * move all the used fixtures to discover * remove src/fixtures alias * remove unused fixtures * cleanup x-pack/tsconfig.json * dont compile apm/scripts * fix tests * dont include infra in xpack/tsconfig.json * update list of includes # Conflicts: # src/fixtures/hits.js --- packages/kbn-test/jest-preset.js | 1 - src/dev/precommit_hook/casing_check_config.js | 1 - src/fixtures/agg_resp/date_histogram.js | 258 -------- src/fixtures/agg_resp/geohash_grid.js | 84 --- src/fixtures/agg_resp/range.js | 45 -- src/fixtures/config_upgrade_from_4.0.0.json | 25 - ..._upgrade_from_4.0.0_to_4.0.1-snapshot.json | 35 - .../config_upgrade_from_4.0.0_to_4.0.1.json | 35 - src/fixtures/fake_chart_events.js | 28 - src/fixtures/fake_hierarchical_data.ts | 621 ------------------ src/fixtures/field_mapping.js | 68 -- src/fixtures/hits.js | 42 -- src/fixtures/mapping_with_dupes.js | 46 -- src/fixtures/mock_index_patterns.js | 19 - src/fixtures/mock_state.js | 20 - src/fixtures/mock_ui_state.js | 33 - src/fixtures/search_response.js | 24 - src/fixtures/stubbed_search_source.js | 54 -- .../discover/public/__fixtures__}/fake_row.js | 0 .../public/__fixtures__}/logstash_fields.js | 3 +- .../public/__fixtures__}/real_hits.js | 0 .../stubbed_logstash_index_pattern.js | 9 +- .../stubbed_saved_object_index_pattern.ts | 2 +- .../doc_table/components/row_headers.test.js | 4 +- .../angular/doc_table/doc_table.test.js | 4 +- .../doc_table/lib/get_default_sort.test.ts | 2 +- .../angular/doc_table/lib/get_sort.test.ts | 2 +- .../lib/get_sort_for_search_source.test.ts | 2 +- .../sidebar/discover_field.test.tsx | 2 +- .../sidebar/discover_field_details.test.tsx | 2 +- .../discover_field_details_footer.test.tsx | 2 +- .../sidebar/discover_sidebar.test.tsx | 4 +- .../discover_sidebar_responsive.test.tsx | 4 +- .../sidebar/lib/field_calculator.test.ts | 4 +- .../public/__fixtures__/logstash_fields.js | 75 +++ .../stubbed_logstash_index_pattern.js | 47 ++ src/plugins/visualizations/public/vis.test.ts | 2 +- src/type_definitions/react_virtualized.d.ts | 11 - tsconfig.base.json | 3 +- tsconfig.json | 60 +- .../fleet/hooks/use_request/use_request.ts | 5 +- .../fleet/mock/fleet_start_services.tsx | 2 +- .../public/applications/fleet/mock/types.ts | 2 +- x-pack/plugins/fleet/server/mocks.ts | 14 + .../server/routes/limited_concurrency.ts | 5 +- .../routes/package_policy/handlers.test.ts | 3 +- .../server/routes/setup/handlers.test.ts | 3 +- .../fleet/server/saved_objects/index.ts | 3 +- .../server/saved_objects/security_solution.js | 11 + .../fleet/server/services/app_context.ts | 7 +- .../server/services/package_policy.test.ts | 3 +- .../fleet/server/services/package_policy.ts | 2 + .../fleet/server/services/setup.test.ts | 3 +- x-pack/plugins/fleet/tsconfig.json | 6 +- x-pack/plugins/osquery/tsconfig.json | 2 +- x-pack/tsconfig.json | 76 +-- 56 files changed, 216 insertions(+), 1614 deletions(-) delete mode 100644 src/fixtures/agg_resp/date_histogram.js delete mode 100644 src/fixtures/agg_resp/geohash_grid.js delete mode 100644 src/fixtures/agg_resp/range.js delete mode 100644 src/fixtures/config_upgrade_from_4.0.0.json delete mode 100644 src/fixtures/config_upgrade_from_4.0.0_to_4.0.1-snapshot.json delete mode 100644 src/fixtures/config_upgrade_from_4.0.0_to_4.0.1.json delete mode 100644 src/fixtures/fake_chart_events.js delete mode 100644 src/fixtures/fake_hierarchical_data.ts delete mode 100644 src/fixtures/field_mapping.js delete mode 100644 src/fixtures/hits.js delete mode 100644 src/fixtures/mapping_with_dupes.js delete mode 100644 src/fixtures/mock_index_patterns.js delete mode 100644 src/fixtures/mock_state.js delete mode 100644 src/fixtures/mock_ui_state.js delete mode 100644 src/fixtures/search_response.js delete mode 100644 src/fixtures/stubbed_search_source.js rename src/{fixtures => plugins/discover/public/__fixtures__}/fake_row.js (100%) rename src/{fixtures => plugins/discover/public/__fixtures__}/logstash_fields.js (96%) rename src/{fixtures => plugins/discover/public/__fixtures__}/real_hits.js (100%) rename src/{fixtures => plugins/discover/public/__fixtures__}/stubbed_logstash_index_pattern.js (81%) create mode 100644 src/plugins/visualizations/public/__fixtures__/logstash_fields.js create mode 100644 src/plugins/visualizations/public/__fixtures__/stubbed_logstash_index_pattern.js delete mode 100644 src/type_definitions/react_virtualized.d.ts create mode 100644 x-pack/plugins/fleet/server/saved_objects/security_solution.js diff --git a/packages/kbn-test/jest-preset.js b/packages/kbn-test/jest-preset.js index 127acd8d0beb5..d6e5ea637ed7b 100644 --- a/packages/kbn-test/jest-preset.js +++ b/packages/kbn-test/jest-preset.js @@ -37,7 +37,6 @@ module.exports = { '\\.ace\\.worker.js$': '/packages/kbn-test/target/jest/mocks/worker_module_mock.js', '\\.editor\\.worker.js$': '/packages/kbn-test/target/jest/mocks/worker_module_mock.js', '^(!!)?file-loader!': '/packages/kbn-test/target/jest/mocks/file_mock.js', - '^fixtures/(.*)': '/src/fixtures/$1', '^src/core/(.*)': '/src/core/$1', '^src/plugins/(.*)': '/src/plugins/$1', }, diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index baed8284347c8..144da018c300c 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -123,7 +123,6 @@ export const REMOVE_EXTENSION = ['packages/kbn-plugin-generator/template/**/*.ej * @type {Array} */ export const TEMPORARILY_IGNORED_PATHS = [ - 'src/fixtures/config_upgrade_from_4.0.0_to_4.0.1-snapshot.json', 'src/core/server/core_app/assets/favicons/android-chrome-192x192.png', 'src/core/server/core_app/assets/favicons/android-chrome-256x256.png', 'src/core/server/core_app/assets/favicons/android-chrome-512x512.png', diff --git a/src/fixtures/agg_resp/date_histogram.js b/src/fixtures/agg_resp/date_histogram.js deleted file mode 100644 index 29b34f1ce69d0..0000000000000 --- a/src/fixtures/agg_resp/date_histogram.js +++ /dev/null @@ -1,258 +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. - */ - -export default { - took: 35, - timed_out: false, - _shards: { - total: 2, - successful: 2, - failed: 0, - }, - hits: { - total: 32899, - max_score: 0, - hits: [], - }, - aggregations: { - 1: { - buckets: [ - { - key_as_string: '2015-01-30T01:00:00.000Z', - key: 1422579600000, - doc_count: 18, - }, - { - key_as_string: '2015-01-30T02:00:00.000Z', - key: 1422583200000, - doc_count: 68, - }, - { - key_as_string: '2015-01-30T03:00:00.000Z', - key: 1422586800000, - doc_count: 146, - }, - { - key_as_string: '2015-01-30T04:00:00.000Z', - key: 1422590400000, - doc_count: 149, - }, - { - key_as_string: '2015-01-30T05:00:00.000Z', - key: 1422594000000, - doc_count: 363, - }, - { - key_as_string: '2015-01-30T06:00:00.000Z', - key: 1422597600000, - doc_count: 555, - }, - { - key_as_string: '2015-01-30T07:00:00.000Z', - key: 1422601200000, - doc_count: 878, - }, - { - key_as_string: '2015-01-30T08:00:00.000Z', - key: 1422604800000, - doc_count: 1133, - }, - { - key_as_string: '2015-01-30T09:00:00.000Z', - key: 1422608400000, - doc_count: 1438, - }, - { - key_as_string: '2015-01-30T10:00:00.000Z', - key: 1422612000000, - doc_count: 1719, - }, - { - key_as_string: '2015-01-30T11:00:00.000Z', - key: 1422615600000, - doc_count: 1813, - }, - { - key_as_string: '2015-01-30T12:00:00.000Z', - key: 1422619200000, - doc_count: 1790, - }, - { - key_as_string: '2015-01-30T13:00:00.000Z', - key: 1422622800000, - doc_count: 1582, - }, - { - key_as_string: '2015-01-30T14:00:00.000Z', - key: 1422626400000, - doc_count: 1439, - }, - { - key_as_string: '2015-01-30T15:00:00.000Z', - key: 1422630000000, - doc_count: 1154, - }, - { - key_as_string: '2015-01-30T16:00:00.000Z', - key: 1422633600000, - doc_count: 847, - }, - { - key_as_string: '2015-01-30T17:00:00.000Z', - key: 1422637200000, - doc_count: 588, - }, - { - key_as_string: '2015-01-30T18:00:00.000Z', - key: 1422640800000, - doc_count: 374, - }, - { - key_as_string: '2015-01-30T19:00:00.000Z', - key: 1422644400000, - doc_count: 152, - }, - { - key_as_string: '2015-01-30T20:00:00.000Z', - key: 1422648000000, - doc_count: 140, - }, - { - key_as_string: '2015-01-30T21:00:00.000Z', - key: 1422651600000, - doc_count: 73, - }, - { - key_as_string: '2015-01-30T22:00:00.000Z', - key: 1422655200000, - doc_count: 28, - }, - { - key_as_string: '2015-01-30T23:00:00.000Z', - key: 1422658800000, - doc_count: 9, - }, - { - key_as_string: '2015-01-31T00:00:00.000Z', - key: 1422662400000, - doc_count: 29, - }, - { - key_as_string: '2015-01-31T01:00:00.000Z', - key: 1422666000000, - doc_count: 38, - }, - { - key_as_string: '2015-01-31T02:00:00.000Z', - key: 1422669600000, - doc_count: 70, - }, - { - key_as_string: '2015-01-31T03:00:00.000Z', - key: 1422673200000, - doc_count: 136, - }, - { - key_as_string: '2015-01-31T04:00:00.000Z', - key: 1422676800000, - doc_count: 173, - }, - { - key_as_string: '2015-01-31T05:00:00.000Z', - key: 1422680400000, - doc_count: 370, - }, - { - key_as_string: '2015-01-31T06:00:00.000Z', - key: 1422684000000, - doc_count: 545, - }, - { - key_as_string: '2015-01-31T07:00:00.000Z', - key: 1422687600000, - doc_count: 845, - }, - { - key_as_string: '2015-01-31T08:00:00.000Z', - key: 1422691200000, - doc_count: 1070, - }, - { - key_as_string: '2015-01-31T09:00:00.000Z', - key: 1422694800000, - doc_count: 1419, - }, - { - key_as_string: '2015-01-31T10:00:00.000Z', - key: 1422698400000, - doc_count: 1725, - }, - { - key_as_string: '2015-01-31T11:00:00.000Z', - key: 1422702000000, - doc_count: 1801, - }, - { - key_as_string: '2015-01-31T12:00:00.000Z', - key: 1422705600000, - doc_count: 1823, - }, - { - key_as_string: '2015-01-31T13:00:00.000Z', - key: 1422709200000, - doc_count: 1657, - }, - { - key_as_string: '2015-01-31T14:00:00.000Z', - key: 1422712800000, - doc_count: 1454, - }, - { - key_as_string: '2015-01-31T15:00:00.000Z', - key: 1422716400000, - doc_count: 1131, - }, - { - key_as_string: '2015-01-31T16:00:00.000Z', - key: 1422720000000, - doc_count: 810, - }, - { - key_as_string: '2015-01-31T17:00:00.000Z', - key: 1422723600000, - doc_count: 583, - }, - { - key_as_string: '2015-01-31T18:00:00.000Z', - key: 1422727200000, - doc_count: 384, - }, - { - key_as_string: '2015-01-31T19:00:00.000Z', - key: 1422730800000, - doc_count: 165, - }, - { - key_as_string: '2015-01-31T20:00:00.000Z', - key: 1422734400000, - doc_count: 135, - }, - { - key_as_string: '2015-01-31T21:00:00.000Z', - key: 1422738000000, - doc_count: 72, - }, - { - key_as_string: '2015-01-31T22:00:00.000Z', - key: 1422741600000, - doc_count: 8, - }, - ], - }, - }, -}; diff --git a/src/fixtures/agg_resp/geohash_grid.js b/src/fixtures/agg_resp/geohash_grid.js deleted file mode 100644 index 4a8fb3704c9b3..0000000000000 --- a/src/fixtures/agg_resp/geohash_grid.js +++ /dev/null @@ -1,84 +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 _ from 'lodash'; -export default function GeoHashGridAggResponseFixture() { - // for vis: - // - // vis = new Vis(indexPattern, { - // type: 'tile_map', - // aggs:[ - // { schema: 'metric', type: 'avg', params: { field: 'bytes' } }, - // { schema: 'split', type: 'terms', params: { field: '@tags', size: 10 } }, - // { schema: 'segment', type: 'geohash_grid', params: { field: 'geo.coordinates', precision: 3 } } - // ], - // params: { - // isDesaturated: true, - // mapType: 'Scaled%20Circle%20Markers' - // }, - // }); - - const geoHashCharts = _.union( - _.range(48, 57), // 0-9 - _.range(65, 90), // A-Z - _.range(97, 122) // a-z - ); - - const tags = _.times(_.random(4, 20), function (i) { - // random number of tags - let docCount = 0; - const buckets = _.times(_.random(40, 200), function () { - return _.sampleSize(geoHashCharts, 3).join(''); - }) - .sort() - .map(function (geoHash) { - const count = _.random(1, 5000); - - docCount += count; - - return { - key: geoHash, - doc_count: count, - 1: { - value: 2048 + i, - }, - }; - }); - - return { - key: 'tag ' + (i + 1), - doc_count: docCount, - 3: { - buckets: buckets, - }, - 1: { - value: 1000 + i, - }, - }; - }); - - return { - took: 3, - timed_out: false, - _shards: { - total: 4, - successful: 4, - failed: 0, - }, - hits: { - total: 298, - max_score: 0.0, - hits: [], - }, - aggregations: { - 2: { - buckets: tags, - }, - }, - }; -} diff --git a/src/fixtures/agg_resp/range.js b/src/fixtures/agg_resp/range.js deleted file mode 100644 index ca15f535add82..0000000000000 --- a/src/fixtures/agg_resp/range.js +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 default { - took: 35, - timed_out: false, - _shards: { - total: 7, - successful: 7, - failed: 0, - }, - hits: { - total: 218512, - max_score: 0, - hits: [], - }, - aggregations: { - 1: { - buckets: { - '*-1024.0': { - to: 1024, - to_as_string: '1024.0', - doc_count: 20904, - }, - '1024.0-2560.0': { - from: 1024, - from_as_string: '1024.0', - to: 2560, - to_as_string: '2560.0', - doc_count: 23358, - }, - '2560.0-*': { - from: 2560, - from_as_string: '2560.0', - doc_count: 174250, - }, - }, - }, - }, -}; diff --git a/src/fixtures/config_upgrade_from_4.0.0.json b/src/fixtures/config_upgrade_from_4.0.0.json deleted file mode 100644 index 522de78648c9b..0000000000000 --- a/src/fixtures/config_upgrade_from_4.0.0.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "took": 1, - "timed_out": false, - "_shards": { - "total": 1, - "successful": 1, - "failed": 0 - }, - "hits": { - "total": 1, - "max_score": 1, - "hits": [ - { - "_index": ".kibana", - "_type": "config", - "_id": "4.0.0", - "_score": 1, - "_source": { - "buildNum": 5888, - "defaultIndex": "logstash-*" - } - } - ] - } -} diff --git a/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1-snapshot.json b/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1-snapshot.json deleted file mode 100644 index 8767232dcdc1c..0000000000000 --- a/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1-snapshot.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "took": 1, - "timed_out": false, - "_shards": { - "total": 1, - "successful": 1, - "failed": 0 - }, - "hits": { - "total": 2, - "max_score": 1, - "hits": [ - { - "_index": ".kibana", - "_type": "config", - "_id": "4.0.1-SNAPSHOT", - "_score": 1, - "_source": { - "buildNum": 5921, - "defaultIndex": "logstash-*" - } - }, - { - "_index": ".kibana", - "_type": "config", - "_id": "4.0.0", - "_score": 1, - "_source": { - "buildNum": 5888, - "defaultIndex": "logstash-*" - } - } - ] - } -} diff --git a/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1.json b/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1.json deleted file mode 100644 index 57b486491b397..0000000000000 --- a/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "took": 1, - "timed_out": false, - "_shards": { - "total": 1, - "successful": 1, - "failed": 0 - }, - "hits": { - "total": 2, - "max_score": 1, - "hits": [ - { - "_index": ".kibana", - "_type": "config", - "_id": "4.0.1", - "_score": 1, - "_source": { - "buildNum": 5921, - "defaultIndex": "logstash-*" - } - }, - { - "_index": ".kibana", - "_type": "config", - "_id": "4.0.0", - "_score": 1, - "_source": { - "buildNum": 5888, - "defaultIndex": "logstash-*" - } - } - ] - } -} diff --git a/src/fixtures/fake_chart_events.js b/src/fixtures/fake_chart_events.js deleted file mode 100644 index 71f49cb4713b8..0000000000000 --- a/src/fixtures/fake_chart_events.js +++ /dev/null @@ -1,28 +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. - */ - -const results = {}; - -results.timeSeries = { - data: { - ordered: { - date: true, - interval: 600000, - max: 1414437217559, - min: 1414394017559, - }, - }, - label: 'apache', - value: 44, - point: { - label: 'apache', - x: 1414400400000, - y: 44, - y0: 0, - }, -}; diff --git a/src/fixtures/fake_hierarchical_data.ts b/src/fixtures/fake_hierarchical_data.ts deleted file mode 100644 index 2e23acfc3a803..0000000000000 --- a/src/fixtures/fake_hierarchical_data.ts +++ /dev/null @@ -1,621 +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. - */ - -export const metricOnly = { - hits: { total: 1000, hits: [], max_score: 0 }, - aggregations: { - agg_1: { value: 412032 }, - }, -}; - -export const threeTermBuckets = { - hits: { total: 1000, hits: [], max_score: 0 }, - aggregations: { - agg_2: { - buckets: [ - { - key: 'png', - doc_count: 50, - agg_1: { value: 412032 }, - agg_3: { - buckets: [ - { - key: 'IT', - doc_count: 10, - agg_1: { value: 9299 }, - agg_4: { - buckets: [ - { key: 'win', doc_count: 4, agg_1: { value: 0 } }, - { key: 'mac', doc_count: 6, agg_1: { value: 9299 } }, - ], - }, - }, - { - key: 'US', - doc_count: 20, - agg_1: { value: 8293 }, - agg_4: { - buckets: [ - { key: 'linux', doc_count: 12, agg_1: { value: 3992 } }, - { key: 'mac', doc_count: 8, agg_1: { value: 3029 } }, - ], - }, - }, - ], - }, - }, - { - key: 'css', - doc_count: 20, - agg_1: { value: 412032 }, - agg_3: { - buckets: [ - { - key: 'MX', - doc_count: 7, - agg_1: { value: 9299 }, - agg_4: { - buckets: [ - { key: 'win', doc_count: 3, agg_1: { value: 4992 } }, - { key: 'mac', doc_count: 4, agg_1: { value: 5892 } }, - ], - }, - }, - { - key: 'US', - doc_count: 13, - agg_1: { value: 8293 }, - agg_4: { - buckets: [ - { key: 'linux', doc_count: 12, agg_1: { value: 3992 } }, - { key: 'mac', doc_count: 1, agg_1: { value: 3029 } }, - ], - }, - }, - ], - }, - }, - { - key: 'html', - doc_count: 90, - agg_1: { value: 412032 }, - agg_3: { - buckets: [ - { - key: 'CN', - doc_count: 85, - agg_1: { value: 9299 }, - agg_4: { - buckets: [ - { key: 'win', doc_count: 46, agg_1: { value: 4992 } }, - { key: 'mac', doc_count: 39, agg_1: { value: 5892 } }, - ], - }, - }, - { - key: 'FR', - doc_count: 15, - agg_1: { value: 8293 }, - agg_4: { - buckets: [ - { key: 'win', doc_count: 3, agg_1: { value: 3992 } }, - { key: 'mac', doc_count: 12, agg_1: { value: 3029 } }, - ], - }, - }, - ], - }, - }, - ], - }, - }, -}; - -export const oneTermOneHistogramBucketWithTwoMetricsOneTopHitOneDerivative = { - hits: { total: 1000, hits: [], max_score: 0 }, - aggregations: { - agg_3: { - buckets: [ - { - key: 'png', - doc_count: 50, - agg_4: { - buckets: [ - { - key_as_string: '2014-09-28T00:00:00.000Z', - key: 1411862400000, - doc_count: 1, - agg_1: { value: 9283 }, - agg_2: { value: 1411862400000 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 23, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-29T00:00:00.000Z', - key: 1411948800000, - doc_count: 2, - agg_1: { value: 28349 }, - agg_2: { value: 1411948800000 }, - agg_5: { value: 203 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 39, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-30T00:00:00.000Z', - key: 1412035200000, - doc_count: 3, - agg_1: { value: 84330 }, - agg_2: { value: 1412035200000 }, - agg_5: { value: 200 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 329, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-01T00:00:00.000Z', - key: 1412121600000, - doc_count: 4, - agg_1: { value: 34992 }, - agg_2: { value: 1412121600000 }, - agg_5: { value: 103 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 22, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-02T00:00:00.000Z', - key: 1412208000000, - doc_count: 5, - agg_1: { value: 145432 }, - agg_2: { value: 1412208000000 }, - agg_5: { value: 153 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 93, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-03T00:00:00.000Z', - key: 1412294400000, - doc_count: 35, - agg_1: { value: 220943 }, - agg_2: { value: 1412294400000 }, - agg_5: { value: 239 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 72, - }, - }, - ], - }, - }, - }, - ], - }, - }, - { - key: 'css', - doc_count: 20, - agg_4: { - buckets: [ - { - key_as_string: '2014-09-28T00:00:00.000Z', - key: 1411862400000, - doc_count: 1, - agg_1: { value: 9283 }, - agg_2: { value: 1411862400000 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 75, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-29T00:00:00.000Z', - key: 1411948800000, - doc_count: 2, - agg_1: { value: 28349 }, - agg_2: { value: 1411948800000 }, - agg_5: { value: 10 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 11, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-30T00:00:00.000Z', - key: 1412035200000, - doc_count: 3, - agg_1: { value: 84330 }, - agg_2: { value: 1412035200000 }, - agg_5: { value: 24 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 238, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-01T00:00:00.000Z', - key: 1412121600000, - doc_count: 4, - agg_1: { value: 34992 }, - agg_2: { value: 1412121600000 }, - agg_5: { value: 49 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 343, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-02T00:00:00.000Z', - key: 1412208000000, - doc_count: 5, - agg_1: { value: 145432 }, - agg_2: { value: 1412208000000 }, - agg_5: { value: 100 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 837, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-03T00:00:00.000Z', - key: 1412294400000, - doc_count: 5, - agg_1: { value: 220943 }, - agg_2: { value: 1412294400000 }, - agg_5: { value: 23 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 302, - }, - }, - ], - }, - }, - }, - ], - }, - }, - { - key: 'html', - doc_count: 90, - agg_4: { - buckets: [ - { - key_as_string: '2014-09-28T00:00:00.000Z', - key: 1411862400000, - doc_count: 10, - agg_1: { value: 9283 }, - agg_2: { value: 1411862400000 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 30, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-29T00:00:00.000Z', - key: 1411948800000, - doc_count: 20, - agg_1: { value: 28349 }, - agg_2: { value: 1411948800000 }, - agg_5: { value: 1 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 43, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-30T00:00:00.000Z', - key: 1412035200000, - doc_count: 30, - agg_1: { value: 84330 }, - agg_2: { value: 1412035200000 }, - agg_5: { value: 5 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 88, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-01T00:00:00.000Z', - key: 1412121600000, - doc_count: 11, - agg_1: { value: 34992 }, - agg_2: { value: 1412121600000 }, - agg_5: { value: 10 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 91, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-02T00:00:00.000Z', - key: 1412208000000, - doc_count: 12, - agg_1: { value: 145432 }, - agg_2: { value: 1412208000000 }, - agg_5: { value: 43 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 534, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-03T00:00:00.000Z', - key: 1412294400000, - doc_count: 7, - agg_1: { value: 220943 }, - agg_2: { value: 1412294400000 }, - agg_5: { value: 1 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 553, - }, - }, - ], - }, - }, - }, - ], - }, - }, - ], - }, - }, -}; - -export const oneRangeBucket = { - took: 35, - timed_out: false, - _shards: { - total: 1, - successful: 1, - failed: 0, - }, - hits: { - total: 6039, - max_score: 0, - hits: [], - }, - aggregations: { - agg_2: { - buckets: { - '0.0-1000.0': { - from: 0, - from_as_string: '0.0', - to: 1000, - to_as_string: '1000.0', - doc_count: 606, - }, - '1000.0-2000.0': { - from: 1000, - from_as_string: '1000.0', - to: 2000, - to_as_string: '2000.0', - doc_count: 298, - }, - }, - }, - }, -}; - -export const oneFilterBucket = { - took: 11, - timed_out: false, - _shards: { - total: 1, - successful: 1, - failed: 0, - }, - hits: { - total: 6005, - max_score: 0, - hits: [], - }, - aggregations: { - agg_2: { - buckets: { - 'type:apache': { - doc_count: 4844, - }, - 'type:nginx': { - doc_count: 1161, - }, - }, - }, - }, -}; - -export const oneHistogramBucket = { - took: 37, - timed_out: false, - _shards: { - total: 6, - successful: 6, - failed: 0, - }, - hits: { - total: 49208, - max_score: 0, - hits: [], - }, - aggregations: { - agg_2: { - buckets: [ - { - key_as_string: '2014-09-28T00:00:00.000Z', - key: 1411862400000, - doc_count: 8247, - }, - { - key_as_string: '2014-09-29T00:00:00.000Z', - key: 1411948800000, - doc_count: 8184, - }, - { - key_as_string: '2014-09-30T00:00:00.000Z', - key: 1412035200000, - doc_count: 8269, - }, - { - key_as_string: '2014-10-01T00:00:00.000Z', - key: 1412121600000, - doc_count: 8141, - }, - { - key_as_string: '2014-10-02T00:00:00.000Z', - key: 1412208000000, - doc_count: 8148, - }, - { - key_as_string: '2014-10-03T00:00:00.000Z', - key: 1412294400000, - doc_count: 8219, - }, - ], - }, - }, -}; diff --git a/src/fixtures/field_mapping.js b/src/fixtures/field_mapping.js deleted file mode 100644 index 5077e361d5458..0000000000000 --- a/src/fixtures/field_mapping.js +++ /dev/null @@ -1,68 +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. - */ - -export default { - test: { - mappings: { - testType: { - baz: { - full_name: 'baz', - mapping: { - bar: { - type: 'long', - }, - }, - }, - 'foo.bar': { - full_name: 'foo.bar', - mapping: { - bar: { - type: 'string', - }, - }, - }, - not_analyzed_field: { - full_name: 'not_analyzed_field', - mapping: { - bar: { - type: 'string', - index: 'not_analyzed', - }, - }, - }, - index_no_field: { - full_name: 'index_no_field', - mapping: { - bar: { - type: 'string', - index: 'no', - }, - }, - }, - _id: { - full_name: '_id', - mapping: { - _id: { - store: false, - index: 'no', - }, - }, - }, - _timestamp: { - full_name: '_timestamp', - mapping: { - _timestamp: { - store: true, - index: 'no', - }, - }, - }, - }, - }, - }, -}; diff --git a/src/fixtures/hits.js b/src/fixtures/hits.js deleted file mode 100644 index 6cbb080031cf2..0000000000000 --- a/src/fixtures/hits.js +++ /dev/null @@ -1,42 +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. - */ - -export default function fitsFixture() { - return [ - // extension - // | machine.os - //timestamp | | bytes - //| ssl ip | | | request - [0, true, '192.168.0.1', 'php', 'Linux', 10, 'foo'], - [1, true, '192.168.0.1', 'php', 'Linux', 20, 'bar'], - [2, true, '192.168.0.1', 'php', 'Linux', 30, 'bar'], - [3, true, '192.168.0.1', 'php', 'Linux', 30, 'baz'], - [4, true, '192.168.0.1', 'php', 'Linux', 30, 'baz'], - [5, true, '192.168.0.1', 'php', 'Linux', 40.141592, 'bat'], - [6, true, '192.168.0.1', 'php', 'Linux', 40.141592, 'bat'], - [7, true, '192.168.0.1', 'php', 'Linux', 40.141592, 'bat'], - [8, true, '192.168.0.1', 'php', 'Linux', 40.141592, 'bat'], - [9, true, '192.168.0.1', 'php', 'Linux', 40.141592, 'bat'], - ].map((row, i) => { - return { - _score: 1, - _id: 1000 + i, - _type: 'test', - _index: 'test-index', - _source: { - '@timestamp': row[0], - ssl: row[1], - ip: row[2], - extension: row[3], - 'machine.os': row[4], - bytes: row[5], - request: row[6], - }, - }; - }); -} diff --git a/src/fixtures/mapping_with_dupes.js b/src/fixtures/mapping_with_dupes.js deleted file mode 100644 index 7f6da2600c9a8..0000000000000 --- a/src/fixtures/mapping_with_dupes.js +++ /dev/null @@ -1,46 +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. - */ - -export default { - test: { - mappings: { - testType: { - baz: { - full_name: 'baz', - mapping: { - bar: { - type: 'long', - }, - }, - }, - 'foo.bar': { - full_name: 'foo.bar', - mapping: { - bar: { - type: 'string', - }, - }, - }, - }, - }, - }, - duplicates: { - mappings: { - testType: { - baz: { - full_name: 'baz', - mapping: { - bar: { - type: 'date', - }, - }, - }, - }, - }, - }, -}; diff --git a/src/fixtures/mock_index_patterns.js b/src/fixtures/mock_index_patterns.js deleted file mode 100644 index ce44b71613b01..0000000000000 --- a/src/fixtures/mock_index_patterns.js +++ /dev/null @@ -1,19 +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 sinon from 'sinon'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; - -export default function (Private) { - const indexPatterns = Private(FixturesStubbedLogstashIndexPatternProvider); - const getIndexPatternStub = sinon.stub().resolves(indexPatterns); - - return { - get: getIndexPatternStub, - }; -} diff --git a/src/fixtures/mock_state.js b/src/fixtures/mock_state.js deleted file mode 100644 index cb18dac7b767d..0000000000000 --- a/src/fixtures/mock_state.js +++ /dev/null @@ -1,20 +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 _ from 'lodash'; -import sinon from 'sinon'; - -function MockState(defaults) { - this.on = _.noop; - this.off = _.noop; - this.save = sinon.stub(); - this.replace = sinon.stub(); - _.assign(this, defaults); -} - -export default MockState; diff --git a/src/fixtures/mock_ui_state.js b/src/fixtures/mock_ui_state.js deleted file mode 100644 index fc0a18137a5fd..0000000000000 --- a/src/fixtures/mock_ui_state.js +++ /dev/null @@ -1,33 +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 { set } from '@elastic/safer-lodash-set'; -import _ from 'lodash'; -let values = {}; -export default { - get: function (path, def) { - return _.get(values, path, def); - }, - set: function (path, val) { - set(values, path, val); - return val; - }, - setSilent: function (path, val) { - set(values, path, val); - return val; - }, - emit: _.noop, - on: _.noop, - off: _.noop, - clearAllKeys: function () { - values = {}; - }, - _reset: function () { - values = {}; - }, -}; diff --git a/src/fixtures/search_response.js b/src/fixtures/search_response.js deleted file mode 100644 index a84bd184990e0..0000000000000 --- a/src/fixtures/search_response.js +++ /dev/null @@ -1,24 +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 hits from 'fixtures/real_hits'; - -export default { - took: 73, - timed_out: false, - _shards: { - total: 144, - successful: 144, - failed: 0, - }, - hits: { - total: 49487, - max_score: 1.0, - hits: hits, - }, -}; diff --git a/src/fixtures/stubbed_search_source.js b/src/fixtures/stubbed_search_source.js deleted file mode 100644 index ea41e7bbe681c..0000000000000 --- a/src/fixtures/stubbed_search_source.js +++ /dev/null @@ -1,54 +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 sinon from 'sinon'; -import searchResponse from 'fixtures/search_response'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; - -export default function stubSearchSource(Private, $q, Promise) { - let deferedResult = $q.defer(); - const indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); - - let onResultsCount = 0; - return { - setField: sinon.spy(), - fetch: sinon.spy(), - destroy: sinon.spy(), - getField: function (param) { - switch (param) { - case 'index': - return indexPattern; - default: - throw new Error(`Param "${param}" is not implemented in the stubbed search source`); - } - }, - crankResults: function () { - deferedResult.resolve(searchResponse); - deferedResult = $q.defer(); - }, - onResults: function () { - onResultsCount++; - - // Up to the test to resolve this manually - // For example: - // someHandler.resolve(require('fixtures/search_response')) - return deferedResult.promise; - }, - getOnResultsCount: function () { - return onResultsCount; - }, - _flatten: function () { - return Promise.resolve({ index: indexPattern, body: {} }); - }, - _requestStartHandlers: [], - onRequestStart(fn) { - this._requestStartHandlers.push(fn); - }, - requestIsStopped() {}, - }; -} diff --git a/src/fixtures/fake_row.js b/src/plugins/discover/public/__fixtures__/fake_row.js similarity index 100% rename from src/fixtures/fake_row.js rename to src/plugins/discover/public/__fixtures__/fake_row.js diff --git a/src/fixtures/logstash_fields.js b/src/plugins/discover/public/__fixtures__/logstash_fields.js similarity index 96% rename from src/fixtures/logstash_fields.js rename to src/plugins/discover/public/__fixtures__/logstash_fields.js index 6303c83d809c0..a51e1555421de 100644 --- a/src/fixtures/logstash_fields.js +++ b/src/plugins/discover/public/__fixtures__/logstash_fields.js @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { shouldReadFieldFromDocValues, castEsToKbnFieldTypeName } from '../plugins/data/server'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { shouldReadFieldFromDocValues, castEsToKbnFieldTypeName } from '../../../data/server'; function stubbedLogstashFields() { return [ diff --git a/src/fixtures/real_hits.js b/src/plugins/discover/public/__fixtures__/real_hits.js similarity index 100% rename from src/fixtures/real_hits.js rename to src/plugins/discover/public/__fixtures__/real_hits.js diff --git a/src/fixtures/stubbed_logstash_index_pattern.js b/src/plugins/discover/public/__fixtures__/stubbed_logstash_index_pattern.js similarity index 81% rename from src/fixtures/stubbed_logstash_index_pattern.js rename to src/plugins/discover/public/__fixtures__/stubbed_logstash_index_pattern.js index 3451fb5422ecd..c8513176d1c96 100644 --- a/src/fixtures/stubbed_logstash_index_pattern.js +++ b/src/plugins/discover/public/__fixtures__/stubbed_logstash_index_pattern.js @@ -6,11 +6,12 @@ * Side Public License, v 1. */ -import stubbedLogstashFields from 'fixtures/logstash_fields'; +import stubbedLogstashFields from './logstash_fields'; +import { getKbnFieldType } from '../../../data/common'; -import { getKbnFieldType } from '../plugins/data/common'; -import { getStubIndexPattern } from '../plugins/data/public/test_utils'; -import { uiSettingsServiceMock } from '../core/public/ui_settings/ui_settings_service.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getStubIndexPattern } from '../../../data/public/test_utils'; +import { uiSettingsServiceMock } from '../../../../core/public/mocks'; const uiSettingSetupMock = uiSettingsServiceMock.createSetupContract(); uiSettingSetupMock.get.mockImplementation((item, defaultValue) => { diff --git a/src/plugins/discover/public/__mocks__/stubbed_saved_object_index_pattern.ts b/src/plugins/discover/public/__mocks__/stubbed_saved_object_index_pattern.ts index b8ce93c45e54a..a0c0b1f2c816e 100644 --- a/src/plugins/discover/public/__mocks__/stubbed_saved_object_index_pattern.ts +++ b/src/plugins/discover/public/__mocks__/stubbed_saved_object_index_pattern.ts @@ -7,7 +7,7 @@ */ // @ts-expect-error -import stubbedLogstashFields from '../../../../fixtures/logstash_fields'; +import stubbedLogstashFields from '../__fixtures__/logstash_fields'; const mockLogstashFields = stubbedLogstashFields(); diff --git a/src/plugins/discover/public/application/angular/doc_table/components/row_headers.test.js b/src/plugins/discover/public/application/angular/doc_table/components/row_headers.test.js index 33772f730912a..1824110c85b1a 100644 --- a/src/plugins/discover/public/application/angular/doc_table/components/row_headers.test.js +++ b/src/plugins/discover/public/application/angular/doc_table/components/row_headers.test.js @@ -12,9 +12,9 @@ import 'angular-sanitize'; import 'angular-route'; import _ from 'lodash'; import sinon from 'sinon'; -import { getFakeRow } from 'fixtures/fake_row'; +import { getFakeRow } from '../../../../__fixtures__/fake_row'; import $ from 'jquery'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; +import FixturesStubbedLogstashIndexPatternProvider from '../../../../__fixtures__/stubbed_logstash_index_pattern'; import { setScopedHistory, setServices, setDocViewsRegistry } from '../../../../kibana_services'; import { coreMock } from '../../../../../../../core/public/mocks'; import { dataPluginMock } from '../../../../../../data/public/mocks'; diff --git a/src/plugins/discover/public/application/angular/doc_table/doc_table.test.js b/src/plugins/discover/public/application/angular/doc_table/doc_table.test.js index cec8d72fbe77f..1765bae07eed7 100644 --- a/src/plugins/discover/public/application/angular/doc_table/doc_table.test.js +++ b/src/plugins/discover/public/application/angular/doc_table/doc_table.test.js @@ -12,8 +12,8 @@ import 'angular-mocks'; import 'angular-sanitize'; import 'angular-route'; import { createBrowserHistory } from 'history'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; -import hits from 'fixtures/real_hits'; +import FixturesStubbedLogstashIndexPatternProvider from '../../../__fixtures__/stubbed_logstash_index_pattern'; +import hits from '../../../__fixtures__/real_hits'; import { coreMock } from '../../../../../../core/public/mocks'; import { dataPluginMock } from '../../../../../data/public/mocks'; import { navigationPluginMock } from '../../../../../navigation/public/mocks'; diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/get_default_sort.test.ts b/src/plugins/discover/public/application/angular/doc_table/lib/get_default_sort.test.ts index 899c3cc2d4133..c73656435fb58 100644 --- a/src/plugins/discover/public/application/angular/doc_table/lib/get_default_sort.test.ts +++ b/src/plugins/discover/public/application/angular/doc_table/lib/get_default_sort.test.ts @@ -8,7 +8,7 @@ import { getDefaultSort } from './get_default_sort'; // @ts-ignore -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; +import FixturesStubbedLogstashIndexPatternProvider from '../../../../__fixtures__/stubbed_logstash_index_pattern'; import { IndexPattern } from '../../../../kibana_services'; describe('getDefaultSort function', function () { diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.test.ts b/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.test.ts index cf8fa67e54566..bd28987b4fdbd 100644 --- a/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.test.ts +++ b/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.test.ts @@ -8,7 +8,7 @@ import { getSort, getSortArray } from './get_sort'; // @ts-ignore -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; +import FixturesStubbedLogstashIndexPatternProvider from '../../../../__fixtures__/stubbed_logstash_index_pattern'; import { IndexPattern } from '../../../../kibana_services'; describe('docTable', function () { diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/get_sort_for_search_source.test.ts b/src/plugins/discover/public/application/angular/doc_table/lib/get_sort_for_search_source.test.ts index 1d965a176b99d..f0a13557af9fd 100644 --- a/src/plugins/discover/public/application/angular/doc_table/lib/get_sort_for_search_source.test.ts +++ b/src/plugins/discover/public/application/angular/doc_table/lib/get_sort_for_search_source.test.ts @@ -8,7 +8,7 @@ import { getSortForSearchSource } from './get_sort_for_search_source'; // @ts-ignore -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; +import FixturesStubbedLogstashIndexPatternProvider from '../../../../__fixtures__/stubbed_logstash_index_pattern'; import { IndexPattern } from '../../../../kibana_services'; import { SortOrder } from '../components/table_header/helpers'; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx index baec882fc6242..c16dab618b284 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { findTestSubject } from '@elastic/eui/lib/test'; // @ts-ignore -import stubbedLogstashFields from 'fixtures/logstash_fields'; +import stubbedLogstashFields from '../../../__fixtures__/logstash_fields'; import { mountWithIntl } from '@kbn/test/jest'; import { DiscoverField } from './discover_field'; import { coreMock } from '../../../../../../core/public/mocks'; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field_details.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_details.test.tsx index 29bd4ce5b2b7d..0113213f70c88 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field_details.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_details.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { findTestSubject } from '@elastic/eui/lib/test'; // @ts-ignore -import stubbedLogstashFields from 'fixtures/logstash_fields'; +import stubbedLogstashFields from '../../../__fixtures__/logstash_fields'; import { mountWithIntl } from '@kbn/test/jest'; import { DiscoverFieldDetails } from './discover_field_details'; import { coreMock } from '../../../../../../core/public/mocks'; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field_details_footer.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_details_footer.test.tsx index a82c3d740e7ed..07baeddf034ef 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field_details_footer.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_details_footer.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { findTestSubject } from '@elastic/eui/lib/test'; // @ts-ignore -import stubbedLogstashFields from 'fixtures/logstash_fields'; +import stubbedLogstashFields from '../../../__fixtures__/logstash_fields'; import { mountWithIntl } from '@kbn/test/jest'; import { coreMock } from '../../../../../../core/public/mocks'; import { IndexPatternField } from '../../../../../data/public'; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx index 0ff70585af144..947972ce1cfc5 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx @@ -10,9 +10,9 @@ import { each, cloneDeep } from 'lodash'; import { ReactWrapper } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; // @ts-ignore -import realHits from 'fixtures/real_hits.js'; +import realHits from '../../../__fixtures__/real_hits.js'; // @ts-ignore -import stubbedLogstashFields from 'fixtures/logstash_fields'; +import stubbedLogstashFields from '../../../__fixtures__/logstash_fields'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; import { DiscoverSidebarProps } from './discover_sidebar'; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.test.tsx index 02ab5abade7fb..7b12ab5f9bcd9 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.test.tsx @@ -10,9 +10,9 @@ import { each, cloneDeep } from 'lodash'; import { ReactWrapper } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; // @ts-ignore -import realHits from 'fixtures/real_hits.js'; +import realHits from '../../../__fixtures__/real_hits.js'; // @ts-ignore -import stubbedLogstashFields from 'fixtures/logstash_fields'; +import stubbedLogstashFields from '../../../__fixtures__/logstash_fields'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; import { coreMock } from '../../../../../../core/public/mocks'; diff --git a/src/plugins/discover/public/application/components/sidebar/lib/field_calculator.test.ts b/src/plugins/discover/public/application/components/sidebar/lib/field_calculator.test.ts index 94464c309251d..faa31dde1bb80 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/field_calculator.test.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/field_calculator.test.ts @@ -8,9 +8,9 @@ import _ from 'lodash'; // @ts-ignore -import realHits from 'fixtures/real_hits.js'; +import realHits from '../../../../__fixtures__/real_hits.js'; // @ts-ignore -import stubbedLogstashFields from 'fixtures/logstash_fields'; +import stubbedLogstashFields from '../../../../__fixtures__/logstash_fields'; import { coreMock } from '../../../../../../../core/public/mocks'; import { IndexPattern } from '../../../../../../data/public'; import { getStubIndexPattern } from '../../../../../../data/public/test_utils'; diff --git a/src/plugins/visualizations/public/__fixtures__/logstash_fields.js b/src/plugins/visualizations/public/__fixtures__/logstash_fields.js new file mode 100644 index 0000000000000..a51e1555421de --- /dev/null +++ b/src/plugins/visualizations/public/__fixtures__/logstash_fields.js @@ -0,0 +1,75 @@ +/* + * 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-next-line @kbn/eslint/no-restricted-paths +import { shouldReadFieldFromDocValues, castEsToKbnFieldTypeName } from '../../../data/server'; + +function stubbedLogstashFields() { + return [ + // |aggregatable + // | |searchable + // name esType | | |metadata | subType + ['bytes', 'long', true, true, { count: 10 }], + ['ssl', 'boolean', true, true, { count: 20 }], + ['@timestamp', 'date', true, true, { count: 30 }], + ['time', 'date', true, true, { count: 30 }], + ['@tags', 'keyword', true, true], + ['utc_time', 'date', true, true], + ['phpmemory', 'integer', true, true], + ['ip', 'ip', true, true], + ['request_body', 'attachment', true, true], + ['point', 'geo_point', true, true], + ['area', 'geo_shape', true, true], + ['hashed', 'murmur3', false, true], + ['geo.coordinates', 'geo_point', true, true], + ['extension', 'text', true, true], + ['extension.keyword', 'keyword', true, true, {}, { multi: { parent: 'extension' } }], + ['machine.os', 'text', true, true], + ['machine.os.raw', 'keyword', true, true, {}, { multi: { parent: 'machine.os' } }], + ['geo.src', 'keyword', true, true], + ['_id', '_id', true, true], + ['_type', '_type', true, true], + ['_source', '_source', true, true], + ['non-filterable', 'text', true, false], + ['non-sortable', 'text', false, false], + ['custom_user_field', 'conflict', true, true], + ['script string', 'text', true, false, { script: "'i am a string'" }], + ['script number', 'long', true, false, { script: '1234' }], + ['script date', 'date', true, false, { script: '1234', lang: 'painless' }], + ['script murmur3', 'murmur3', true, false, { script: '1234' }], + ].map(function (row) { + const [name, esType, aggregatable, searchable, metadata = {}, subType = undefined] = row; + + const { + count = 0, + script, + lang = script ? 'expression' : undefined, + scripted = !!script, + } = metadata; + + // the conflict type is actually a kbnFieldType, we + // don't have any other way to represent it here + const type = esType === 'conflict' ? esType : castEsToKbnFieldTypeName(esType); + + return { + name, + type, + esTypes: [esType], + readFromDocValues: shouldReadFieldFromDocValues(aggregatable, esType), + aggregatable, + searchable, + count, + script, + lang, + scripted, + subType, + }; + }); +} + +export default stubbedLogstashFields; diff --git a/src/plugins/visualizations/public/__fixtures__/stubbed_logstash_index_pattern.js b/src/plugins/visualizations/public/__fixtures__/stubbed_logstash_index_pattern.js new file mode 100644 index 0000000000000..c8513176d1c96 --- /dev/null +++ b/src/plugins/visualizations/public/__fixtures__/stubbed_logstash_index_pattern.js @@ -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 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 stubbedLogstashFields from './logstash_fields'; +import { getKbnFieldType } from '../../../data/common'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getStubIndexPattern } from '../../../data/public/test_utils'; +import { uiSettingsServiceMock } from '../../../../core/public/mocks'; + +const uiSettingSetupMock = uiSettingsServiceMock.createSetupContract(); +uiSettingSetupMock.get.mockImplementation((item, defaultValue) => { + return defaultValue; +}); + +export default function stubbedLogstashIndexPatternService() { + const mockLogstashFields = stubbedLogstashFields(); + + const fields = mockLogstashFields.map(function (field) { + const kbnType = getKbnFieldType(field.type); + + if (!kbnType || kbnType.name === 'unknown') { + throw new TypeError(`unknown type ${field.type}`); + } + + return { + ...field, + sortable: 'sortable' in field ? !!field.sortable : kbnType.sortable, + filterable: 'filterable' in field ? !!field.filterable : kbnType.filterable, + displayName: field.name, + }; + }); + + const indexPattern = getStubIndexPattern('logstash-*', (cfg) => cfg, 'time', fields, { + uiSettings: uiSettingSetupMock, + }); + + indexPattern.id = 'logstash-*'; + indexPattern.isTimeNanosBased = () => false; + + return indexPattern; +} diff --git a/src/plugins/visualizations/public/vis.test.ts b/src/plugins/visualizations/public/vis.test.ts index b90e5effeb8a5..45c5bb6b979c6 100644 --- a/src/plugins/visualizations/public/vis.test.ts +++ b/src/plugins/visualizations/public/vis.test.ts @@ -26,7 +26,7 @@ jest.mock('./services', () => { // eslint-disable-next-line const { SearchSource } = require('../../data/common/search/search_source'); // eslint-disable-next-line - const fixturesStubbedLogstashIndexPatternProvider = require('../../../fixtures/stubbed_logstash_index_pattern'); + const fixturesStubbedLogstashIndexPatternProvider = require('./__fixtures__/stubbed_logstash_index_pattern'); const visType = new BaseVisType({ name: 'pie', title: 'pie', diff --git a/src/type_definitions/react_virtualized.d.ts b/src/type_definitions/react_virtualized.d.ts deleted file mode 100644 index d78a159b71560..0000000000000 --- a/src/type_definitions/react_virtualized.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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. - */ - -declare module 'react-virtualized' { - export type ListProps = any; -} diff --git a/tsconfig.base.json b/tsconfig.base.json index f8e07911e71ce..c63d43b4cb6ad 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -5,8 +5,7 @@ // Allows for importing from `kibana` package for the exported types. "kibana": ["./kibana"], "kibana/public": ["src/core/public"], - "kibana/server": ["src/core/server"], - "fixtures/*": ["src/fixtures/*"] + "kibana/server": ["src/core/server"] }, // Support .tsx files and transform JSX into calls to React.createElement "jsx": "react", diff --git a/tsconfig.json b/tsconfig.json index f6e0fbc8d9e97..48feac3efe475 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,65 +7,7 @@ "exclude": [ "src/**/__fixtures__/**/*", "src/core/**/*", - "src/plugins/telemetry_management_section/**/*", - "src/plugins/advanced_settings/**/*", - "src/plugins/apm_oss/**/*", - "src/plugins/bfetch/**/*", - "src/plugins/charts/**/*", - "src/plugins/console/**/*", - "src/plugins/dashboard/**/*", - "src/plugins/discover/**/*", - "src/plugins/data/**/*", - "src/plugins/dev_tools/**/*", - "src/plugins/embeddable/**/*", - "src/plugins/es_ui_shared/**/*", - "src/plugins/expressions/**/*", - "src/plugins/home/**/*", - "src/plugins/input_control_vis/**/*", - "src/plugins/inspector/**/*", - "src/plugins/kibana_legacy/**/*", - "src/plugins/kibana_overview/**/*", - "src/plugins/kibana_react/**/*", - "src/plugins/kibana_usage_collection/**/*", - "src/plugins/kibana_utils/**/*", - "src/plugins/legacy_export/**/*", - "src/plugins/management/**/*", - "src/plugins/maps_legacy/**/*", - "src/plugins/navigation/**/*", - "src/plugins/newsfeed/**/*", - "src/plugins/region_map/**/*", - "src/plugins/saved_objects/**/*", - "src/plugins/saved_objects_management/**/*", - "src/plugins/saved_objects_tagging_oss/**/*", - "src/plugins/security_oss/**/*", - "src/plugins/share/**/*", - "src/plugins/spaces_oss/**/*", - "src/plugins/telemetry/**/*", - "src/plugins/telemetry_collection_manager/**/*", - "src/plugins/tile_map/**/*", - "src/plugins/timelion/**/*", - "src/plugins/ui_actions/**/*", - "src/plugins/url_forwarding/**/*", - "src/plugins/usage_collection/**/*", - "src/plugins/presentation_util/**/*", - "src/plugins/vis_default_editor/**/*", - "src/plugins/vis_type_markdown/**/*", - "src/plugins/vis_type_metric/**/*", - "src/plugins/vis_type_table/**/*", - "src/plugins/vis_type_tagcloud/**/*", - "src/plugins/vis_type_timelion/**/*", - "src/plugins/vis_type_timeseries/**/*", - "src/plugins/vis_type_vislib/**/*", - "src/plugins/vis_type_vega/**/*", - "src/plugins/vis_type_xy/**/*", - "src/plugins/visualizations/**/*", - "src/plugins/visualize/**/*", - "src/plugins/index_pattern_management/**/*", - // In the build we actually exclude **/public/**/* from this config so that - // we can run the TSC on both this and the .browser version of this config - // file, but if we did it during development IDEs would not be able to find - // the tsconfig.json file for public files correctly. - // "src/**/public/**/*" + "src/plugins/**/*" ], "references": [ { "path": "./src/core/tsconfig.json" }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/use_request.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/use_request.ts index 33c993ffdad40..4c4433c2b4f89 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/use_request.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/use_request.ts @@ -19,7 +19,10 @@ let httpClient: HttpSetup; export type UseRequestConfig = _UseRequestConfig; -interface RequestError extends Error { +/** + * @internal + */ +export interface RequestError extends Error { statusCode?: number; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/mock/fleet_start_services.tsx b/x-pack/plugins/fleet/public/applications/fleet/mock/fleet_start_services.tsx index 72e6601a023e1..d219384f66cef 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/mock/fleet_start_services.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/mock/fleet_start_services.tsx @@ -7,10 +7,10 @@ import React from 'react'; import { I18nProvider } from '@kbn/i18n/react'; +import { MockedKeys } from '@kbn/utility-types/jest'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { createStartDepsMock } from './plugin_dependencies'; import { IStorage, Storage } from '../../../../../../../src/plugins/kibana_utils/public'; -import { MockedKeys } from '../../../../../../../packages/kbn-utility-types/jest/index'; import { setHttpClient } from '../hooks/use_request'; import { MockedFleetStartServices } from './types'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/mock/types.ts b/x-pack/plugins/fleet/public/applications/fleet/mock/types.ts index 9e0adf75c0a35..0a55fa43bf18d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/mock/types.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/mock/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { MockedKeys } from '../../../../../../../packages/kbn-utility-types/jest/index'; +import { MockedKeys } from '@kbn/utility-types/jest'; import { FleetSetupDeps, FleetStart, FleetStartDeps, FleetStartServices } from '../../../plugin'; export type MockedFleetStartServices = MockedKeys; diff --git a/x-pack/plugins/fleet/server/mocks.ts b/x-pack/plugins/fleet/server/mocks.ts index 92159c1ced7c3..c650995c809cb 100644 --- a/x-pack/plugins/fleet/server/mocks.ts +++ b/x-pack/plugins/fleet/server/mocks.ts @@ -10,6 +10,9 @@ import { loggingSystemMock, savedObjectsServiceMock, } from 'src/core/server/mocks'; +import { coreMock } from '../../../../src/core/server/mocks'; +import { licensingMock } from '../../../plugins/licensing/server/mocks'; + import { FleetAppContext } from './plugin'; import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks'; import { securityMock } from '../../security/server/mocks'; @@ -29,6 +32,17 @@ export const createAppContextStartContractMock = (): FleetAppContext => { }; }; +function createCoreRequestHandlerContextMock() { + return { + core: coreMock.createRequestHandlerContext(), + licensing: licensingMock.createRequestHandlerContext(), + }; +} + +export const xpackMocks = { + createRequestHandlerContext: createCoreRequestHandlerContextMock, +}; + export const createPackagePolicyServiceMock = () => { return { compilePackagePolicyInputs: jest.fn(), diff --git a/x-pack/plugins/fleet/server/routes/limited_concurrency.ts b/x-pack/plugins/fleet/server/routes/limited_concurrency.ts index 45af0a3b7eaab..92195ae08681a 100644 --- a/x-pack/plugins/fleet/server/routes/limited_concurrency.ts +++ b/x-pack/plugins/fleet/server/routes/limited_concurrency.ts @@ -5,11 +5,12 @@ * 2.0. */ -import { +import type { CoreSetup, KibanaRequest, LifecycleResponseFactory, OnPreAuthToolkit, + OnPreAuthHandler, } from 'kibana/server'; import { LIMITED_CONCURRENCY_ROUTE_TAG } from '../../common'; import { FleetConfigType } from '../index'; @@ -48,7 +49,7 @@ export function createLimitedPreAuthHandler({ }: { isMatch: (request: KibanaRequest) => boolean; maxCounter: IMaxCounter; -}) { +}): OnPreAuthHandler { return function preAuthHandler( request: KibanaRequest, response: LifecycleResponseFactory, diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts index df99f2fba7ed9..2b44975cc3b4d 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts @@ -9,9 +9,8 @@ import { httpServerMock, httpServiceMock } from 'src/core/server/mocks'; import { IRouter, KibanaRequest, RequestHandler, RouteConfig } from 'kibana/server'; import { registerRoutes } from './index'; import { PACKAGE_POLICY_API_ROUTES } from '../../../common/constants'; -import { xpackMocks } from '../../../../../mocks'; import { appContextService } from '../../services'; -import { createAppContextStartContractMock } from '../../mocks'; +import { createAppContextStartContractMock, xpackMocks } from '../../mocks'; import { PackagePolicyServiceInterface, ExternalCallback } from '../..'; import { CreatePackagePolicyRequestSchema } from '../../types/rest_spec'; import { packagePolicyService } from '../../services'; 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 af9596849fd7a..946f17ad8129d 100644 --- a/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts @@ -5,11 +5,10 @@ * 2.0. */ -import { xpackMocks } from '../../../../../../x-pack/mocks'; import { httpServerMock } from 'src/core/server/mocks'; import { PostIngestSetupResponse } from '../../../common'; import { RegistryError } from '../../errors'; -import { createAppContextStartContractMock } from '../../mocks'; +import { createAppContextStartContractMock, xpackMocks } from '../../mocks'; import { FleetSetupHandler } from './handlers'; import { appContextService } from '../../services/app_context'; import { setupIngestManager } from '../../services/setup'; diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index d50db8d9809f4..f2eb8be5c030c 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -10,7 +10,8 @@ import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objec import { migratePackagePolicyToV7110, migratePackagePolicyToV7120, -} from '../../../security_solution/common'; + // @ts-expect-error +} from './security_solution'; import { OUTPUT_SAVED_OBJECT_TYPE, AGENT_POLICY_SAVED_OBJECT_TYPE, diff --git a/x-pack/plugins/fleet/server/saved_objects/security_solution.js b/x-pack/plugins/fleet/server/saved_objects/security_solution.js new file mode 100644 index 0000000000000..63f70ba783c0c --- /dev/null +++ b/x-pack/plugins/fleet/server/saved_objects/security_solution.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { + migratePackagePolicyToV7110, + migratePackagePolicyToV7120, +} from '../../../security_solution/common'; diff --git a/x-pack/plugins/fleet/server/services/app_context.ts b/x-pack/plugins/fleet/server/services/app_context.ts index f63282f8ed7c6..02e4fceea54f9 100644 --- a/x-pack/plugins/fleet/server/services/app_context.ts +++ b/x-pack/plugins/fleet/server/services/app_context.ts @@ -7,6 +7,8 @@ import { BehaviorSubject, Observable } from 'rxjs'; import { first } from 'rxjs/operators'; +import { kibanaPackageJSON } from '@kbn/utils'; + import { ElasticsearchClient, SavedObjectsServiceStart, @@ -18,7 +20,6 @@ import { EncryptedSavedObjectsClient, EncryptedSavedObjectsPluginSetup, } from '../../../encrypted_saved_objects/server'; -import packageJSON from '../../../../../package.json'; import { SecurityPluginStart } from '../../../security/server'; import { FleetConfigType } from '../../common'; import { ExternalCallback, ExternalCallbacksStorage, FleetAppContext } from '../plugin'; @@ -33,8 +34,8 @@ class AppContextService { private configSubject$?: BehaviorSubject; private savedObjects: SavedObjectsServiceStart | undefined; private isProductionMode: FleetAppContext['isProductionMode'] = false; - private kibanaVersion: FleetAppContext['kibanaVersion'] = packageJSON.version; - private kibanaBranch: FleetAppContext['kibanaBranch'] = packageJSON.branch; + private kibanaVersion: FleetAppContext['kibanaVersion'] = kibanaPackageJSON.version; + private kibanaBranch: FleetAppContext['kibanaBranch'] = kibanaPackageJSON.branch; private cloud?: CloudSetup; private logger: Logger | undefined; private httpSetup?: HttpServiceSetup; 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 1f2666dc14d1f..604592a0a8d87 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.test.ts @@ -12,10 +12,9 @@ import { PackageInfo, PackagePolicySOAttributes } from '../types'; import { SavedObjectsUpdateResponse } from 'src/core/server'; import { httpServerMock } from 'src/core/server/mocks'; import { KibanaRequest } from 'kibana/server'; -import { xpackMocks } from '../../../../mocks'; import { ExternalCallback } from '..'; import { appContextService } from './app_context'; -import { createAppContextStartContractMock } from '../mocks'; +import { createAppContextStartContractMock, xpackMocks } from '../mocks'; async function mockedGetAssetsData(_a: any, _b: any, dataset: string) { if (dataset === 'dataset1') { diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 8d1ac90f3ec15..a882ceb0037f2 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -565,3 +565,5 @@ async function _compilePackageStream( export type PackagePolicyServiceInterface = PackagePolicyService; export const packagePolicyService = new PackagePolicyService(); + +export type { PackagePolicyService }; diff --git a/x-pack/plugins/fleet/server/services/setup.test.ts b/x-pack/plugins/fleet/server/services/setup.test.ts index a4df30b97a443..479f28fa0a1ed 100644 --- a/x-pack/plugins/fleet/server/services/setup.test.ts +++ b/x-pack/plugins/fleet/server/services/setup.test.ts @@ -5,8 +5,7 @@ * 2.0. */ -import { xpackMocks } from '../../../../../x-pack/mocks'; -import { createAppContextStartContractMock } from '../mocks'; +import { createAppContextStartContractMock, xpackMocks } from '../mocks'; import { appContextService } from './app_context'; import { setupIngestManager } from './setup'; diff --git a/x-pack/plugins/fleet/tsconfig.json b/x-pack/plugins/fleet/tsconfig.json index 3a37b14410424..152fb2e132f62 100644 --- a/x-pack/plugins/fleet/tsconfig.json +++ b/x-pack/plugins/fleet/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "composite": true, "outDir": "./target/types", @@ -12,7 +12,9 @@ "common/**/*", "public/**/*", "server/**/*", - "scripts/**/*" + "scripts/**/*", + "package.json", + "../../typings/**/*" ], "references": [ { "path": "../../../src/core/tsconfig.json" }, diff --git a/x-pack/plugins/osquery/tsconfig.json b/x-pack/plugins/osquery/tsconfig.json index 6167833762583..407830d6a6c21 100644 --- a/x-pack/plugins/osquery/tsconfig.json +++ b/x-pack/plugins/osquery/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "composite": true, "outDir": "./target/types", diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 6b874f6253843..2c475083b589a 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -1,69 +1,24 @@ { "extends": "../tsconfig.base.json", - "include": ["mocks.ts", "typings/**/*", "plugins/**/*", "tasks/**/*"], + "include": [ + "mocks.ts", + "typings/**/*", + "tasks/**/*", + "plugins/apm/**/*", + "plugins/case/**/*", + "plugins/lists/**/*", + "plugins/logstash/**/*", + "plugins/monitoring/**/*", + "plugins/security_solution/**/*", + "plugins/xpack_legacy/**/*", + "plugins/drilldowns/url_drilldown/**/*" + ], "exclude": [ - "plugins/actions/**/*", - "plugins/alerts/**/*", + "test/**/*", "plugins/apm/e2e/cypress/**/*", "plugins/apm/ftr_e2e/**/*", "plugins/apm/scripts/**/*", - "plugins/banners/**/*", - "plugins/canvas/**/*", - "plugins/console_extensions/**/*", - "plugins/code/**/*", - "plugins/data_enhanced/**/*", - "plugins/discover_enhanced/**/*", - "plugins/dashboard_mode/**/*", - "plugins/dashboard_enhanced/**/*", - "plugins/fleet/**/*", - "plugins/global_search/**/*", - "plugins/global_search_providers/**/*", - "plugins/graph/**/*", - "plugins/features/**/*", - "plugins/file_upload/**/*", - "plugins/embeddable_enhanced/**/*", - "plugins/event_log/**/*", - "plugins/enterprise_search/**/*", - "plugins/infra/**/*", - "plugins/licensing/**/*", - "plugins/lens/**/*", - "plugins/maps/**/*", - "plugins/maps_legacy_licensing/**/*", - "plugins/ml/**/*", - "plugins/observability/**/*", - "plugins/osquery/**/*", - "plugins/reporting/**/*", - "plugins/searchprofiler/**/*", - "plugins/security_solution/cypress/**/*", - "plugins/task_manager/**/*", - "plugins/telemetry_collection_xpack/**/*", - "plugins/transform/**/*", - "plugins/translations/**/*", - "plugins/triggers_actions_ui/**/*", - "plugins/ui_actions_enhanced/**/*", - "plugins/spaces/**/*", - "plugins/security/**/*", - "plugins/stack_alerts/**/*", - "plugins/encrypted_saved_objects/**/*", - "plugins/beats_management/**/*", - "plugins/cloud/**/*", - "plugins/saved_objects_tagging/**/*", - "plugins/global_search_bar/**/*", - "plugins/ingest_pipelines/**/*", - "plugins/license_management/**/*", - "plugins/snapshot_restore/**/*", - "plugins/painless_lab/**/*", - "plugins/watcher/**/*", - "plugins/runtime_fields/**/*", - "plugins/index_management/**/*", - "plugins/grokdebugger/**/*", - "plugins/upgrade_assistant/**/*", - "plugins/rollup/**/*", - "plugins/remote_clusters/**/*", - "plugins/cross_cluster_replication/**/*", - "plugins/index_lifecycle_management/**/*", - "plugins/uptime/**/*", - "test/**/*" + "plugins/security_solution/cypress/**/*" ], "compilerOptions": { // overhead is too significant @@ -121,6 +76,7 @@ { "path": "./plugins/event_log/tsconfig.json" }, { "path": "./plugins/features/tsconfig.json" }, { "path": "./plugins/file_upload/tsconfig.json" }, + { "path": "./plugins/fleet/tsconfig.json" }, { "path": "./plugins/global_search_bar/tsconfig.json" }, { "path": "./plugins/global_search_providers/tsconfig.json" }, { "path": "./plugins/global_search/tsconfig.json" }, From d3145b3f232af0f812721dbba910ffd0b9fbeeb6 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Thu, 11 Feb 2021 14:25:12 -0600 Subject: [PATCH 24/53] [Workplace Search] Port bugfix to handle duplicate schema (#91055) (#91178) Ports https://github.com/elastic/ent-search/pull/3040 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/schema/schema_logic.test.ts | 27 ++++++++++++------- .../components/schema/schema_logic.ts | 21 ++++++++++++--- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts index 28850531ebb94..74e3337e9600a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts @@ -307,16 +307,25 @@ describe('SchemaLogic', () => { }); }); - it('addNewField', () => { - const setServerFieldSpy = jest.spyOn(SchemaLogic.actions, 'setServerField'); - SchemaLogic.actions.onInitializeSchema(serverResponse); - const newSchema = { - ...schema, - bar: 'number', - }; - SchemaLogic.actions.addNewField('bar', 'number'); + describe('addNewField', () => { + it('handles happy path', () => { + const setServerFieldSpy = jest.spyOn(SchemaLogic.actions, 'setServerField'); + SchemaLogic.actions.onInitializeSchema(serverResponse); + const newSchema = { + ...schema, + bar: 'number', + }; + SchemaLogic.actions.addNewField('bar', 'number'); + + expect(setServerFieldSpy).toHaveBeenCalledWith(newSchema, ADD); + }); - expect(setServerFieldSpy).toHaveBeenCalledWith(newSchema, ADD); + it('handles duplicate', () => { + SchemaLogic.actions.onInitializeSchema(serverResponse); + SchemaLogic.actions.addNewField('foo', 'number'); + + expect(setErrorMessage).toHaveBeenCalledWith('New field already exists: foo.'); + }); }); it('updateExistingFieldType', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts index 10b7f85a631bc..c97c6f5f0c1be 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts @@ -8,6 +8,8 @@ import { kea, MakeLogicType } from 'kea'; import { cloneDeep, isEqual } from 'lodash'; +import { i18n } from '@kbn/i18n'; + import { TEXT } from '../../../../../shared/constants/field_types'; import { ADD, UPDATE } from '../../../../../shared/constants/operations'; import { @@ -300,9 +302,22 @@ export const SchemaLogic = kea>({ } }, addNewField: ({ fieldName, newFieldType }) => { - const schema = cloneDeep(values.activeSchema); - schema[fieldName] = newFieldType; - actions.setServerField(schema, ADD); + if (fieldName in values.activeSchema) { + window.scrollTo(0, 0); + setErrorMessage( + i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.schema.newFieldExists.message', + { + defaultMessage: 'New field already exists: {fieldName}.', + values: { fieldName }, + } + ) + ); + } else { + const schema = cloneDeep(values.activeSchema); + schema[fieldName] = newFieldType; + actions.setServerField(schema, ADD); + } }, updateExistingFieldType: ({ fieldName, newFieldType }) => { const schema = cloneDeep(values.activeSchema); From 0bfcb636b65da050dea39eb0ff5fc2492869fa30 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Thu, 11 Feb 2021 14:35:34 -0600 Subject: [PATCH 25/53] [7.x] [Metrics UI] Fix saving/loading saved views from URL (#90216) (#91025) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../saved_views/toolbar_control.tsx | 6 +- .../containers/saved_view/saved_view.tsx | 64 +++++++++++++++---- .../infra/public/pages/metrics/index.tsx | 6 +- .../pages/metrics/inventory_view/index.tsx | 6 +- 4 files changed, 60 insertions(+), 22 deletions(-) diff --git a/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx b/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx index 09e319b9935d3..ade43638deb68 100644 --- a/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx +++ b/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx @@ -6,7 +6,7 @@ */ import { EuiFlexGroup } from '@elastic/eui'; -import React, { useCallback, useState, useEffect, useContext } from 'react'; +import React, { useCallback, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -21,7 +21,7 @@ import { SavedViewCreateModal } from './create_modal'; import { SavedViewUpdateModal } from './update_modal'; import { SavedViewManageViewsFlyout } from './manage_views_flyout'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { SavedView } from '../../containers/saved_view/saved_view'; +import { useSavedViewContext } from '../../containers/saved_view/saved_view'; import { SavedViewListModal } from './view_list_modal'; interface Props { @@ -47,7 +47,7 @@ export function SavedViewsToolbarControls(props: Props) { updatedView, currentView, setCurrentView, - } = useContext(SavedView.Context); + } = useSavedViewContext(); const [modalOpen, setModalOpen] = useState(false); const [viewListModalOpen, setViewListModalOpen] = useState(false); const [isInvalid, setIsInvalid] = useState(false); diff --git a/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx b/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx index e867cf800f4b4..4c4835cbe4cdb 100644 --- a/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx +++ b/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx @@ -6,9 +6,14 @@ */ import createContainer from 'constate'; +import * as rt from 'io-ts'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { constant, identity } from 'fp-ts/lib/function'; import { useCallback, useMemo, useState, useEffect, useContext } from 'react'; import { i18n } from '@kbn/i18n'; import { SimpleSavedObject, SavedObjectAttributes } from 'kibana/public'; +import { useUrlState } from '../../utils/use_url_state'; import { useFindSavedObject } from '../../hooks/use_find_saved_object'; import { useCreateSavedObject } from '../../hooks/use_create_saved_object'; import { useDeleteSavedObject } from '../../hooks/use_delete_saved_object'; @@ -39,6 +44,14 @@ interface Props { shouldLoadDefault: boolean; } +const savedViewUrlStateRT = rt.type({ + viewId: rt.string, +}); +type SavedViewUrlState = rt.TypeOf; +const DEFAULT_SAVED_VIEW_STATE: SavedViewUrlState = { + viewId: '0', +}; + export const useSavedView = (props: Props) => { const { source, @@ -52,6 +65,13 @@ export const useSavedView = (props: Props) => { const { data, loading, find, error: errorOnFind, hasView } = useFindSavedObject< SavedViewSavedObject >(viewType); + const [urlState, setUrlState] = useUrlState({ + defaultState: DEFAULT_SAVED_VIEW_STATE, + decodeUrlState, + encodeUrlState, + urlStateKey: 'savedView', + }); + const [shouldLoadDefault] = useState(props.shouldLoadDefault); const [currentView, setCurrentView] = useState | null>(null); const [loadingDefaultView, setLoadingDefaultView] = useState(null); @@ -212,25 +232,35 @@ export const useSavedView = (props: Props) => { }); }, [setCurrentView, defaultViewId, defaultViewState]); - useEffect(() => { - if (loadingDefaultView || currentView || !shouldLoadDefault) { - return; - } - + const loadDefaultViewIfSet = useCallback(() => { if (defaultViewId !== '0') { loadDefaultView(); } else { setDefault(); setLoadingDefaultView(false); } - }, [ - loadDefaultView, - shouldLoadDefault, - setDefault, - loadingDefaultView, - currentView, - defaultViewId, - ]); + }, [defaultViewId, loadDefaultView, setDefault, setLoadingDefaultView]); + + useEffect(() => { + if (loadingDefaultView || currentView || !shouldLoadDefault) { + return; + } + + loadDefaultViewIfSet(); + }, [loadDefaultViewIfSet, loadingDefaultView, currentView, shouldLoadDefault]); + + useEffect(() => { + if (currentView && urlState.viewId !== currentView.id && data) + setUrlState({ viewId: currentView.id }); + }, [urlState, setUrlState, currentView, defaultViewId, data]); + + useEffect(() => { + if (!currentView && !loading && data) { + const viewToSet = views.find((v) => v.id === urlState.viewId); + if (viewToSet) setCurrentView(viewToSet); + else loadDefaultViewIfSet(); + } + }, [loading, currentView, data, views, setCurrentView, loadDefaultViewIfSet, urlState.viewId]); return { views, @@ -260,3 +290,11 @@ export const useSavedView = (props: Props) => { export const SavedView = createContainer(useSavedView); export const [SavedViewProvider, useSavedViewContext] = SavedView; + +const encodeUrlState = (state: SavedViewUrlState) => { + return savedViewUrlStateRT.encode(state); +}; +const decodeUrlState = (value: unknown) => { + const state = pipe(savedViewUrlStateRT.decode(value), fold(constant(undefined), identity)); + return state; +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index 8fd32bda7fbc8..240cb778275b1 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -36,7 +36,7 @@ import { WaffleTimeProvider } from './inventory_view/hooks/use_waffle_time'; import { WaffleFiltersProvider } from './inventory_view/hooks/use_waffle_filters'; import { MetricsAlertDropdown } from '../../alerting/common/components/metrics_alert_dropdown'; -import { SavedView } from '../../containers/saved_view/saved_view'; +import { SavedViewProvider } from '../../containers/saved_view/saved_view'; import { AlertPrefillProvider } from '../../alerting/use_alert_prefill'; import { InfraMLCapabilitiesProvider } from '../../containers/ml/infra_ml_capabilities'; import { AnomalyDetectionFlyout } from './inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout'; @@ -195,7 +195,7 @@ const PageContent = (props: { const { options } = useContext(MetricsExplorerOptionsContainer.Context); return ( - - + ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx index 7123c022538e9..6b980d33c2559 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx @@ -23,7 +23,7 @@ import { useTrackPageview } from '../../../../../observability/public'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { Layout } from './components/layout'; import { useLinkProps } from '../../../hooks/use_link_props'; -import { SavedView } from '../../../containers/saved_view/saved_view'; +import { SavedViewProvider } from '../../../containers/saved_view/saved_view'; import { DEFAULT_WAFFLE_VIEW_STATE } from './hooks/use_waffle_view_state'; import { useWaffleOptionsContext } from './hooks/use_waffle_options'; @@ -64,13 +64,13 @@ export const SnapshotPage = () => { ) : metricIndicesExist ? ( <> - - + ) : hasFailedLoadingSource ? ( From ec03fa15b72e9875d9659a246c95226d8336b1dc Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 11 Feb 2021 15:35:59 -0500 Subject: [PATCH 26/53] Add saved object docs (#90860) (#91152) * iwp * add docs on saved objects * add saved object docs * Update dev_docs/key_concepts/saved_objects.mdx Co-authored-by: Brandon Kobel * Update dev_docs/tutorials/saved_objects.mdx Co-authored-by: Brandon Kobel * Update dev_docs/tutorials/saved_objects.mdx Co-authored-by: Brandon Kobel * Update dev_docs/tutorials/saved_objects.mdx Co-authored-by: Brandon Kobel * Update dev_docs/tutorials/saved_objects.mdx Co-authored-by: Brandon Kobel * Update dev_docs/tutorials/saved_objects.mdx Co-authored-by: Brandon Kobel * Update dev_docs/tutorials/saved_objects.mdx Co-authored-by: Brandon Kobel * review updates * remove this line, support being added Co-authored-by: Brandon Kobel Co-authored-by: Stacey Gammon Co-authored-by: Brandon Kobel --- .../assets/saved_object_vs_data_indices.png | Bin 0 -> 13819 bytes dev_docs/key_concepts/saved_objects.mdx | 74 ++++++ dev_docs/tutorials/saved_objects.mdx | 250 ++++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100644 dev_docs/assets/saved_object_vs_data_indices.png create mode 100644 dev_docs/key_concepts/saved_objects.mdx create mode 100644 dev_docs/tutorials/saved_objects.mdx diff --git a/dev_docs/assets/saved_object_vs_data_indices.png b/dev_docs/assets/saved_object_vs_data_indices.png new file mode 100644 index 0000000000000000000000000000000000000000..e79a5cd848db1c5f3010950ad7e224bad7fb0f02 GIT binary patch literal 13819 zcmeHuXH=70(`Xcxg9sc&M5%fZP$Ymz(a=`^Wh>Z*a9ew-<007-Gt=#}Vj=A44yaNJNC35aQJPtfxbk>C$f zj+TX06L{?HNeiBmS_)My4rdXk~SF~p_qGmn&4`w`C#&VjrB;v7hFJbt!ct?!mj zwJ@x1Mt68%n&v(LsgvH>E=}y!U~cFA1p-~$$5NF+peyHexj;8ADuTKr$sjp4DBGt~ z|000bs!)vyTQ-o?kGcG_*t`%h==Qyh+_-dBp&1ej`tAx*zGtvkmZN?YWFA=!@w7u* zz~Lbds>eZ*C!gYzj&8n6V?E2Mz&nDLWn7T$1J$pgluosGxhLMr=N3G_5HfTDLqP2< zmKYrWBqO#nl%5ov-k^2>-#>EyW9I}mhHQKD*0uoU=Ie4mV2w8Z4R*o z`)5>yx3IIqcbm*?@|H0D7Q(z##(b1OsVNoEA`a83Q+hr)%3d1 z$21a?F!keh6Yq{1^Anoo+@kYJ1F<{kD0rJXsj07qgrICf=}{fLmu6Z*N`k`Q9nRR%RiLmEbfbV%`6cZ94CgP z*`YL%>P@~Kr#^*u&e$Q*onXh^%_U|-8P3~dJjF%K+JF*%OG6`)j5_3Jy)(rOaWi?m z|BH9_x3K`X7$Rj+c5l0(;ovRxOk~=jZZCr+Lk6goR?s}NMp$Tb^B(>puB?$VIW-mj z{NI>>OH%7}B6<{CJYFwrCB~y=b@z+{zf$hNiONS5uTb{DkZPzBG%q4; z$lut|lG0_v`eE?<=@U6w#tSh4SU4#M8S#33rPYX zx7%<~#*zOY0Spw2mgiulI5U#hVli*elSI=dn^TK4tcrBXZT$R$8S~h|W$+0mAzDB` z>|kX(Z0IFl+s?H2*3dC;+0LzU@_lNNXp!igMx8xZrtIffl4x-iS7-enaB)WB^r8;( zdn2JCqH{BMdHU!696s1ys~6RX)zauood z>ky(%`LPjSTP5-;X{Eg-4^}HRvfl}>JVC~Xau$d{59EZ)`zU#(y~U?;Te2rKmWziw z>rJQ^aW9+vtjA@BgGFRiR7w@;;PX3}{dH5GoKW|H9{u^*Mv({1ZFkNp*fBa-XdDwh z$o4L_rL@;YaCf*$l-7TWpAJ4k#O!0VwRpTs5}aWZ#os9hQn!$vw+XI}I5JCNifNBL zJ3bTQ9w_BG1LIaQ2&)W+NlLS8k%H7s)hdNEaM}d%hgD>aVvpg`m%YQPEsHMuxqEUq z>0r(f48dgN+;!12cD-?U?nNg9UkgBumG?MHs+rW5>yT)Kg)MZ^xL)GH@rx8X9cKaW zYO1*r^)7D0|0yg!)S!C=inW+~Eh}f!$L3j@?48LRyUG~qbw@y3#;PB~jxteMm1)p} z+jGf!=-KmtL}dp-3_)k^WE!NE37>)a63*%GX|1QLsK&_8#?0LqwS&ckI(K!A+{G@A zeMaMb<;vv~Z))P4ql> z{fV1&H0BV4%hzwpmgQJhU4u}4DK_>VAWg(m`B?s9_>9M}0qPv1(+d7}DAsZmpQ)QtYu zD6@NkzlBH)jN(xet?9Uve>Shu4th{d#~<^1a9`-*6EZePsXI-P(Qp0H^DM1II7_V@ zYr=|Z+gUhyT%yI@&WR8Q-RUjvEye#ptTUoS5OCyRE~mmsBM&aB&|2B+3rLn1|x)vHMxuNR)W_h92*>x+5CGkjq+ z5tL=JVEpc49Cp%hhNQ-5pD|cs@3?!4aZd9N`}q|;!gVu|k<`C)6)s>y_VOQv#Krb< zWP9Ett%vcFHWt?X!_dJF8%W!ffh#!?ch8*mnm0;ByBLH$j-GpBdVmcWEjvBOLdn_TU`4cF~Pw!6-DsUUd-*EKoKb zWrg94A=07O)mn(nJ7?!iydbZBspEopAPz%cL|)WZy`AVVWW1e%U<29x?K3yAt%Nar zCG6;QT~I9kXUnqKo(V3YE$IzyJVVb|^y<4&E5&*f>jd+Qy|3e0xrOfX35ltXZcai) zU~CqOJ3ZU|^0#mu(fn_JhnBx#c`&~hQ_jWTX}Kc>H4lRZXo-e4Udj^d!YpwM;H%1# zj__zB%=~9pD#+2$)CPsrjG1y{nL4|M_w8Ck#ZEsS<(*ky{KHgMjOYB%jkFIoaAP$b zHrttg#JxYYiumfz$5k!$A{C*4p>RQ`eNcuz*x~VyK{K(MgJ;39VmgG;HYKt6#Oe%k zu$1$1h>}{6w8BLGuCtJ#%Uy29dja`BgZyh#B=4JRB|z+1)bGhLIwjL-j~7o~&?P0d z)Bo^mqza*D&LXz8&916ClossQG!bWuFO{Aa-ekFb$DFlQOXZ_l(hDxsBi|R7OOf?7 z)t5cC@c!Bh*NE?rv<;iP&wnu73LFY=k`O+(`na;LT(F0%ox~SDqPwzC6yQOuD*2!Z z`T59Bhr0bYzWNtqDAOfI3vN8Jlk34z9QeI|9xEOOA6j%;V}nI>EkOGUl?OzZZyztu zwg?mcn^mk>ac;sR@llB1^6Ot^;k#YXR48LdxD4Y>3k1YMKa1?d6bsPyPCAB3|^I4>C7DJ%8$}qFnd-i zioA;KYl?k&EA(B}k~mZ{KheIvfEUX?cTWg{$=C~AY?+1X2up~$US$&EM>kxgOnr4T zm>n-6HFfSG7F-d*W_uK?yvC_&JsAEjuKE|p>A=*PnPSrIsjFj2hEv;n7IGxmkU)d(w%hn#XQ=K0<+gF>Kzn%bc{aLOb>R~GpV*4 zI{&)$(cJgzSmSzyIv#hMmAS|0GXvRIAQS;zwX7ONg}mQ#2?Kl_-^|Fjt(|YjuYc~2 zDzO({kn&+&?o;Sdo!rfBKd;;}{c1E3ld$&J^%W&SteT?S2;&`J56!Kb-F@!u3v#wx z`jo=R71Wq_c1WQ!U$ArytxYUtj-v*ab&+)=sC&*_t_(wXz;bK%gBo{Bs=kV;A{+L} z?G%KYJu=i9&(7eDEGfvIxnxljBUiB>YjC2g-E_Gel6M}fvu5VX<9Ty^Y9pRw=&aM5 zoN)%>T|*Y$bMuFLT2@;h(l+%XV`)RcIiiAT2$sBH9)cJg3mm6Py-+v2)ZURWT7655 z;phqF&+_If$h}{*r|Fzgx%XtFC`VpoRI+9a&xR3np;snX3Rq5$+K@YyPfFs^hI$&8$0!8*kaO*My^DR3sVq{XozbVDVYsQng$Pau=?~m^2a2xZdCT zcq0=iD>aIYCv!f!b_?02b`RA)lXXvJe}#6uVSeK~k&<7bjulWemh)QAU*OhN4j$Ci z&@)uSdgW~Y;+-xlqVcaay!S}pRjikw@EEFP8}Y}NE>(Q~&c(PiY?+l^s;=Ya+pVjn zFrUA6GZ>6iu{oMWxt3wrm*dQ6GE+U!b*K59xpy|fUn_O=$|~%eOx>t&L7jh!v*#I6 z#G`P2mfOOa6gl(Uj|v^PM^ME?&R^GO=5)jNyA&xtp;=FaN-kQk5jozQ*5rrS^JYCZ zx0A~vaC^u&zcr#q_50OsKh(s1x!Y$b!^^j)S1jx2(|z%kNs&B|BMf?PxID_NI%HcNRdjZFut zspHA@bH(b?_(F?buks)9!c$79(i651*W1hk5rAu8865yKn6~|w_=a0S$-vd z-=o|~srjXeX*|pS9J;u#xpLKm2(j91C11Kz`ry{DmNpH+e=BLli=@w>*(0i4*8)IK5_}$ zVYEAhesZzz3rmmwx$sF0UA00~fU&5^D&$+e`Lq9e`h_g(GwFmZ4}!f4s=lYtZj7m= zekIT?czNpf{?KwlPi|;%6pL^CU3tp{mQq#bu&@`DQ1i6=$FG#M?!tK@v+nmwpB?B{3w z^grs(N}ui)+&4JpT(x!lLxzU;w(MMDo@)?SctEVO%I_MLn`tdE7Wd*q`M*btcD5R$ zd!zEIXM2oxQ^Db>q1?L*s@?%JI`dHuV1@oOF4JXewb7*<0O;5;jFjdOWvc|eI&;V$Ckx%gJ*wSv}dg<|Dpgo`S z#F8NlVG+ZIhbsznu%ZJDV~MSoDcxT#{Z-vI?lA*%BMj>sYRq4xj2l&?tlb^C{CnG! zdj@ugu&FP+`;Nw@$L zan&Ik!=1bE>Wig&QN`6=qoTFp>y1mDNwiHm&W3t%`<`L#@Xy{;DN{nwAj*v$Zg%=4 zf`BscGMX!IMK1*JStiqT6@iAnH2@p+qfbY8Cvc7pBTGTfh+6MHK1VN?bJ)RfKNl4x z7Y#JHgmMhFVyK=t^$(&O+60UlJ2vGzEU%?TNGfc9zX4aHFGl%-jU~%(zDIrA%Nlh` z-5QZt8y-lI0!~VWGEnx-V5ZN6{>^1O z%EU+=@8**Tze$xG)ZEk9Yl~Nb0~%Z$`KRjM&AtAt_eCF@DvqbKCVSI=v@RwJXSLdJ z(ZSEN^b98=RKh7vUw{*#$`sZ97?Y4Dpl_(U*8ouq-f_1V*D11!FwrcE45i#P#3=3V ztuMX$c(a6qpSrToYMt3CB%WX*w3;z8DVC+wKv35UA3_p@t35v|68S%a|KmFU0-XM+ z$wQ~8a6&%Yb5DZtjDNcf(hZxZ;#*YyKCU#21h?4ZzMc8Id+XQV%S)>K(n$aJWhX%9 znv%|mkXZ77UnLcKJ?ww~%JkkRMgO-yvt_N( zKDE;C$;!Tor>GviKy-K$jG>=jH&b6j9f$<|A&k_q!x+AuzwXyUt)7cCB*fXeteR_5@lOKfW`)1j31ZO?)!*@A@O+i-(OvIkjt!>WAo0kvbi zafZ+cTgl5C{=Mn#Em|Z<=~>=I1eA7+&R@ZLFAY{!coRII{X>9(MubTUPVcBeSJy&~ zr>1tacuueJhOaJe;wPTwt!kL;hntS)6x(H@`}gL5*{*8agz9XC{nZ$~&D5J(9ePjr zFIKKlq7?IoEo6c-wJ@(YBm_#XRP$JC2>QZLM;Z&Mo(R0#q@pp(Pn&6$^oQYtFM98U zu#{G2Y?HbJKdf4qNr9r1D(+#{o%@cvcMIK9$XN*0@TtlpI(BLCvw; z%ovrS%W-9G-x$2OwQ0h(zGPHx@Lv{Pd8u>smx-5g+n4W!r>1R-t?Z-Uuq-G2(rJ5^ znqlj9oCQYj3Hf~q;q2N}*yIS`AnCT{=R}g>bVOg;x6gg+m3NGn+w61W!WajVzAB^N z_{AB0F6^I$d;C2QW^YXwP>nBg9J5<^M2j27OLCL11RGzYQM%B}BvgBVpOnd?o~WsAyK`og(2=89QnB{}F^dAfNxW~% zu8<=>6&9A05}YXI6D6QGuCXrjm*nXN50Jq`?I*6X-@T13@FqqkeSh^S7ZM(RK%fqr4PO7 z8@8Kq79(59W8*iVOP9Py#r8Lz?hL6`>8#t9Y=s3D%{4ueba2D$V;z>R^*G=8kH{2< zQU+90@CyYx_1;64A&GN!!OzPgHJBT;mG^Lm6h{3<-x0fq)kQ`1Gs5#bCBum!y*t0q zP3vgHFHQKhe~a$XtL0B0?4Tb*XnF?=kn8xa8PYSvOQL$s$07G;yq7kou;(^~?Yt$F z)0I7nZJR=h+)KGPjKT+voUjpt=d(<>j_7)E{CiBn546Uf($8;jbFV&^g&O+&jvgD_ zCp5L1uQ=!~%96+SnR=ETG$V4^uy8z9cwtQu-_^Z*`NA|o?Qi91G75wxIr6h6fD^e$ zI^st2$X2>K$ziWd;Ijc3nW4(6c2IB2EevTvV$oae-*KY?)V)l#>PzKRVYBn5M{ z6y_h4u$m)({mhjuu>V3**eEKYa9$BvW!|N_3Uy?PTJ0I#z984f50#zP5n?5lP&lrz z%^f0Ds}SfTha-%T-+zpDe@sBwy4R!020_36eMDsHW=52;>>{fj(SU?r<&%4`A==T@ z<4d8J7{2?^g^1E{{CJL{1rj!pqQ&l1+W(9a?Rfo>+cB^2g<@drR`G%Yqh zeBt7GU@Gb3$6}Unr2NAiZ^Ht@fe+4Wf2XCM>2$ylJ@7#?rU}&6hl1uwvzF`S>O_cc zSj1XBPAv4JG~H8s6%F7QAcWo;_S|R#Qs)pLy*O{ z~E_vtTZK?OQO=>hvuYt;azrqzw>7{lUhE>d=z7cDE0wtP#ovo|r27mam=&Kn3U{jx(k)ST^jbJ%Tom;1CPg*2v{RD3NS_>~@z99Q-CXLohjVns%)<{{j4tOeRG*|pcAZ__ z2SjEx^dMQh1#8qW*wANB5Nue?120;gT&ihO>IfcMiu19l@4kyQhLyLTL|;SK2+gXL zF-=DC(hlWIZ3*yUaE%Fd3ur5c9;WI^>M&(sY!mz4sD)v*z{r#vZp zy=LkHQad<9&Nx>?l)(tYNy}mhH7coSL#ZJ*?lnV*smSK<0B6@tz4+lRX$B&qvRgjP zPFBRc9+8QJ9z2|45&EnMv5>@w%3k`5dJ{!e&St+_4bmt7PEhKTc`&?D_1JtS9bX_@b-+NE+p}B3hN~^O;R?9s56pB-eA$v ztShWXN;l0L5Sd<3(&ay<*Xy(TyDSLlEF*Y%m+uPRUa@HKAG+s(I&paNE#g)~9h%B# zUEwfNI%VE?NG5tcub(1rd$!r*(T3+bF#FkdCiEt5>i=z~U3-iD&HDpHejUE2Y6 z2183IYYE2Hb;jvlr7I4Vh3OGR?k`{2nA%u)Ws-H8Bq6%}6SLP=JD$}Wz)=@Q9<}0K zvz;=@=OtkU6=JSf*Pd!^!C`O+TY#QnoR(ki;?t>u&@D{-8 zOQq(*-_vpMTf6A2S(f712gK<32LtN0InK+r-sXf>aG8abD%QFJCVq4Y#rJz)TwHPK zGi@opYoxT4OeBtd#=D9GW~2jJ&CBZbadib+5`WTarANvJVvxkC1SHg1+w)m)uLs68 z2mo~1*LTZcDL3*4sE^GF*)|nsD3mMv5(*qr(1|22zrm-;bN&k9T~F+Jkz4N;H>`al zK*81)_b%GCAvj*C3}$)EQcn#%mh-XV`qKrx>CE5Tc|E_`D^zUVZ7o#;Q|Xa^mgY4K zC?E|bOGt%S*(!wf$+mKsTN;^!HC&7h_#pdrp&ZJSefMWs@uHft-;GavMqTQ|dd;H> zNVR`RG@K`^&%T1ycl%;-q%1>Bt}fcnTlLYa_M6D}&pK_BHL0Q2y;Z*d=+gan@g%el z(p*Ij8#uKX(UR=;e(Y9(OzK&zRr#UyC*hGWafZd zPgoEsFz3$;qZaF8iwz&)nlXZO)yI@fxgpte{8rLIXV%Rk$^ zGJ8ZQ@Lr9mHe$S~Sla-cr{S+fsvT3)S?$nWxy3c`q}a4SkbI4(ywH}{qpjB7r`W`r zfd^RwZWw(9J_&47C2UW&DKzm-@Yn`+{H}rvB_q@)`q5xs@HpX7C;M0}w#iIioJ9&G z^WVVzHc=CQvx6{cw7u!OgZPV)u{lWY3wZI~zGB=;7IHrzA5+NBN48F{m-LnFInk_! zb8i?a41Vt6T|0a2D$mh--w^d>6|qzirtc&NTJ?os z%w(oSpfqm5cmdZzzLf5Lars*7Y$>kwicoRj^x(CHdKdLC_caDTECmi1S@|)%PG{aYZqZl43Y7Esdu^j;)CX$?#QChryLaa_tE}K({9~N4 z!s|&0!=2m_tBOnkwF|oIs+DTW#Y)L`2tDVwc_MFuS&x5gyYb@8tgA{iioHZya?SejhVO8Q)-i(6W zSJIcjIK-#M=8nJ4{w`K>M~8ZW?#i;SHnX~XS=K$KEnRn|E#hl?B7@{azV-0R{T?s% zY~gg)sU=i-VN%JMJyxxR;qUjX5`TR2IGfE=|;(RN^uGCE_)WBhLj*y(UMw{2sLs8?2TWtwnO) z9+l}63Y%W|o&h0c^>-o zuD^#usUb9$%x)<*qK8yBt(WZW?rccy;^^VBa8GcZ9UYFiHf}BnTY)L{?|i#;IhJfU zfQooczUualiznOPP^z$YPOrIu8%T^mJ(-1?%{OS2WAFL(o3QjuS=Ab8V_m|6~F0Urg%63lq8p0CU zueIlpznYoOe)4jS>x^FaVU3KZ`y2NdqW*SF7kqNjR`S^|%7QCRWHo?!L#X$IL*~S< z{G%<&7Sc%qODI=a%G?raC5N25yt+vtWXFFG!m2!|mo{c9*&SvnqJ4Kvd+pfI@vWNE zDhETvbHiYw1%V4FrTSFxE;T{pNpnfg6!YGh!9pyM?xlOxA~G$Y)9H`Xzqq~paTWlMo>a1~@0P-{%|U4_r~MH?cp&o-B!gY6 zQ6W06*Qi`I3w}`lm`@Ohn{@K_vnJOM%x6uWWw5$COga$rXtKLSM89kGw1#U)Jdz01 z>HRj7S=C4?+PQTS6O3;f)3DS`2yW zO~Ymd;u3TiTnf%42BQ2x6EfKArp+&GjLc+v=+)auBK7_pU2P_(w9bI)xKg5|=$=BK zB>QFsTcgtNRl+R2tJ95@+|*~bXTT`}el~GrKW$HI)X4Eely53c!hxq-9&4PLm(zFH zP$Y>Oj;qI6+wE%J|ut zKguLWWEeY5%*#^z)Yx0nU4!g?9Ad4QkBXPA=8UK0vDcw>x9vKwZ^GXriRY8*L(xL>0pkVXPE*+MZ!mQ#)O&mB znng|0tUOlPy8bm=5K*%6hiFas?>arOhB9n3kj{jz-~@rSjgY%7Z%}dM%=m-O>LeubfeAGdi-eNKEHk=vI|HvKdo}e| zn|;N*N?$?3N$OZ*pMlS(m@+I}{D}4D$p`hX*)jGnT=K{*8TBm&O{)D6d-u_6j+64| zQbQU(ghAi$scAODD4f>zEn$=LBB?uVBAgiQoe=&12IRBl#O${=X;@69Pp?c{pbX|J zeJfz?FMRolA55q}*w#t@9>nhtIzv$g5+#2BVA65brd03l*e-q8lis`buFN?k@u>;@ zXsVL_-bU^g<{bvi<$0;HdigMwNTfEI*TzFhzfUf4#8;p5sZVA~_f367T;SE;H)X$u z*^kSEUGhDccopC6&fJR!7A=1!&!HL$Hy=6{_ljkKisQGNv8jK}DSlw`4h=nMGtNNJ zc?&J&^Jd3~!P9rYX?Q#?B2zbdgSKmOLG#ic5H;gKbecfVg z!1kk*xaIrcAfF~~ZBKBWtoAw+9DdetA@9uhvd}GS$r5myO^>%P;#ru36=8|hXDUnd zedrUZ3RJRoaqU}Rp@u0syXwzQyFrb%nx6`xb1&gAOd-Sdng%Rg&l}0S@+qt0RFgb0 z+BK98Y)XNAkDIM+9TFa^kS})ow=<#M5UxDryD=Lq_J?pg*YDW0+4plnO9c3ZI431y zUv?#|?!+ZlbqT_t68)RuJgpFDWngzktjDKw^Kq7nvw;Vt18rXxy^fN4KwQxhTQy4*+;EJ-Q^WE%`;&HF&1idJpQ?0kDJjc1 zY#MY)qilMjYlA~~hDxTJ9uW8iG^ej;}m9+;#svdiOi|VabkxS^~b(uT$b14Yo6l2ZJGAPi<7KRy^ERB zDuNZTx&$maS#Hco)mzuM_jPPg?nv$hp*W04Tf|d0xLB#GWkdD3|Qm-;*fF z(|1>UE$5fF-12f6#f6)-p)GyIs|exio*{kiK~2DMbSXoBr8GNyq}x{KWqfNP!QB)t zyWe^v-cLC>c-7_f4HH%>jh-cT;L){Jc|fPB`P~@bT7v85%mT#= zFgcH^7*deC#O+pqLe&qMZ^E%dY*(Sm!d%D#Ng1@5!1*i0KFaj4kx%!JY-`V&375%g ze^yCW11ITt>+x-AA?_#!-no5sB7h$=J4s#dMNefpVKoz`qT=g(KK9**cjEi z#5XsC-*Ya!D9J{YQ=>vA;rzO{=)qcIT&ZYxypr{PU!&nL7#62=qkyO>l0<^Kn9nDR)B7M6demdlnR8T4Pef3UtW3+N8v8TgwCFEV?II*gKpp;BhU& z`UG~ox~5dSvRFcxc)-pz5co(gZY~S^&Wl@U;QXr?!9Go}zFGm{qk>AZja~Ji_gCd? zM2p^EJ9Lr2?^b@}Ndn6d!EzYt4*VYJ!cq$+_TBZj!*yBdZw`X}pIMI=Y5ZETQ0<8hLJoJ$I@TYp`3qg^mQ$Xd8g{kCnE0QMX`kH}{n_EJDndN#RwTi69w=M(DA2oW--drm;u;1!A1gR< zY80pg+RMMKWRExly+Qj+Vf)KRo=DK}t~rt9Nb#T%@H8+>^;-ktf@DCzOUF;>&?Qur zlOOg*Sap(sxfTJaMpfAwjcfstwucDih>JYSM}q+7ZASa4;O|_%Ww7>F=-q*!4`MD3 z^{@GWZ2KoP$|eapDR24Ml2Aq>18nZMAF?rJ${U+$!cA^v0{Oa4Vz%)c#E=X7jH?Ww zny)Q~DY=FH0<3ghk^*k^oYuz%2%DNIS?CB+X>ic6?9rzpCbyU1<~ z`O}$UK!Ml0vqW{H6WC|qGW+`17nsDrOgm{^F6oC1ylZ!-1EKFigr8(2`ICqxP`6%B z%)VF53wPbU2ylzB01Ay*AhXy2&2T?te+cjq^JezilqXti=;(4SKWxLhPISTuMw>E< z1t*lMoF?VnW~3>ao7Qk$t}>^8uvP?O*;2~sB!TZ*ti#z>gw}5UaCX2RZR+8(1tC_5 z*9JgqkKtXHJIx5T<=(nnkHuUkiwItMZbA}6ERQ$kiJ~VKG4>0K5jTXcOjog}4+GX#1 zz;g6-15%S}7_*IicBX(#NS{U_yfixbwJnD&oOfoYFXN1<#>dceFc zO?N$}2=}+ei6|E!fHzZsC^-9{3HL)XDM^6*?CE4R#bU|oObJ#EOrhN4I5A*?F{gL;w=6lQluBAAC?PBUWqPC}yul7NyeCN#g}HVhmd$k6}~;UBGSZp|KC z1t%1L?RN~dop#<|nbz3%@nKAZwShGT1^g@gAfF#l#9jzzlOC-fMuOvV|GASTrkMXO zG{K6_h{%@6oC7t+{nssgap{rb7m~gZ=X6L7xlg!(G(6D82XZ9Z3MGf3=bvK|5UE3; zNce)wvgv#hBzV=rP6`x|Za*9hNhF(YJnk literal 0 HcmV?d00001 diff --git a/dev_docs/key_concepts/saved_objects.mdx b/dev_docs/key_concepts/saved_objects.mdx new file mode 100644 index 0000000000000..d89342765c8f1 --- /dev/null +++ b/dev_docs/key_concepts/saved_objects.mdx @@ -0,0 +1,74 @@ +--- +id: kibDevDocsSavedObjectsIntro +slug: /kibana-dev-docs/saved-objects-intro +title: Saved Objects +summary: Saved Objects are a key concept to understand when building a Kibana plugin. +date: 2021-02-02 +tags: ['kibana','dev', 'contributor', 'api docs'] +--- + +"Saved Objects" are developer defined, persisted entities, stored in the Kibana system index (which is also sometimes referred to as the `.kibana` index). +The Saved Objects service allows Kibana plugins to use Elasticsearch like a primary database. Think of it as an Object Document Mapper for Elasticsearch. + Some examples of Saved Object types are dashboards, lens, canvas workpads, index patterns, cases, ml jobs, and advanced settings. Some Saved Object types are + exposed to the user in the [Saved Object management UI](https://www.elastic.co/guide/en/kibana/current/managing-saved-objects.html), but not all. + +Developers create and manage their Saved Objects using the SavedObjectClient, while other data in Elasticsearch should be accessed via the data plugin's search +services. + +![image](../assets/saved_object_vs_data_indices.png) + + + + +## References + +In order to support import and export, and space-sharing capabilities, Saved Objects need to explicitly list any references they contain to other Saved Objects. +The parent should have a reference to it's children, not the other way around. That way when a "parent" is exported (or shared to a space), + all the "children" will be automatically included. However, when a "child" is exported, it will not include all "parents". + + + +## Migrations and Backward compatibility + +As your plugin evolves, you may need to change your Saved Object type in a breaking way (for example, changing the type of an attribtue, or removing +an attribute). If that happens, you should write a migration to upgrade the Saved Objects that existed prior to the change. + +. + +## Security + +Saved Objects can be secured using Kibana's Privileges model, unlike data that comes from data indices, which is secured using Elasticsearch's Privileges model. + +### Space awareness + +Saved Objects are "space aware". They exist in the space they were created in, and any spaces they have been shared with. + +### Feature controls and RBAC + +Feature controls provide another level of isolation and shareability for Saved Objects. Admins can give users and roles read, write or none permissions for each Saved Object type. + +### Object level security (OLS) + +OLS is an oft-requested feature that is not implemented yet. When it is, it will provide users with even more sharing and privacy flexibility. Individual +objects can be private to the user, shared with a selection of others, or made public. Much like how sharing Google Docs works. + +## Scalability + +By default all saved object types go into a single index. If you expect your saved object type to have a lot of unique fields, or if you expect there +to be many of them, you can have your objects go in a separate index by using the `indexPattern` field. Reporting and task manager are two +examples of features that use this capability. + +## Searchability + +Because saved objects are stored in system indices, they cannot be searched like other data can. If you see the phrase “[X] as data” it is +referring to this searching limitation. Users will not be able to create custom dashboards using saved object data, like they would for data stored +in Elasticsearch data indices. + +## Saved Objects by value + +Sometimes Saved Objects end up persisted inside another Saved Object. We call these Saved Objects “by value”, as opposed to "by + reference". If an end user creates a visualization and adds it to a dashboard without saving it to the visualization + library, the data ends up nested inside the dashboard Saved Object. This helps keep the visualization library smaller. It also avoids + issues with edits propagating - since an entity can only exist in a single place. + Note that from the end user stand point, we don’t use these terms “by reference” and “by value”. + diff --git a/dev_docs/tutorials/saved_objects.mdx b/dev_docs/tutorials/saved_objects.mdx new file mode 100644 index 0000000000000..bd7d231218af1 --- /dev/null +++ b/dev_docs/tutorials/saved_objects.mdx @@ -0,0 +1,250 @@ +--- +id: kibDevTutorialSavedObject +slug: /kibana-dev-docs/tutorial/saved-objects +title: Register a new saved object type +summary: Learn how to register a new saved object type. +date: 2021-02-05 +tags: ['kibana','onboarding', 'dev', 'architecture', 'tutorials'] +--- + +Saved Object type definitions should be defined in their own `my_plugin/server/saved_objects` directory. + +The folder should contain a file per type, named after the snake_case name of the type, and an index.ts file exporting all the types. + +**src/plugins/my_plugin/server/saved_objects/dashboard_visualization.ts** + +```ts +import { SavedObjectsType } from 'src/core/server'; + +export const dashboardVisualization: SavedObjectsType = { + name: 'dashboard_visualization', [1] + hidden: false, + namespaceType: 'single', + mappings: { + dynamic: false, + properties: { + description: { + type: 'text', + }, + hits: { + type: 'integer', + }, + }, + }, + migrations: { + '1.0.0': migratedashboardVisualizationToV1, + '2.0.0': migratedashboardVisualizationToV2, + }, +}; +``` + +[1] Since the name of a Saved Object type forms part of the url path for the public Saved Objects HTTP API, +these should follow our API URL path convention and always be written as snake case. + +**src/plugins/my_plugin/server/saved_objects/index.ts** + +```ts +export { dashboardVisualization } from './dashboard_visualization'; +export { dashboard } from './dashboard'; +``` + +**src/plugins/my_plugin/server/plugin.ts** + +```ts +import { dashboard, dashboardVisualization } from './saved_objects'; + +export class MyPlugin implements Plugin { + setup({ savedObjects }) { + savedObjects.registerType(dashboard); + savedObjects.registerType(dashboardVisualization); + } +} +``` + +## Mappings + +Each Saved Object type can define its own Elasticsearch field mappings. Because multiple Saved Object +types can share the same index, mappings defined by a type will be nested under a top-level field that matches the type name. + +For example, the mappings defined by the dashboard_visualization Saved Object type: + +**src/plugins/my_plugin/server/saved_objects/dashboard_visualization.ts** + +```ts +import { SavedObjectsType } from 'src/core/server'; + +export const dashboardVisualization: SavedObjectsType = { + name: 'dashboard_visualization', + ... + mappings: { + properties: { + dynamic: false, + description: { + type: 'text', + }, + hits: { + type: 'integer', + }, + }, + }, + migrations: { ... }, +}; +``` + +Will result in the following mappings being applied to the .kibana index: + +```ts +{ + "mappings": { + "dynamic": "strict", + "properties": { + ... + "dashboard_vizualization": { + "dynamic": false, + "properties": { + "description": { + "type": "text", + }, + "hits": { + "type": "integer", + }, + }, + } + } + } +} +``` +Do not use field mappings like you would use data types for the columns of a SQL database. Instead, field mappings are analogous to a +SQL index. Only specify field mappings for the fields you wish to search on or query. By specifying `dynamic: false` + in any level of your mappings, Elasticsearch will accept and store any other fields even if they are not specified in your mappings. + +Since Elasticsearch has a default limit of 1000 fields per index, plugins should carefully consider the +fields they add to the mappings. Similarly, Saved Object types should never use `dynamic: true` as this can cause an arbitrary + amount of fields to be added to the .kibana index. + + ## References + +Declare by adding an id, type and name to the + `references` array. + +```ts +router.get( + { path: '/some-path', validate: false }, + async (context, req, res) => { + const object = await context.core.savedObjects.client.create( + 'dashboard', + { + title: 'my dashboard', + panels: [ + { visualization: 'vis1' }, [1] + ], + indexPattern: 'indexPattern1' + }, + { references: [ + { id: '...', type: 'visualization', name: 'vis1' }, + { id: '...', type: 'index_pattern', name: 'indexPattern1' }, + ] + } + ) + ... + } +); +``` +[1] Note how `dashboard.panels[0].visualization` stores the name property of the reference (not the id directly) to be able to uniquely +identify this reference. This guarantees that the id the reference points to always remains up to date. If a + visualization id was directly stored in `dashboard.panels[0].visualization` there is a risk that this id gets updated without + updating the reference in the references array. + +## Writing migrations + +Saved Objects support schema changes between Kibana versions, which we call migrations. Migrations are + applied when a Kibana installation is upgraded from one version to the next, when exports are imported via + the Saved Objects Management UI, or when a new object is created via the HTTP API. + +Each Saved Object type may define migrations for its schema. Migrations are specified by the Kibana version number, receive an input document, + and must return the fully migrated document to be persisted to Elasticsearch. + +Let’s say we want to define two migrations: - In version 1.1.0, we want to drop the subtitle field and append it to the title - In version + 1.4.0, we want to add a new id field to every panel with a newly generated UUID. + +First, the current mappings should always reflect the latest or "target" schema. Next, we should define a migration function for each step in the schema evolution: + +**src/plugins/my_plugin/server/saved_objects/dashboard_visualization.ts** + +```ts +import { SavedObjectsType, SavedObjectMigrationFn } from 'src/core/server'; +import uuid from 'uuid'; + +interface DashboardVisualizationPre110 { + title: string; + subtitle: string; + panels: Array<{}>; +} +interface DashboardVisualization110 { + title: string; + panels: Array<{}>; +} + +interface DashboardVisualization140 { + title: string; + panels: Array<{ id: string }>; +} + +const migrateDashboardVisualization110: SavedObjectMigrationFn< + DashboardVisualizationPre110, [1] + DashboardVisualization110 +> = (doc) => { + const { subtitle, ...attributesWithoutSubtitle } = doc.attributes; + return { + ...doc, [2] + attributes: { + ...attributesWithoutSubtitle, + title: `${doc.attributes.title} - ${doc.attributes.subtitle}`, + }, + }; +}; + +const migrateDashboardVisualization140: SavedObjectMigrationFn< + DashboardVisualization110, + DashboardVisualization140 +> = (doc) => { + const outPanels = doc.attributes.panels?.map((panel) => { + return { ...panel, id: uuid.v4() }; + }); + return { + ...doc, + attributes: { + ...doc.attributes, + panels: outPanels, + }, + }; +}; + +export const dashboardVisualization: SavedObjectsType = { + name: 'dashboard_visualization', [1] + /** ... */ + migrations: { + // Takes a pre 1.1.0 doc, and converts it to 1.1.0 + '1.1.0': migrateDashboardVisualization110, + + // Takes a 1.1.0 doc, and converts it to 1.4.0 + '1.4.0': migrateDashboardVisualization140, [3] + }, +}; +``` +[1] It is useful to define an interface for each version of the schema. This allows TypeScript to ensure that you are properly handling the input and output + types correctly as the schema evolves. + +[2] Returning a shallow copy is necessary to avoid type errors when using different types for the input and output shape. + +[3] Migrations do not have to be defined for every version. The version number of a migration must always be the earliest Kibana version + in which this migration was released. So if you are creating a migration which will + be part of the v7.10.0 release, but will also be backported and released as v7.9.3, the migration version should be: 7.9.3. + + Migrations should be written defensively, an exception in a migration function will prevent a Kibana upgrade from succeeding and will cause downtime for our users. + Having said that, if a + document is encountered that is not in the expected shape, migrations are encouraged to throw an exception to abort the upgrade. In most scenarios, it is better to + fail an upgrade than to silently ignore a corrupt document which can cause unexpected behaviour at some future point in time. + +It is critical that you have extensive tests to ensure that migrations behave as expected with all possible input documents. Given how simple it is to test all the branch +conditions in a migration function and the high impact of a bug in this code, there’s really no reason not to aim for 100% test code coverage. From fef8e5c4a27eb54e729adb5eaa4cb4bbda3edc5a Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Thu, 11 Feb 2021 13:09:46 -0800 Subject: [PATCH 27/53] [Core][SO] - Updating SO _find filter parser to take into consideration multi-fields (#90988) (#91195) This PR addresses the bug #90985 . Please see link for bug details. TLDR: SO _find filter does not take into consideration that filter string can refer to multi-fields which should be parsed differently. This addition adds to the helper method that checks if there are any errors in the filter formatting. --- .../service/lib/filter_utils.test.ts | 124 +++++++++++++++++- .../saved_objects/service/lib/filter_utils.ts | 22 +++- 2 files changed, 143 insertions(+), 3 deletions(-) diff --git a/src/core/server/saved_objects/service/lib/filter_utils.test.ts b/src/core/server/saved_objects/service/lib/filter_utils.test.ts index f6f8f88e84304..05a936db4bfee 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.test.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.test.ts @@ -9,7 +9,12 @@ // @ts-expect-error no ts import { esKuery } from '../../es_query'; -import { validateFilterKueryNode, validateConvertFilterToKueryNode } from './filter_utils'; +import { + validateFilterKueryNode, + validateConvertFilterToKueryNode, + fieldDefined, + hasFilterKeyError, +} from './filter_utils'; const mockMappings = { properties: { @@ -39,6 +44,18 @@ const mockMappings = { }, }, }, + bean: { + properties: { + canned: { + fields: { + text: { + type: 'text', + }, + }, + type: 'keyword', + }, + }, + }, alert: { properties: { actions: { @@ -90,6 +107,15 @@ describe('Filter Utils', () => { validateConvertFilterToKueryNode(['foo'], 'foo.attributes.title: "best"', mockMappings) ).toEqual(esKuery.fromKueryExpression('foo.title: "best"')); }); + test('Validate a multi-field KQL expression filter', () => { + expect( + validateConvertFilterToKueryNode( + ['bean'], + 'bean.attributes.canned.text: "best"', + mockMappings + ) + ).toEqual(esKuery.fromKueryExpression('bean.canned.text: "best"')); + }); test('Assemble filter kuery node saved object attributes with one saved object type', () => { expect( validateConvertFilterToKueryNode( @@ -485,4 +511,100 @@ describe('Filter Utils', () => { ]); }); }); + + describe('#hasFilterKeyError', () => { + test('Return no error if filter key is valid', () => { + const hasError = hasFilterKeyError('bean.attributes.canned.text', ['bean'], mockMappings); + + expect(hasError).toBeNull(); + }); + + test('Return error if key is not defined', () => { + const hasError = hasFilterKeyError(undefined, ['bean'], mockMappings); + + expect(hasError).toEqual( + 'The key is empty and needs to be wrapped by a saved object type like bean' + ); + }); + + test('Return error if key is null', () => { + const hasError = hasFilterKeyError(null, ['bean'], mockMappings); + + expect(hasError).toEqual( + 'The key is empty and needs to be wrapped by a saved object type like bean' + ); + }); + + test('Return error if key does not identify an SO wrapper', () => { + const hasError = hasFilterKeyError('beanattributescannedtext', ['bean'], mockMappings); + + expect(hasError).toEqual( + "This key 'beanattributescannedtext' need to be wrapped by a saved object type like bean" + ); + }); + + test('Return error if key does not match an SO type', () => { + const hasError = hasFilterKeyError('canned.attributes.bean.text', ['bean'], mockMappings); + + expect(hasError).toEqual('This type canned is not allowed'); + }); + + test('Return error if key does not match SO attribute structure', () => { + const hasError = hasFilterKeyError('bean.canned.text', ['bean'], mockMappings); + + expect(hasError).toEqual( + "This key 'bean.canned.text' does NOT match the filter proposition SavedObjectType.attributes.key" + ); + }); + + test('Return error if key matches SO attribute parent, not attribute itself', () => { + const hasError = hasFilterKeyError('alert.actions', ['alert'], mockMappings); + + expect(hasError).toEqual( + "This key 'alert.actions' does NOT match the filter proposition SavedObjectType.attributes.key" + ); + }); + + test('Return error if key refers to a non-existent attribute parent', () => { + const hasError = hasFilterKeyError('alert.not_a_key', ['alert'], mockMappings); + + expect(hasError).toEqual( + "This key 'alert.not_a_key' does NOT exist in alert saved object index patterns" + ); + }); + + test('Return error if key refers to a non-existent attribute', () => { + const hasError = hasFilterKeyError('bean.attributes.red', ['bean'], mockMappings); + + expect(hasError).toEqual( + "This key 'bean.attributes.red' does NOT exist in bean saved object index patterns" + ); + }); + }); + + describe('#fieldDefined', () => { + test('Return false if filter is using an non-existing key', () => { + const isFieldDefined = fieldDefined(mockMappings, 'foo.not_a_key'); + + expect(isFieldDefined).toBeFalsy(); + }); + + test('Return true if filter is using an existing key', () => { + const isFieldDefined = fieldDefined(mockMappings, 'foo.title'); + + expect(isFieldDefined).toBeTruthy(); + }); + + test('Return true if filter is using a default for a multi-field property', () => { + const isFieldDefined = fieldDefined(mockMappings, 'bean.canned'); + + expect(isFieldDefined).toBeTruthy(); + }); + + test('Return true if filter is using a non-default for a multi-field property', () => { + const isFieldDefined = fieldDefined(mockMappings, 'bean.canned.text'); + + expect(isFieldDefined).toBeTruthy(); + }); + }); }); diff --git a/src/core/server/saved_objects/service/lib/filter_utils.ts b/src/core/server/saved_objects/service/lib/filter_utils.ts index b81c7d3e0885a..54b0033c9fcbe 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.ts @@ -205,7 +205,25 @@ export const hasFilterKeyError = ( return null; }; -const fieldDefined = (indexMappings: IndexMapping, key: string) => { +export const fieldDefined = (indexMappings: IndexMapping, key: string): boolean => { const mappingKey = 'properties.' + key.split('.').join('.properties.'); - return get(indexMappings, mappingKey) != null; + const potentialKey = get(indexMappings, mappingKey); + + // If the `mappingKey` does not match a valid path, before returning null, + // we want to check and see if the intended path was for a multi-field + // such as `x.attributes.field.text` where `field` is mapped to both text + // and keyword + if (potentialKey == null) { + const propertiesAttribute = 'properties'; + const indexOfLastProperties = mappingKey.lastIndexOf(propertiesAttribute); + const fieldMapping = mappingKey.substr(0, indexOfLastProperties); + const fieldType = mappingKey.substr( + mappingKey.lastIndexOf(propertiesAttribute) + `${propertiesAttribute}.`.length + ); + const mapping = `${fieldMapping}fields.${fieldType}`; + + return get(indexMappings, mapping) != null; + } else { + return true; + } }; From 852ef0b643e3908e9c514c9fa090186efa9dd498 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Thu, 11 Feb 2021 13:12:52 -0800 Subject: [PATCH 28/53] [Time to Visualize] Adds functional tests for linking/unlinking panel from embeddable library (#89612) (#91066) # Conflicts: # .github/CODEOWNERS Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../actions/add_to_library_action.tsx | 2 +- .../apps/dashboard/embeddable_library.ts | 111 ++++++++++++++++++ test/functional/apps/dashboard/index.ts | 1 + .../services/dashboard/panel_actions.ts | 25 ++++ x-pack/test/functional/apps/lens/dashboard.ts | 38 ++++++ .../maps/embeddable/embeddable_library.js | 80 +++++++++++++ .../functional/apps/maps/embeddable/index.js | 1 + 7 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 test/functional/apps/dashboard/embeddable_library.ts create mode 100644 x-pack/test/functional/apps/maps/embeddable/embeddable_library.js diff --git a/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx b/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx index 5d384ed8ebd82..ef730e16bc5cf 100644 --- a/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx +++ b/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx @@ -22,7 +22,7 @@ import { NotificationsStart } from '../../services/core'; import { dashboardAddToLibraryAction } from '../../dashboard_strings'; import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..'; -export const ACTION_ADD_TO_LIBRARY = 'addToFromLibrary'; +export const ACTION_ADD_TO_LIBRARY = 'saveToLibrary'; export interface AddToLibraryActionContext { embeddable: IEmbeddable; diff --git a/test/functional/apps/dashboard/embeddable_library.ts b/test/functional/apps/dashboard/embeddable_library.ts new file mode 100644 index 0000000000000..20fe9aeb1387a --- /dev/null +++ b/test/functional/apps/dashboard/embeddable_library.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); + const esArchiver = getService('esArchiver'); + const find = getService('find'); + const kibanaServer = getService('kibanaServer'); + const testSubjects = getService('testSubjects'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const panelActions = getService('dashboardPanelActions'); + + describe('embeddable library', () => { + before(async () => { + await esArchiver.load('dashboard/current/kibana'); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.clickNewDashboard(); + }); + + it('unlink visualize panel from embeddable library', async () => { + // add heatmap panel from library + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.filterEmbeddableNames('Rendering Test: heatmap'); + await find.clickByButtonText('Rendering Test: heatmap'); + await dashboardAddPanel.closeAddPanel(); + + const originalPanel = await testSubjects.find('embeddablePanelHeading-RenderingTest:heatmap'); + await panelActions.unlinkFromLibary(originalPanel); + await testSubjects.existOrFail('unlinkPanelSuccess'); + + const updatedPanel = await testSubjects.find('embeddablePanelHeading-RenderingTest:heatmap'); + const libraryActionExists = await testSubjects.descendantExists( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + updatedPanel + ); + expect(libraryActionExists).to.be(false); + + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.filterEmbeddableNames('Rendering Test: heatmap'); + await find.existsByLinkText('Rendering Test: heatmap'); + await dashboardAddPanel.closeAddPanel(); + }); + + it('save visualize panel to embeddable library', async () => { + const originalPanel = await testSubjects.find('embeddablePanelHeading-RenderingTest:heatmap'); + await panelActions.saveToLibrary('Rendering Test: heatmap - copy', originalPanel); + await testSubjects.existOrFail('addPanelToLibrarySuccess'); + + const updatedPanel = await testSubjects.find( + 'embeddablePanelHeading-RenderingTest:heatmap-copy' + ); + const libraryActionExists = await testSubjects.descendantExists( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + updatedPanel + ); + expect(libraryActionExists).to.be(true); + }); + + it('unlink map panel from embeddable library', async () => { + // add map panel from library + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.filterEmbeddableNames('Rendering Test: geo map'); + await find.clickByButtonText('Rendering Test: geo map'); + await dashboardAddPanel.closeAddPanel(); + + const originalPanel = await testSubjects.find('embeddablePanelHeading-RenderingTest:geomap'); + await panelActions.unlinkFromLibary(originalPanel); + await testSubjects.existOrFail('unlinkPanelSuccess'); + + const updatedPanel = await testSubjects.find('embeddablePanelHeading-RenderingTest:geomap'); + const libraryActionExists = await testSubjects.descendantExists( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + updatedPanel + ); + expect(libraryActionExists).to.be(false); + + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.filterEmbeddableNames('Rendering Test: geo map'); + await find.existsByLinkText('Rendering Test: geo map'); + await dashboardAddPanel.closeAddPanel(); + }); + + it('save map panel to embeddable library', async () => { + const originalPanel = await testSubjects.find('embeddablePanelHeading-RenderingTest:geomap'); + await panelActions.saveToLibrary('Rendering Test: geo map - copy', originalPanel); + await testSubjects.existOrFail('addPanelToLibrarySuccess'); + + const updatedPanel = await testSubjects.find( + 'embeddablePanelHeading-RenderingTest:geomap-copy' + ); + const libraryActionExists = await testSubjects.descendantExists( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + updatedPanel + ); + expect(libraryActionExists).to.be(true); + }); + }); +} diff --git a/test/functional/apps/dashboard/index.ts b/test/functional/apps/dashboard/index.ts index 9332503539874..b71a89501fbf6 100644 --- a/test/functional/apps/dashboard/index.ts +++ b/test/functional/apps/dashboard/index.ts @@ -81,6 +81,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { // The dashboard_snapshot test below requires the timestamped URL which breaks the view_edit test. // If we don't use the timestamp in the URL, the colors in the charts will be different. loadTestFile(require.resolve('./dashboard_snapshots')); + loadTestFile(require.resolve('./embeddable_library')); }); // Each of these tests call initTests themselves, the way it was originally written. The above tests only load diff --git a/test/functional/services/dashboard/panel_actions.ts b/test/functional/services/dashboard/panel_actions.ts index 534d4cebd92f4..881e3ad4157a4 100644 --- a/test/functional/services/dashboard/panel_actions.ts +++ b/test/functional/services/dashboard/panel_actions.ts @@ -17,6 +17,8 @@ const TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-togglePanel'; const CUSTOMIZE_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-ACTION_CUSTOMIZE_PANEL'; const OPEN_CONTEXT_MENU_ICON_DATA_TEST_SUBJ = 'embeddablePanelToggleMenuIcon'; const OPEN_INSPECTOR_TEST_SUBJ = 'embeddablePanelAction-openInspector'; +const LIBRARY_NOTIFICATION_TEST_SUBJ = 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION'; +const SAVE_TO_LIBRARY_TEST_SUBJ = 'embeddablePanelAction-saveToLibrary'; export function DashboardPanelActionsProvider({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); @@ -170,6 +172,29 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft await testSubjects.click(OPEN_INSPECTOR_TEST_SUBJ); } + async unlinkFromLibary(parent?: WebElementWrapper) { + log.debug('unlinkFromLibrary'); + const libraryNotification = parent + ? await testSubjects.findDescendant(LIBRARY_NOTIFICATION_TEST_SUBJ, parent) + : await testSubjects.find(LIBRARY_NOTIFICATION_TEST_SUBJ); + await libraryNotification.click(); + await testSubjects.click('libraryNotificationUnlinkButton'); + } + + async saveToLibrary(newTitle: string, parent?: WebElementWrapper) { + log.debug('saveToLibrary'); + await this.openContextMenu(parent); + const exists = await testSubjects.exists(SAVE_TO_LIBRARY_TEST_SUBJ); + if (!exists) { + await this.clickContextMenuMoreItem(); + } + await testSubjects.click(SAVE_TO_LIBRARY_TEST_SUBJ); + await testSubjects.setValue('savedObjectTitle', newTitle, { + clearWithKeyboard: true, + }); + await testSubjects.click('confirmSaveSavedObjectButton'); + } + async expectExistsRemovePanelAction() { log.debug('expectExistsRemovePanelAction'); await this.expectExistsPanelAction(REMOVE_PANEL_DATA_TEST_SUBJ); diff --git a/x-pack/test/functional/apps/lens/dashboard.ts b/x-pack/test/functional/apps/lens/dashboard.ts index 738e45c1cbcf1..5cbd5dff45e1e 100644 --- a/x-pack/test/functional/apps/lens/dashboard.ts +++ b/x-pack/test/functional/apps/lens/dashboard.ts @@ -156,5 +156,43 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await panelActions.clickContextMenuMoreItem(); await testSubjects.existOrFail(ACTION_TEST_SUBJ); }); + + it('unlink lens panel from embeddable library', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.filterEmbeddableNames('lnsPieVis'); + await find.clickByButtonText('lnsPieVis'); + await dashboardAddPanel.closeAddPanel(); + + const originalPanel = await testSubjects.find('embeddablePanelHeading-lnsPieVis'); + await panelActions.unlinkFromLibary(originalPanel); + await testSubjects.existOrFail('unlinkPanelSuccess'); + + const updatedPanel = await testSubjects.find('embeddablePanelHeading-lnsPieVis'); + const libraryActionExists = await testSubjects.descendantExists( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + updatedPanel + ); + expect(libraryActionExists).to.be(false); + }); + + it('save lens panel to embeddable library', async () => { + const originalPanel = await testSubjects.find('embeddablePanelHeading-lnsPieVis'); + await panelActions.saveToLibrary('lnsPieVis - copy', originalPanel); + await testSubjects.click('confirmSaveSavedObjectButton'); + await testSubjects.existOrFail('addPanelToLibrarySuccess'); + + const updatedPanel = await testSubjects.find('embeddablePanelHeading-lnsPieVis-copy'); + const libraryActionExists = await testSubjects.descendantExists( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + updatedPanel + ); + expect(libraryActionExists).to.be(true); + + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.filterEmbeddableNames('lnsPieVis'); + await find.existsByLinkText('lnsPieVis'); + }); }); } diff --git a/x-pack/test/functional/apps/maps/embeddable/embeddable_library.js b/x-pack/test/functional/apps/maps/embeddable/embeddable_library.js new file mode 100644 index 0000000000000..40e73f0d8a763 --- /dev/null +++ b/x-pack/test/functional/apps/maps/embeddable/embeddable_library.js @@ -0,0 +1,80 @@ +/* + * 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'; + +export default function ({ getPageObjects, getService }) { + const find = getService('find'); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'maps', 'visualize']); + const kibanaServer = getService('kibanaServer'); + const security = getService('security'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const dashboardVisualizations = getService('dashboardVisualizations'); + + describe('maps in embeddable library', () => { + before(async () => { + await security.testUser.setRoles( + [ + 'test_logstash_reader', + 'global_maps_all', + 'geoshape_data_reader', + 'global_dashboard_all', + 'meta_for_geoshape_data_reader', + ], + false + ); + await kibanaServer.uiSettings.replace({ + defaultIndex: 'c698b940-e149-11e8-a35a-370a8516603a', + }); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await dashboardAddPanel.clickCreateNewLink(); + await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); + await PageObjects.visualize.clickMapsApp(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.maps.waitForLayersToLoad(); + await PageObjects.maps.clickSaveAndReturnButton(); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('save map panel to embeddable library', async () => { + await dashboardPanelActions.saveToLibrary('embeddable library map'); + await testSubjects.existOrFail('addPanelToLibrarySuccess'); + + const mapPanel = await testSubjects.find('embeddablePanelHeading-embeddablelibrarymap'); + const libraryActionExists = await testSubjects.descendantExists( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + mapPanel + ); + expect(libraryActionExists).to.be(true); + }); + + it('unlink map panel from embeddable library', async () => { + const originalPanel = await testSubjects.find('embeddablePanelHeading-embeddablelibrarymap'); + await dashboardPanelActions.unlinkFromLibary(originalPanel); + await testSubjects.existOrFail('unlinkPanelSuccess'); + + const updatedPanel = await testSubjects.find('embeddablePanelHeading-embeddablelibrarymap'); + const libraryActionExists = await testSubjects.descendantExists( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + updatedPanel + ); + expect(libraryActionExists).to.be(false); + + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.filterEmbeddableNames('embeddable library map'); + await find.existsByLinkText('embeddable library map'); + await dashboardAddPanel.closeAddPanel(); + }); + }); +} diff --git a/x-pack/test/functional/apps/maps/embeddable/index.js b/x-pack/test/functional/apps/maps/embeddable/index.js index 88d8cf2d7bd54..552f830e2a379 100644 --- a/x-pack/test/functional/apps/maps/embeddable/index.js +++ b/x-pack/test/functional/apps/maps/embeddable/index.js @@ -10,6 +10,7 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./add_to_dashboard')); loadTestFile(require.resolve('./save_and_return')); loadTestFile(require.resolve('./dashboard')); + loadTestFile(require.resolve('./embeddable_library')); loadTestFile(require.resolve('./embeddable_state')); loadTestFile(require.resolve('./tooltip_filter_actions')); }); From b121769bb6add89572d6b1bc26860e7b25d2653b Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Thu, 11 Feb 2021 16:14:26 -0500 Subject: [PATCH 29/53] [7.x] [Security Solution][Timeline] - Open Host & Network details in side panel (#90064) (#91190) # Conflicts: # x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx --- .../common/types/timeline/index.ts | 36 +- .../cases/components/case_view/index.test.tsx | 7 +- .../cases/components/case_view/index.tsx | 12 +- .../events_viewer/event_details_flyout.tsx | 106 -- .../events_viewer/events_viewer.test.tsx | 7 +- .../events_viewer/events_viewer.tsx | 8 +- .../common/components/events_viewer/index.tsx | 14 +- .../public/common/components/links/index.tsx | 31 +- .../overview_description_list/index.tsx | 26 + .../public/common/mock/global_state.ts | 2 +- .../public/common/mock/timeline_results.ts | 4 +- .../components/alerts_table/actions.test.tsx | 2 +- .../public/hosts/pages/details/index.tsx | 1 + .../details/__snapshots__/index.test.tsx.snap | 152 +++ .../network/components/details/index.test.tsx | 15 + .../network/components/details/index.tsx | 47 +- .../network/components/ip/index.test.tsx | 7 +- .../public/network/pages/details/index.tsx | 1 + .../__snapshots__/index.test.tsx.snap | 205 ++++ .../host_overview/endpoint_overview/index.tsx | 139 +-- .../components/host_overview/index.test.tsx | 42 +- .../components/host_overview/index.tsx | 46 +- .../field_renderers/field_renderers.tsx | 34 +- .../components/formatted_ip/index.tsx | 74 +- .../components/open_timeline/helpers.test.ts | 16 +- .../open_timeline/note_previews/index.tsx | 5 +- .../__snapshots__/index.test.tsx.snap | 1029 +++++++++++++++++ .../event_details/expandable_event.tsx} | 4 +- .../side_panel/event_details/index.tsx | 109 ++ .../event_details/translations.ts} | 14 - .../host_details/expandable_host.tsx | 94 ++ .../side_panel/host_details/index.tsx | 116 ++ .../components/side_panel/index.test.tsx | 204 ++++ .../timelines/components/side_panel/index.tsx | 120 ++ .../network_details/expandable_network.tsx | 134 +++ .../side_panel/network_details/index.tsx | 113 ++ .../timeline/body/actions/index.test.tsx | 6 +- .../timeline/body/actions/index.tsx | 9 +- .../body/events/event_column_view.test.tsx | 2 +- .../body/events/event_column_view.tsx | 10 +- .../timeline/body/events/stateful_event.tsx | 174 +-- .../body/events/stateful_event_context.tsx | 17 + .../components/timeline/body/index.test.tsx | 15 +- .../components/timeline/body/index.tsx | 4 + .../body/renderers/formatted_field.test.tsx | 8 +- .../timeline/body/renderers/host_name.tsx | 58 +- .../components/timeline/event_details.tsx | 85 -- .../timelines/components/timeline/index.tsx | 6 +- .../timeline/notes_tab_content/index.tsx | 20 +- .../timeline/notes_tab_content/selectors.ts | 2 +- .../__snapshots__/index.test.tsx.snap | 2 +- .../pinned_tab_content/index.test.tsx | 2 +- .../timeline/pinned_tab_content/index.tsx | 25 +- .../__snapshots__/index.test.tsx.snap | 4 +- .../timeline/query_tab_content/index.test.tsx | 4 +- .../timeline/query_tab_content/index.tsx | 42 +- .../containers/active_timeline_context.ts | 44 +- .../public/timelines/containers/index.tsx | 4 +- .../timelines/store/timeline/actions.ts | 14 +- .../timelines/store/timeline/defaults.ts | 2 +- .../timelines/store/timeline/epic.test.ts | 2 +- .../timeline/epic_local_storage.test.tsx | 4 +- .../timelines/store/timeline/helpers.ts | 30 +- .../public/timelines/store/timeline/model.ts | 7 +- .../timelines/store/timeline/reducer.test.ts | 2 +- .../timelines/store/timeline/reducer.ts | 32 +- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 68 files changed, 3000 insertions(+), 616 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/overview_description_list/index.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap rename x-pack/plugins/security_solution/public/timelines/components/{timeline/expandable_event/index.tsx => side_panel/event_details/expandable_event.tsx} (96%) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx rename x-pack/plugins/security_solution/public/timelines/components/{timeline/expandable_event/translations.tsx => side_panel/event_details/translations.ts} (77%) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/expandable_host.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/index.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/expandable_network.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/index.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event_context.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 26a30e7c8f239..cee8ccdea3e9e 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -14,6 +14,7 @@ import { success, success_count as successCount, } from '../../detection_engine/schemas/common/schemas'; +import { FlowTarget } from '../../search_strategy/security_solution/network'; import { PositiveInteger } from '../../detection_engine/schemas/types'; import { errorSchema } from '../../detection_engine/schemas/response/error_schema'; @@ -423,11 +424,38 @@ type EmptyObject = Record; export type TimelineExpandedEventType = | { - eventId: string; - indexName: string; + panelView?: 'eventDetail'; + params?: { + eventId: string; + indexName: string; + }; } | EmptyObject; -export type TimelineExpandedEvent = { - [tab in TimelineTabs]?: TimelineExpandedEventType; +export type TimelineExpandedHostType = + | { + panelView?: 'hostDetail'; + params?: { + hostName: string; + }; + } + | EmptyObject; + +export type TimelineExpandedNetworkType = + | { + panelView?: 'networkDetail'; + params?: { + ip: string; + flowTarget: FlowTarget; + }; + } + | EmptyObject; + +export type TimelineExpandedDetailType = + | TimelineExpandedEventType + | TimelineExpandedHostType + | TimelineExpandedNetworkType; + +export type TimelineExpandedDetail = { + [tab in TimelineTabs]?: TimelineExpandedDetailType; }; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index e74b66eeeb9f0..dc0ef9ad026a4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -615,7 +615,7 @@ describe('CaseView ', () => { type: 'x-pack/security_solution/local/timeline/CREATE_TIMELINE', payload: { columns: [], - expandedEvent: {}, + expandedDetail: {}, id: 'timeline-case', indexNames: [], show: false, @@ -661,9 +661,10 @@ describe('CaseView ', () => { .first() .simulate('click'); expect(mockDispatch).toHaveBeenCalledWith({ - type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT', + type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL', payload: { - event: { eventId: 'alert-id-1', indexName: 'alert-index-1' }, + panelView: 'eventDetail', + params: { eventId: 'alert-id-1', indexName: 'alert-index-1' }, timelineId: 'timeline-case', }, }); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index e690a01dca54b..0eaa867077a4a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -44,7 +44,7 @@ import { } from '../configure_cases/utils'; import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query'; import { buildAlertsQuery, getRuleIdsFromComments } from './helpers'; -import { EventDetailsFlyout } from '../../../common/components/events_viewer/event_details_flyout'; +import { DetailsPanel } from '../../../timelines/components/side_panel'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { TimelineId } from '../../../../common/types/timeline'; @@ -368,9 +368,10 @@ export const CaseComponent = React.memo( const showAlert = useCallback( (alertId: string, index: string) => { dispatch( - timelineActions.toggleExpandedEvent({ + timelineActions.toggleDetailPanel({ + panelView: 'eventDetail', timelineId: TimelineId.casePage, - event: { + params: { eventId: alertId, indexName: index, }, @@ -390,7 +391,7 @@ export const CaseComponent = React.memo( id: TimelineId.casePage, columns: [], indexNames: [], - expandedEvent: {}, + expandedDetail: {}, show: false, }) ); @@ -500,9 +501,10 @@ export const CaseComponent = React.memo( - diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx deleted file mode 100644 index 60418f3a2a080..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx +++ /dev/null @@ -1,106 +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 { some } from 'lodash/fp'; -import { EuiFlyout, EuiFlyoutHeader, EuiFlyoutBody } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; -import styled from 'styled-components'; -import deepEqual from 'fast-deep-equal'; -import { useDispatch } from 'react-redux'; - -import { BrowserFields, DocValueFields } from '../../containers/source'; -import { - ExpandableEvent, - ExpandableEventTitle, -} from '../../../timelines/components/timeline/expandable_event'; -import { useDeepEqualSelector } from '../../hooks/use_selector'; -import { useTimelineEventsDetails } from '../../../timelines/containers/details'; -import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline'; -import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; - -const StyledEuiFlyout = styled(EuiFlyout)` - z-index: ${({ theme }) => theme.eui.euiZLevel7}; -`; - -const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` - .euiFlyoutBody__overflow { - display: flex; - flex: 1; - overflow: hidden; - - .euiFlyoutBody__overflowContent { - flex: 1; - overflow: hidden; - padding: ${({ theme }) => `${theme.eui.paddingSizes.xs} ${theme.eui.paddingSizes.m} 64px`}; - } - } -`; - -interface EventDetailsFlyoutProps { - browserFields: BrowserFields; - docValueFields: DocValueFields[]; - timelineId: string; -} - -const EventDetailsFlyoutComponent: React.FC = ({ - browserFields, - docValueFields, - timelineId, -}) => { - const dispatch = useDispatch(); - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const expandedEvent = useDeepEqualSelector( - (state) => (getTimeline(state, timelineId) ?? timelineDefaults)?.expandedEvent?.query ?? {} - ); - - const handleClearSelection = useCallback(() => { - dispatch(timelineActions.toggleExpandedEvent({ timelineId })); - }, [dispatch, timelineId]); - - const [loading, detailsData] = useTimelineEventsDetails({ - docValueFields, - indexName: expandedEvent?.indexName ?? '', - eventId: expandedEvent?.eventId ?? '', - skip: !expandedEvent.eventId, - }); - - const isAlert = useMemo( - () => some({ category: 'signal', field: 'signal.rule.id' }, detailsData), - [detailsData] - ); - - if (!expandedEvent.eventId) { - return null; - } - - return ( - - - - - - - - - ); -}; - -export const EventDetailsFlyout = React.memo( - EventDetailsFlyoutComponent, - (prevProps, nextProps) => - deepEqual(prevProps.browserFields, nextProps.browserFields) && - deepEqual(prevProps.docValueFields, nextProps.docValueFields) && - prevProps.timelineId === nextProps.timelineId -); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 6dad6c439ce46..a37528fcb24d7 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -86,7 +86,6 @@ const eventsViewerDefaultProps = { deletedEventIds: [], docValueFields: [], end: to, - expandedEvent: {}, filters: [], id: TimelineId.detectionsPage, indexNames: mockIndexNames, @@ -100,7 +99,6 @@ const eventsViewerDefaultProps = { query: '', language: 'kql', }, - handleCloseExpandedEvent: jest.fn(), start: from, sort: [ { @@ -150,14 +148,15 @@ describe('EventsViewer', () => { expect(mockDispatch).toBeCalledTimes(2); expect(mockDispatch.mock.calls[1][0]).toEqual({ payload: { - event: { + panelView: 'eventDetail', + params: { eventId: 'yb8TkHYBRgU82_bJu_rY', indexName: 'auditbeat-7.10.1-2020.12.18-000001', }, tabType: 'query', timelineId: TimelineId.test, }, - type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT', + type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL', }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 254309aee906b..012c9a3a450c0 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -40,11 +40,7 @@ import { inputsModel } from '../../store'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; import { ExitFullScreen } from '../exit_full_screen'; import { useGlobalFullScreen } from '../../containers/use_full_screen'; -import { - TimelineExpandedEventType, - TimelineId, - TimelineTabs, -} from '../../../../common/types/timeline'; +import { TimelineId, TimelineTabs } from '../../../../common/types/timeline'; import { GraphOverlay } from '../../../timelines/components/graph_overlay'; import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../../../timelines/components/timeline/styles'; @@ -113,7 +109,6 @@ interface Props { deletedEventIds: Readonly; docValueFields: DocValueFields[]; end: string; - expandedEvent: TimelineExpandedEventType; filters: Filter[]; headerFilterGroup?: React.ReactNode; height?: number; @@ -141,7 +136,6 @@ const EventsViewerComponent: React.FC = ({ deletedEventIds, docValueFields, end, - expandedEvent, filters, headerFilterGroup, id, diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 2b5420674b89c..59dc756bb2b3e 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -21,7 +21,7 @@ import { InspectButtonContainer } from '../inspect'; import { useGlobalFullScreen } from '../../containers/use_full_screen'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useSourcererScope } from '../../containers/sourcerer'; -import { EventDetailsFlyout } from './event_details_flyout'; +import { DetailsPanel } from '../../../timelines/components/side_panel'; const DEFAULT_EVENTS_VIEWER_HEIGHT = 652; @@ -46,6 +46,11 @@ export interface OwnProps { type Props = OwnProps & PropsFromRedux; +/** + * The stateful events viewer component is the highest level component that is utilized across the security_solution pages layer where + * timeline is used BESIDES the flyout. The flyout makes use of the `EventsViewer` component which is a subcomponent here + * NOTE: As of writting, it is not used in the Case_View component + */ const StatefulEventsViewerComponent: React.FC = ({ createTimeline, columns, @@ -53,7 +58,6 @@ const StatefulEventsViewerComponent: React.FC = ({ deletedEventIds, deleteEventQuery, end, - expandedEvent, excludedRowRendererIds, filters, headerFilterGroup, @@ -114,7 +118,6 @@ const StatefulEventsViewerComponent: React.FC = ({ dataProviders={dataProviders!} deletedEventIds={deletedEventIds} end={end} - expandedEvent={expandedEvent} isLoadingIndexPattern={isLoadingIndexPattern} filters={globalFilters} headerFilterGroup={headerFilterGroup} @@ -133,9 +136,10 @@ const StatefulEventsViewerComponent: React.FC = ({ /> - @@ -155,7 +159,6 @@ const makeMapStateToProps = () => { dataProviders, deletedEventIds, excludedRowRendererIds, - expandedEvent, graphEventId, itemsPerPage, itemsPerPageOptions, @@ -168,7 +171,6 @@ const makeMapStateToProps = () => { columns, dataProviders, deletedEventIds, - expandedEvent: expandedEvent?.query ?? {}, excludedRowRendererIds, filters: getGlobalFiltersQuerySelector(state), id, diff --git a/x-pack/plugins/security_solution/public/common/components/links/index.tsx b/x-pack/plugins/security_solution/public/common/components/links/index.tsx index 49d739b3f6679..6b4148db2b1ee 100644 --- a/x-pack/plugins/security_solution/public/common/components/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/links/index.tsx @@ -55,10 +55,11 @@ export const LinkAnchor: React.FC = ({ children, ...props }) => ( ); // Internal Links -const HostDetailsLinkComponent: React.FC<{ children?: React.ReactNode; hostName: string }> = ({ - children, - hostName, -}) => { +const HostDetailsLinkComponent: React.FC<{ + children?: React.ReactNode; + hostName: string; + isButton?: boolean; +}> = ({ children, hostName, isButton }) => { const { formatUrl, search } = useFormatUrl(SecurityPageName.hosts); const { navigateToApp } = useKibana().services.application; const goToHostDetails = useCallback( @@ -71,7 +72,14 @@ const HostDetailsLinkComponent: React.FC<{ children?: React.ReactNode; hostName: [hostName, navigateToApp, search] ); - return ( + return isButton ? ( + + {children ? children : hostName} + + ) : ( ); }; + export const HostDetailsLink = React.memo(HostDetailsLinkComponent); const allowedUrlSchemes = ['http://', 'https://']; @@ -119,7 +128,8 @@ const NetworkDetailsLinkComponent: React.FC<{ children?: React.ReactNode; ip: string; flowTarget?: FlowTarget | FlowTargetSourceDest; -}> = ({ children, ip, flowTarget = FlowTarget.source }) => { + isButton?: boolean; +}> = ({ children, ip, flowTarget = FlowTarget.source, isButton }) => { const { formatUrl, search } = useFormatUrl(SecurityPageName.network); const { navigateToApp } = useKibana().services.application; const goToNetworkDetails = useCallback( @@ -132,7 +142,14 @@ const NetworkDetailsLinkComponent: React.FC<{ [flowTarget, ip, navigateToApp, search] ); - return ( + return isButton ? ( + + {children ? children : ip} + + ) : ( ( + + + +); diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 21e4ef6a46c8c..bfd25aa469c93 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -214,7 +214,7 @@ export const mockGlobalState: State = { description: '', eventIdToNoteIds: {}, excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, highlightedDropAndProviderId: '', historyIds: [], isFavorite: false, diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index 79486f773b1f2..351caa2df3e31 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -2109,7 +2109,7 @@ export const mockTimelineModel: TimelineModel = { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [ { $state: { @@ -2232,7 +2232,7 @@ export const defaultTimelineProps: CreateTimelineProps = { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index a2dbeedb3f016..3c3d79c0c518f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -156,7 +156,7 @@ describe('alert actions', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [ { $state: { diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx index 18ab93dbb340c..faa240f98e53e 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx @@ -151,6 +151,7 @@ const HostDetailsComponent: React.FC = ({ detailName, hostDeta docValueFields={docValueFields} id={id} inspect={inspect} + isInDetailsSidePanel={false} refetch={refetch} setQuery={setQuery} data={hostOverview as HostItem} diff --git a/x-pack/plugins/security_solution/public/network/components/details/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/details/__snapshots__/index.test.tsx.snap index ca2ce4ee921c7..c22c3bf680781 100644 --- a/x-pack/plugins/security_solution/public/network/components/details/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/details/__snapshots__/index.test.tsx.snap @@ -141,6 +141,158 @@ exports[`IP Overview Component rendering it renders the default IP Overview 1`] flowTarget="source" id="ipOverview" ip="10.10.10.10" + isInDetailsSidePanel={false} + isLoadingAnomaliesData={false} + loading={false} + narrowDateRange={[MockFunction]} + startDate="2019-06-15T06:00:00.000Z" + type="details" + updateFlowTargetAction={[MockFunction]} +/> +`; + +exports[`IP Overview Component rendering it renders the side panel IP overview 1`] = ` + { loading: false, id: 'ipOverview', ip: '10.10.10.10', + isInDetailsSidePanel: false, isLoadingAnomaliesData: false, narrowDateRange: (jest.fn() as unknown) as NarrowDateRange, startDate: '2019-06-15T06:00:00.000Z', @@ -76,5 +77,19 @@ describe('IP Overview Component', () => { expect(wrapper.find('IpOverview')).toMatchSnapshot(); }); + + test('it renders the side panel IP overview', () => { + const panelViewProps = { + ...mockProps, + isInDetailsSidePanel: true, + }; + const wrapper = shallow( + + + + ); + + expect(wrapper.find('IpOverview')).toMatchSnapshot(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/network/components/details/index.tsx b/x-pack/plugins/security_solution/public/network/components/details/index.tsx index 384fffc472e21..e263d49e22fc0 100644 --- a/x-pack/plugins/security_solution/public/network/components/details/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/details/index.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import { EuiFlexItem } from '@elastic/eui'; import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; import React from 'react'; @@ -27,39 +26,38 @@ import { whoisRenderer, } from '../../../timelines/components/field_renderers/field_renderers'; import * as i18n from './translations'; -import { DescriptionListStyled, OverviewWrapper } from '../../../common/components/page'; +import { OverviewWrapper } from '../../../common/components/page'; import { Loader } from '../../../common/components/loader'; import { Anomalies, NarrowDateRange } from '../../../common/components/ml/types'; import { AnomalyScores } from '../../../common/components/ml/score/anomaly_scores'; import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions'; import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect'; +import { OverviewDescriptionList } from '../../../common/components/overview_description_list'; export interface IpOverviewProps { + anomaliesData: Anomalies | null; + contextID?: string; // used to provide unique draggable context when viewing in the side panel data: NetworkDetailsStrategyResponse['networkDetails']; + endDate: string; flowTarget: FlowTarget; id: string; ip: string; - loading: boolean; + isInDetailsSidePanel: boolean; isLoadingAnomaliesData: boolean; - anomaliesData: Anomalies | null; + loading: boolean; + narrowDateRange: NarrowDateRange; startDate: string; - endDate: string; type: networkModel.NetworkType; - narrowDateRange: NarrowDateRange; } -const getDescriptionList = (descriptionList: DescriptionList[], key: number) => ( - - - -); - export const IpOverview = React.memo( ({ + contextID, id, ip, data, + isInDetailsSidePanel = false, // Rather than duplicate the component, alter the structure based on it's location loading, flowTarget, startDate, @@ -77,13 +75,14 @@ export const IpOverview = React.memo( title: i18n.LOCATION, description: locationRenderer( [`${flowTarget}.geo.city_name`, `${flowTarget}.geo.region_name`], - data + data, + contextID ), }, { title: i18n.AUTONOMOUS_SYSTEM, description: typeData - ? autonomousSystemRenderer(typeData.autonomousSystem, flowTarget) + ? autonomousSystemRenderer(typeData.autonomousSystem, flowTarget, contextID) : getEmptyTagValue(), }, ]; @@ -123,12 +122,13 @@ export const IpOverview = React.memo( title: i18n.HOST_ID, description: typeData && data.host - ? hostIdRenderer({ host: data.host, ipFilter: ip }) + ? hostIdRenderer({ host: data.host, ipFilter: ip, contextID }) : getEmptyTagValue(), }, { title: i18n.HOST_NAME, - description: typeData && data.host ? hostNameRenderer(data.host, ip) : getEmptyTagValue(), + description: + typeData && data.host ? hostNameRenderer(data.host, ip, contextID) : getEmptyTagValue(), }, ], [ @@ -139,12 +139,17 @@ export const IpOverview = React.memo( return ( - - - - {descriptionLists.map((descriptionList, index) => - getDescriptionList(descriptionList, index) + + {!isInDetailsSidePanel && ( + )} + {descriptionLists.map((descriptionList, index) => ( + + ))} {loading && ( { expect(wrapper.find('[data-test-subj="formatted-ip"]').first().text()).toEqual('10.1.2.3'); }); - test('it hyperlinks to the network/ip page', () => { + test('it dispalys a button which opens the network/ip side panel', () => { const wrapper = mount( @@ -53,8 +53,7 @@ describe('Port', () => { ); expect( - wrapper.find('[data-test-subj="draggable-truncatable-content"]').find('a').first().props() - .href - ).toEqual('/ip/10.1.2.3/source'); + wrapper.find('[data-test-subj="draggable-truncatable-content"]').find('a').first().text() + ).toEqual('10.1.2.3'); }); }); diff --git a/x-pack/plugins/security_solution/public/network/pages/details/index.tsx b/x-pack/plugins/security_solution/public/network/pages/details/index.tsx index 124b400d56e92..896eec39c125c 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/index.tsx @@ -147,6 +147,7 @@ const NetworkDetailsComponent: React.FC = () => { id={id} inspect={inspect} ip={ip} + isInDetailsSidePanel={false} data={networkDetails} anomaliesData={anomaliesData} loading={loading} diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/overview/components/host_overview/__snapshots__/index.test.tsx.snap index 47d45ab740dcf..5d7b2d5b85af6 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/__snapshots__/index.test.tsx.snap @@ -196,6 +196,211 @@ exports[`Host Summary Component rendering it renders the default Host Summary 1` endDate="2019-06-18T06:00:00.000Z" id="hostOverview" indexNames={Array []} + isInDetailsSidePanel={false} + isLoadingAnomaliesData={false} + loading={false} + narrowDateRange={[MockFunction]} + startDate="2019-06-15T06:00:00.000Z" +/> +`; + +exports[`Host Summary Component rendering it renders the panel view Host Summary 1`] = ` + ( - - - -); - -export const EndpointOverview = React.memo(({ data }) => { - const getDefaultRenderer = useCallback( - (fieldName: string, fieldData: EndpointFields, attrName: string) => ( - - ), - [] - ); - const descriptionLists: Readonly = useMemo( - () => [ - [ - { - title: i18n.ENDPOINT_POLICY, - description: - data != null && data.endpointPolicy != null ? data.endpointPolicy : getEmptyTagValue(), - }, - ], - [ - { - title: i18n.POLICY_STATUS, - description: - data != null && data.policyStatus != null ? ( - - {data.policyStatus} - - ) : ( - getEmptyTagValue() - ), - }, +export const EndpointOverview = React.memo( + ({ contextID, data, isInDetailsSidePanel = false }) => { + const getDefaultRenderer = useCallback( + (fieldName: string, fieldData: EndpointFields, attrName: string) => ( + + ), + [contextID] + ); + const descriptionLists: Readonly = useMemo( + () => [ + [ + { + title: i18n.ENDPOINT_POLICY, + description: + data != null && data.endpointPolicy != null + ? data.endpointPolicy + : getEmptyTagValue(), + }, + ], + [ + { + title: i18n.POLICY_STATUS, + description: + data != null && data.policyStatus != null ? ( + + {data.policyStatus} + + ) : ( + getEmptyTagValue() + ), + }, + ], + [ + { + title: i18n.SENSORVERSION, + description: + data != null && data.sensorVersion != null + ? getDefaultRenderer('sensorVersion', data, 'agent.version') + : getEmptyTagValue(), + }, + ], + [], // needs 4 columns for design ], - [ - { - title: i18n.SENSORVERSION, - description: - data != null && data.sensorVersion != null - ? getDefaultRenderer('sensorVersion', data, 'agent.version') - : getEmptyTagValue(), - }, - ], - [], // needs 4 columns for design - ], - [data, getDefaultRenderer] - ); + [data, getDefaultRenderer] + ); - return ( - <> - {descriptionLists.map((descriptionList, index) => getDescriptionList(descriptionList, index))} - - ); -}); + return ( + <> + {descriptionLists.map((descriptionList, index) => ( + + ))} + + ); + } +); EndpointOverview.displayName = 'EndpointOverview'; diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.test.tsx index 3292f0297fa2d..e1c12ac6383a6 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.test.tsx @@ -15,21 +15,39 @@ import { mockData } from './mock'; import { mockAnomalies } from '../../../common/components/ml/mock'; describe('Host Summary Component', () => { describe('rendering', () => { + const mockProps = { + anomaliesData: mockAnomalies, + data: mockData.Hosts.edges[0].node, + docValueFields: [], + endDate: '2019-06-18T06:00:00.000Z', + id: 'hostOverview', + indexNames: [], + isInDetailsSidePanel: false, + isLoadingAnomaliesData: false, + loading: false, + narrowDateRange: jest.fn(), + startDate: '2019-06-15T06:00:00.000Z', + }; + test('it renders the default Host Summary', () => { const wrapper = shallow( - + + + ); + + expect(wrapper.find('HostOverview')).toMatchSnapshot(); + }); + + test('it renders the panel view Host Summary', () => { + const panelViewProps = { + ...mockProps, + isInDetailsSidePanel: true, + }; + + const wrapper = shallow( + + ); diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx index 90dc681617328..de0d782b3ceb7 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; +import { EuiHorizontalRule } from '@elastic/eui'; import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; import { getOr } from 'lodash/fp'; @@ -27,7 +27,7 @@ import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; import { AnomalyScores } from '../../../common/components/ml/score/anomaly_scores'; import { Anomalies, NarrowDateRange } from '../../../common/components/ml/types'; -import { DescriptionListStyled, OverviewWrapper } from '../../../common/components/page'; +import { OverviewWrapper } from '../../../common/components/page'; import { FirstLastSeenHost, FirstLastSeenHostType, @@ -35,11 +35,14 @@ import { import * as i18n from './translations'; import { EndpointOverview } from './endpoint_overview'; +import { OverviewDescriptionList } from '../../../common/components/overview_description_list'; interface HostSummaryProps { + contextID?: string; // used to provide unique draggable context when viewing in the side panel data: HostItem; docValueFields: DocValueFields[]; id: string; + isInDetailsSidePanel: boolean; loading: boolean; isLoadingAnomaliesData: boolean; indexNames: string[]; @@ -49,19 +52,15 @@ interface HostSummaryProps { narrowDateRange: NarrowDateRange; } -const getDescriptionList = (descriptionList: DescriptionList[], key: number) => ( - - - -); - export const HostOverview = React.memo( ({ anomaliesData, + contextID, data, docValueFields, endDate, id, + isInDetailsSidePanel = false, // Rather than duplicate the component, alter the structure based on it's location isLoadingAnomaliesData, indexNames, loading, @@ -77,10 +76,10 @@ export const HostOverview = React.memo( ), - [] + [contextID] ); const column: DescriptionList[] = useMemo( @@ -162,7 +161,7 @@ export const HostOverview = React.memo( (ip != null ? : getEmptyTagValue())} /> ), @@ -198,17 +197,22 @@ export const HostOverview = React.memo( }, ], ], - [data, firstColumn, getDefaultRenderer] + [contextID, data, firstColumn, getDefaultRenderer] ); return ( <> - - - - {descriptionLists.map((descriptionList, index) => - getDescriptionList(descriptionList, index) + + {!isInDetailsSidePanel && ( + )} + {descriptionLists.map((descriptionList, index) => ( + + ))} {loading && ( ( {data.endpoint != null ? ( <> - - + + {loading && ( fieldNames.length > 0 && fieldNames.every((fieldName) => getOr(null, fieldName, data)) ? ( @@ -52,7 +53,9 @@ export const locationRenderer = ( {index ? ',\u00A0' : ''} @@ -71,13 +74,16 @@ export const dateRenderer = (timestamp?: string | null): React.ReactElement => ( export const autonomousSystemRenderer = ( as: AutonomousSystem, - flowTarget: FlowTarget + flowTarget: FlowTarget, + contextID?: string ): React.ReactElement => as && as.organization && as.organization.name && as.number ? ( @@ -85,7 +91,9 @@ export const autonomousSystemRenderer = ( {'/'} @@ -96,12 +104,14 @@ export const autonomousSystemRenderer = ( ); interface HostIdRendererTypes { + contextID?: string; host: HostEcs; ipFilter?: string; noLink?: boolean; } export const hostIdRenderer = ({ + contextID, host, ipFilter, noLink, @@ -110,7 +120,9 @@ export const hostIdRenderer = ({ <> {host.name && host.name[0] != null ? ( @@ -128,14 +140,20 @@ export const hostIdRenderer = ({ getEmptyTagValue() ); -export const hostNameRenderer = (host?: HostEcs, ipFilter?: string): React.ReactElement => +export const hostNameRenderer = ( + host?: HostEcs, + ipFilter?: string, + contextID?: string +): React.ReactElement => host && host.name && host.name[0] && host.ip && (!(ipFilter != null) || host.ip.includes(ipFilter)) ? ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx index a3ac543ac6682..e1331f1b496ba 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx @@ -6,9 +6,11 @@ */ import { isArray, isEmpty, isString, uniq } from 'lodash/fp'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useContext } from 'react'; +import { useDispatch } from 'react-redux'; import deepEqual from 'fast-deep-equal'; +import { FlowTarget } from '../../../../common/search_strategy/security_solution/network'; import { DragEffects, DraggableWrapper, @@ -16,13 +18,21 @@ import { import { escapeDataProviderId } from '../../../common/components/drag_and_drop/helpers'; import { Content } from '../../../common/components/draggables'; import { getOrEmptyTagFromValue } from '../../../common/components/empty_value'; -import { NetworkDetailsLink } from '../../../common/components/links'; import { parseQueryValue } from '../../../timelines/components/timeline/body/renderers/parse_query_value'; import { DataProvider, IS_OPERATOR, } from '../../../timelines/components/timeline/data_providers/data_provider'; import { Provider } from '../../../timelines/components/timeline/data_providers/provider'; +import { + TimelineExpandedDetailType, + TimelineId, + TimelineTabs, +} from '../../../../common/types/timeline'; +import { activeTimeline } from '../../containers/active_timeline_context'; +import { timelineActions } from '../../store/timeline'; +import { StatefulEventContext } from '../timeline/body/events/stateful_event_context'; +import { LinkAnchor } from '../../../common/components/links'; const getUniqueId = ({ contextId, @@ -128,22 +138,52 @@ const AddressLinksItemComponent: React.FC = ({ fieldName, truncate, }) => { - const key = useMemo( - () => - `address-links-draggable-wrapper-${getUniqueId({ - contextId, - eventId, - fieldName, - address, - })}`, - [address, contextId, eventId, fieldName] - ); + const key = `address-links-draggable-wrapper-${getUniqueId({ + contextId, + eventId, + fieldName, + address, + })}`; const dataProviderProp = useMemo( () => getDataProvider({ contextId, eventId, fieldName, address }), [address, contextId, eventId, fieldName] ); + const dispatch = useDispatch(); + const eventContext = useContext(StatefulEventContext); + + const openNetworkDetailsSidePanel = useCallback( + (e) => { + e.preventDefault(); + if (address && eventContext?.timelineID && eventContext?.tabType) { + const { tabType, timelineID } = eventContext; + const updatedExpandedDetail: TimelineExpandedDetailType = { + panelView: 'networkDetail', + params: { + ip: address, + flowTarget: fieldName.includes(FlowTarget.destination) + ? FlowTarget.destination + : FlowTarget.source, + }, + }; + + dispatch( + timelineActions.toggleDetailPanel({ + ...updatedExpandedDetail, + tabType, + timelineId: timelineID, + }) + ); + + if (timelineID === TimelineId.active && tabType === TimelineTabs.query) { + activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail }); + } + } + }, + [dispatch, eventContext, address, fieldName] + ); + const render = useCallback( (_props, _provided, snapshot) => snapshot.isDragging ? ( @@ -152,10 +192,16 @@ const AddressLinksItemComponent: React.FC = ({ ) : ( - + + {address} + ), - [address, dataProviderProp, fieldName] + [address, dataProviderProp, openNetworkDetailsSidePanel, fieldName] ); return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index b9a0df63e19af..cde1b705be98e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -294,7 +294,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -397,7 +397,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -500,7 +500,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -601,7 +601,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -740,7 +740,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -868,7 +868,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [ { $state: { @@ -1012,7 +1012,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -1115,7 +1115,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx index 76b53adc872e8..5581ea4e5c165 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx @@ -46,10 +46,11 @@ const ToggleEventDetailsButtonComponent: React.FC const handleClick = useCallback(() => { dispatch( - timelineActions.toggleExpandedEvent({ + timelineActions.toggleDetailPanel({ + panelView: 'eventDetail', tabType: TimelineTabs.notes, timelineId, - event: { + params: { eventId, indexName: existingIndexNames.join(','), }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..124c8012fd533 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -0,0 +1,1029 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Details Panel Component DetailsPanel: rendering it should not render the DetailsPanel if an expanded detail with a panelView, but not params have been set 1`] = ` + +`; + +exports[`Details Panel Component DetailsPanel: rendering it should not render the DetailsPanel if no expanded detail has been set in the reducer 1`] = ` + +`; + +exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should render the Details Panel when the panelView is set and the associated params are set 1`] = ` +.c0 { + -webkit-flex: 0; + -ms-flex: 0; + flex: 0; +} + + + + + + +
+ +
+ +
+
+ +
+ + + +
+
+
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should render the Event Details view in the Details Panel when the panelView is eventDetail and the eventId is set 1`] = `null`; + +exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should render the Event Details view of the Details Panel in the flyout when the panelView is eventDetail and the eventId is set 1`] = ` +Array [ + .c1 { + -webkit-flex: 0; + -ms-flex: 0; + flex: 0; +} + +.c2 .euiFlyoutBody__overflow { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; +} + +.c2 .euiFlyoutBody__overflow .euiFlyoutBody__overflowContent { + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; + padding: 4px 16px 64px; +} + +.c0 { + z-index: 7000; +} + + + + + +
+
+ + + + + + + +
+ + + +
+ +
+ +
+
+
+
+
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+
, + .c1 { + -webkit-flex: 0; + -ms-flex: 0; + flex: 0; +} + +.c2 .euiFlyoutBody__overflow { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; +} + +.c2 .euiFlyoutBody__overflow .euiFlyoutBody__overflowContent { + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; + padding: 4px 16px 64px; +} + +.c0 { + z-index: 7000; +} + + + + +
+
+ + + + + + + +
+ + + +
+ +
+ +
+
+
+
+
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
, + .c1 { + -webkit-flex: 0; + -ms-flex: 0; + flex: 0; +} + +.c2 .euiFlyoutBody__overflow { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; +} + +.c2 .euiFlyoutBody__overflow .euiFlyoutBody__overflowContent { + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; + padding: 4px 16px 64px; +} + +.c0 { + z-index: 7000; +} + +
+ + + + + + + +
+ + + +
+ +
+ +
+
+
+
+
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
, +] +`; + +exports[`Details Panel Component DetailsPanel:HostDetails: rendering it should render the Host Details view in the Details Panel when the panelView is hostDetail and the hostName is set 1`] = `null`; + +exports[`Details Panel Component DetailsPanel:NetworkDetails: rendering it should render the Network Details view in the Details Panel when the panelView is networkDetail and the ip is set 1`] = `null`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx rename to x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx index 159745c5a3f86..6e8238dfe4b25 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx @@ -21,7 +21,7 @@ import { import React, { useMemo, useState } from 'react'; import styled from 'styled-components'; -import { TimelineExpandedEventType, TimelineTabs } from '../../../../../common/types/timeline'; +import { TimelineTabs } from '../../../../../common/types/timeline'; import { BrowserFields } from '../../../../common/containers/source'; import { EventDetails, @@ -36,7 +36,7 @@ export type HandleOnEventClosed = () => void; interface Props { browserFields: BrowserFields; detailsData: TimelineEventsDetailsItem[] | null; - event: TimelineExpandedEventType; + event: { eventId: string; indexName: string }; isAlert: boolean; loading: boolean; messageHeight?: number; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx new file mode 100644 index 0000000000000..d8b9e7121f60d --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -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 { some } from 'lodash/fp'; +import { EuiFlyoutHeader, EuiFlyoutBody, EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; +import deepEqual from 'fast-deep-equal'; + +import { BrowserFields, DocValueFields } from '../../../../common/containers/source'; +import { ExpandableEvent, ExpandableEventTitle } from './expandable_event'; +import { useTimelineEventsDetails } from '../../../containers/details'; +import { TimelineTabs } from '../../../../../common/types/timeline'; + +const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` + .euiFlyoutBody__overflow { + display: flex; + flex: 1; + overflow: hidden; + + .euiFlyoutBody__overflowContent { + flex: 1; + overflow: hidden; + padding: ${({ theme }) => `${theme.eui.paddingSizes.xs} ${theme.eui.paddingSizes.m} 64px`}; + } + } +`; + +interface EventDetailsPanelProps { + browserFields: BrowserFields; + docValueFields: DocValueFields[]; + expandedEvent: { eventId: string; indexName: string }; + handleOnEventClosed: () => void; + isFlyoutView?: boolean; + tabType: TimelineTabs; + timelineId: string; +} + +const EventDetailsPanelComponent: React.FC = ({ + browserFields, + docValueFields, + expandedEvent, + handleOnEventClosed, + isFlyoutView, + tabType, + timelineId, +}) => { + const [loading, detailsData] = useTimelineEventsDetails({ + docValueFields, + indexName: expandedEvent.indexName ?? '', + eventId: expandedEvent.eventId ?? '', + skip: !expandedEvent.eventId, + }); + + const isAlert = some({ category: 'signal', field: 'signal.rule.id' }, detailsData); + + if (!expandedEvent?.eventId) { + return null; + } + + return isFlyoutView ? ( + <> + + + + + + + + ) : ( + <> + + + + + ); +}; + +export const EventDetailsPanel = React.memo( + EventDetailsPanelComponent, + (prevProps, nextProps) => + deepEqual(prevProps.browserFields, nextProps.browserFields) && + deepEqual(prevProps.docValueFields, nextProps.docValueFields) && + prevProps.timelineId === nextProps.timelineId +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts similarity index 77% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx rename to x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts index 234f3ac49e64d..2910e04747e39 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts @@ -14,13 +14,6 @@ export const MESSAGE = i18n.translate( } ); -export const COPY_TO_CLIPBOARD = i18n.translate( - 'xpack.securitySolution.timeline.expandableEvent.copyToClipboardToolTip', - { - defaultMessage: 'Copy to Clipboard', - } -); - export const CLOSE = i18n.translate( 'xpack.securitySolution.timeline.expandableEvent.closeEventDetailsLabel', { @@ -28,13 +21,6 @@ export const CLOSE = i18n.translate( } ); -export const EVENT = i18n.translate( - 'xpack.securitySolution.timeline.expandableEvent.eventToolTipTitle', - { - defaultMessage: 'Event', - } -); - export const EVENT_DETAILS_PLACEHOLDER = i18n.translate( 'xpack.securitySolution.timeline.expandableEvent.placeholder', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/expandable_host.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/expandable_host.tsx new file mode 100644 index 0000000000000..4e101e29bb484 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/expandable_host.tsx @@ -0,0 +1,94 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EuiTitle } from '@elastic/eui'; +import { HostDetailsLink } from '../../../../common/components/links'; +import { useGlobalTime } from '../../../../common/containers/use_global_time'; +import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { HostOverview } from '../../../../overview/components/host_overview'; +import { setAbsoluteRangeDatePicker } from '../../../../common/store/inputs/actions'; +import { HostItem } from '../../../../../common/search_strategy'; +import { AnomalyTableProvider } from '../../../../common/components/ml/anomaly/anomaly_table_provider'; +import { hostToCriteria } from '../../../../common/components/ml/criteria/host_to_criteria'; +import { scoreIntervalToDateTime } from '../../../../common/components/ml/score/score_interval_to_datetime'; +import { HostOverviewByNameQuery } from '../../../../hosts/containers/hosts/details'; + +interface ExpandableHostProps { + hostName: string; +} + +export const ExpandableHostDetailsTitle = ({ hostName }: ExpandableHostProps) => ( + +

+ {i18n.translate('xpack.securitySolution.timeline.sidePanel.hostDetails.title', { + defaultMessage: 'Host details', + })} + {`: ${hostName}`} +

+
+); + +export const ExpandableHostDetailsPageLink = ({ hostName }: ExpandableHostProps) => ( + + {i18n.translate('xpack.securitySolution.timeline.sidePanel.hostDetails.hostDetailsPageLink', { + defaultMessage: 'View details page', + })} + +); + +export const ExpandableHostDetails = ({ + contextID, + hostName, +}: ExpandableHostProps & { contextID: string }) => { + const { to, from, isInitializing } = useGlobalTime(); + const { docValueFields, selectedPatterns } = useSourcererScope(); + return ( + + {({ hostOverview, loading, id }) => ( + + {({ isLoadingAnomaliesData, anomaliesData }) => ( + { + const fromTo = scoreIntervalToDateTime(score, interval); + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }); + }} + /> + )} + + )} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/index.tsx new file mode 100644 index 0000000000000..39064cda16001 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/index.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable react/display-name */ + +import { + EuiFlexGroup, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlexItem, + EuiButtonIcon, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import styled from 'styled-components'; +import { + ExpandableHostDetails, + ExpandableHostDetailsPageLink, + ExpandableHostDetailsTitle, +} from './expandable_host'; + +const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` + .euiFlyoutBody__overflow { + display: flex; + flex: 1; + overflow-x: hidden; + overflow-y: scroll; + + .euiFlyoutBody__overflowContent { + flex: 1; + overflow-x: hidden; + overflow-y: scroll; + padding: ${({ theme }) => `${theme.eui.paddingSizes.xs} ${theme.eui.paddingSizes.m} 64px`}; + } + } +`; + +const StyledEuiFlexGroup = styled(EuiFlexGroup)` + flex: 0; +`; + +const StyledEuiFlexItem = styled(EuiFlexItem)` + &.euiFlexItem { + flex: 1 0 0; + overflow-y: scroll; + overflow-x: hidden; + } +`; + +const StyledEuiFlexButtonWrapper = styled(EuiFlexItem)` + align-self: flex-start; +`; + +interface HostDetailsProps { + contextID: string; + expandedHost: { hostName: string }; + handleOnHostClosed: () => void; + isFlyoutView?: boolean; +} + +export const HostDetailsPanel: React.FC = React.memo( + ({ contextID, expandedHost, handleOnHostClosed, isFlyoutView }) => { + const { hostName } = expandedHost; + + if (!hostName) { + return null; + } + + return isFlyoutView ? ( + <> + + + + + + + + + + + ) : ( + <> + + + + + + + + + + + + + + + + + + ); + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx new file mode 100644 index 0000000000000..71ab7f01ddd54 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx @@ -0,0 +1,204 @@ +/* + * 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 { mount } from 'enzyme'; +import React from 'react'; + +import '../../../common/mock/match_media'; +import { + apolloClientObservable, + mockGlobalState, + TestProviders, + SUB_PLUGINS_REDUCER, + kibanaObservable, + createSecuritySolutionStorageMock, +} from '../../../common/mock'; +import { createStore, State } from '../../../common/store'; +import { DetailsPanel } from './index'; +import { TimelineExpandedDetail, TimelineTabs } from '../../../../common/types/timeline'; +import { FlowTarget } from '../../../../common/search_strategy/security_solution/network'; + +describe('Details Panel Component', () => { + const state: State = { ...mockGlobalState }; + + const { storage } = createSecuritySolutionStorageMock(); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); + + const dataLessExpandedDetail = { + [TimelineTabs.query]: { + panelView: 'hostDetail', + params: {}, + }, + }; + + const hostExpandedDetail: TimelineExpandedDetail = { + [TimelineTabs.query]: { + panelView: 'hostDetail', + params: { + hostName: 'woohoo!', + }, + }, + }; + + const networkExpandedDetail: TimelineExpandedDetail = { + [TimelineTabs.query]: { + panelView: 'networkDetail', + params: { + ip: 'woohoo!', + flowTarget: FlowTarget.source, + }, + }, + }; + + const eventExpandedDetail: TimelineExpandedDetail = { + [TimelineTabs.query]: { + panelView: 'eventDetail', + params: { + eventId: 'my-id', + indexName: 'my-index', + }, + }, + }; + + const mockProps = { + browserFields: {}, + docValueFields: [], + handleOnPanelClosed: jest.fn(), + isFlyoutView: false, + tabType: TimelineTabs.query, + timelineId: 'test', + }; + + describe('DetailsPanel: rendering', () => { + beforeEach(() => { + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); + }); + + test('it should not render the DetailsPanel if no expanded detail has been set in the reducer', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('DetailsPanel')).toMatchSnapshot(); + }); + + test('it should not render the DetailsPanel if an expanded detail with a panelView, but not params have been set', () => { + state.timeline.timelineById.test.expandedDetail = dataLessExpandedDetail as TimelineExpandedDetail; // Casting as the dataless doesn't meet the actual type requirements + const wrapper = mount( + + + + ); + + expect(wrapper.find('DetailsPanel')).toMatchSnapshot(); + }); + }); + + describe('DetailsPanel:EventDetails: rendering', () => { + beforeEach(() => { + state.timeline.timelineById.test.expandedDetail = eventExpandedDetail; + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); + }); + + test('it should render the Details Panel when the panelView is set and the associated params are set', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('DetailsPanel')).toMatchSnapshot(); + }); + + test('it should render the Event Details view of the Details Panel in the flyout when the panelView is eventDetail and the eventId is set', () => { + const currentProps = { ...mockProps, isFlyoutView: true }; + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="timeline:details-panel:flyout"]')).toMatchSnapshot(); + }); + + test('it should render the Event Details view in the Details Panel when the panelView is eventDetail and the eventId is set', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('EventDetails')).toMatchSnapshot(); + }); + }); + + describe('DetailsPanel:HostDetails: rendering', () => { + beforeEach(() => { + state.timeline.timelineById.test.expandedDetail = hostExpandedDetail; + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); + }); + + test('it should render the Host Details view in the Details Panel when the panelView is hostDetail and the hostName is set', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('HostDetails')).toMatchSnapshot(); + }); + }); + + describe('DetailsPanel:NetworkDetails: rendering', () => { + beforeEach(() => { + state.timeline.timelineById.test.expandedDetail = networkExpandedDetail; + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); + }); + + test('it should render the Network Details view in the Details Panel when the panelView is networkDetail and the ip is set', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('NetworkDetails')).toMatchSnapshot(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx new file mode 100644 index 0000000000000..0482491562f57 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx @@ -0,0 +1,120 @@ +/* + * 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, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { EuiFlyout } from '@elastic/eui'; +import styled from 'styled-components'; +import { timelineActions, timelineSelectors } from '../../store/timeline'; +import { timelineDefaults } from '../../store/timeline/defaults'; +import { BrowserFields, DocValueFields } from '../../../common/containers/source'; +import { TimelineTabs } from '../../../../common/types/timeline'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { EventDetailsPanel } from './event_details'; +import { HostDetailsPanel } from './host_details'; +import { NetworkDetailsPanel } from './network_details'; + +const StyledEuiFlyout = styled(EuiFlyout)` + z-index: ${({ theme }) => theme.eui.euiZLevel7}; +`; + +interface DetailsPanelProps { + browserFields: BrowserFields; + docValueFields: DocValueFields[]; + handleOnPanelClosed?: () => void; + isFlyoutView?: boolean; + tabType?: TimelineTabs; + timelineId: string; +} + +/** + * This panel is used in both the main timeline as well as the flyouts on the host, detection, cases, and network pages. + * To prevent duplication the `isFlyoutView` prop is passed to determine the layout that should be used + * `tabType` defaults to query and `handleOnPanelClosed` defaults to unsetting the default query tab which is used for the flyout panel + */ +export const DetailsPanel = React.memo( + ({ + browserFields, + docValueFields, + handleOnPanelClosed, + isFlyoutView, + tabType, + timelineId, + }: DetailsPanelProps) => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const expandedDetail = useDeepEqualSelector((state) => { + return (getTimeline(state, timelineId) ?? timelineDefaults).expandedDetail; + }); + + // To be used primarily in the flyout scenario where we don't want to maintain the tabType + const defaultOnPanelClose = useCallback(() => { + dispatch(timelineActions.toggleDetailPanel({ timelineId })); + }, [dispatch, timelineId]); + + const activeTab = tabType ?? TimelineTabs.query; + const closePanel = useCallback(() => { + if (handleOnPanelClosed) handleOnPanelClosed(); + else defaultOnPanelClose(); + }, [defaultOnPanelClose, handleOnPanelClosed]); + + if (!expandedDetail) return null; + + const currentTabDetail = expandedDetail[activeTab]; + + if (!currentTabDetail?.panelView) return null; + + let visiblePanel = null; // store in variable to make return statement more readable + const contextID = `${timelineId}-${activeTab}`; + + if (currentTabDetail?.panelView === 'eventDetail' && currentTabDetail?.params?.eventId) { + visiblePanel = ( + + ); + } + + if (currentTabDetail?.panelView === 'hostDetail' && currentTabDetail?.params?.hostName) { + visiblePanel = ( + + ); + } + + if (currentTabDetail?.panelView === 'networkDetail' && currentTabDetail?.params?.ip) { + visiblePanel = ( + + ); + } + + return isFlyoutView ? ( + + {visiblePanel} + + ) : ( + visiblePanel + ); + } +); + +DetailsPanel.displayName = 'DetailsPanel'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/expandable_network.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/expandable_network.tsx new file mode 100644 index 0000000000000..b12b575681acf --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/expandable_network.tsx @@ -0,0 +1,134 @@ +/* + * 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 { EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { FlowTarget } from '../../../../../common/search_strategy'; +import { NetworkDetailsLink } from '../../../../common/components/links'; +import { IpOverview } from '../../../../network/components/details'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { useGlobalTime } from '../../../../common/containers/use_global_time'; +import { networkToCriteria } from '../../../../common/components/ml/criteria/network_to_criteria'; +import { scoreIntervalToDateTime } from '../../../../common/components/ml/score/score_interval_to_datetime'; +import { useKibana } from '../../../../common/lib/kibana'; +import { convertToBuildEsQuery } from '../../../../common/lib/keury'; +import { inputsSelectors } from '../../../../common/store'; +import { setAbsoluteRangeDatePicker } from '../../../../common/store/inputs/actions'; +import { OverviewEmpty } from '../../../../overview/components/overview_empty'; +import { esQuery } from '../../../../../../../../src/plugins/data/public'; +import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { useNetworkDetails } from '../../../../network/containers/details'; +import { networkModel } from '../../../../network/store'; +import { useAnomaliesTableData } from '../../../../common/components/ml/anomaly/use_anomalies_table_data'; + +interface ExpandableNetworkProps { + expandedNetwork: { ip: string; flowTarget: FlowTarget }; +} + +export const ExpandableNetworkDetailsTitle = ({ ip }: { ip: string }) => ( + +

+ {i18n.translate('xpack.securitySolution.timeline.sidePanel.networkDetails.title', { + defaultMessage: 'Network details', + })} + {`: ${ip}`} +

+
+); + +export const ExpandableNetworkDetailsPageLink = ({ + expandedNetwork: { ip, flowTarget }, +}: ExpandableNetworkProps) => ( + + {i18n.translate( + 'xpack.securitySolution.timeline.sidePanel.networkDetails.networkDetailsPageLink', + { + defaultMessage: 'View details page', + } + )} + +); + +export const ExpandableNetworkDetails = ({ + contextID, + expandedNetwork, +}: ExpandableNetworkProps & { contextID: string }) => { + const { ip, flowTarget } = expandedNetwork; + const dispatch = useDispatch(); + const { to, from, isInitializing } = useGlobalTime(); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); + + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); + + const type = networkModel.NetworkType.details; + const narrowDateRange = useCallback( + (score, interval) => { + const fromTo = scoreIntervalToDateTime(score, interval); + dispatch( + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }) + ); + }, + [dispatch] + ); + const { + services: { uiSettings }, + } = useKibana(); + + const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); + const filterQuery = convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), + indexPattern, + queries: [query], + filters, + }); + + const [loading, { id, networkDetails }] = useNetworkDetails({ + docValueFields, + skip: isInitializing, + filterQuery, + indexNames: selectedPatterns, + ip, + }); + + const [isLoadingAnomaliesData, anomaliesData] = useAnomaliesTableData({ + criteriaFields: networkToCriteria(ip, flowTarget), + startDate: from, + endDate: to, + skip: isInitializing, + }); + + return indicesExist ? ( + + ) : ( + + ); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/index.tsx new file mode 100644 index 0000000000000..e05c9435fc456 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/index.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable react/display-name */ + +import { + EuiFlexGroup, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlexItem, + EuiButtonIcon, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import styled from 'styled-components'; +import { FlowTarget } from '../../../../../common/search_strategy'; +import { + ExpandableNetworkDetailsTitle, + ExpandableNetworkDetailsPageLink, + ExpandableNetworkDetails, +} from './expandable_network'; + +const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` + .euiFlyoutBody__overflow { + display: flex; + flex: 1; + overflow-x: hidden; + overflow-y: scroll; + + .euiFlyoutBody__overflowContent { + flex: 1; + overflow-x: hidden; + overflow-y: scroll; + padding: ${({ theme }) => `${theme.eui.paddingSizes.xs} ${theme.eui.paddingSizes.m} 64px`}; + } + } +`; + +const StyledEuiFlexGroup = styled(EuiFlexGroup)` + flex: 0; +`; + +const StyledEuiFlexItem = styled(EuiFlexItem)` + &.euiFlexItem { + flex: 1 0 0; + overflow-y: scroll; + overflow-x: hidden; + } +`; + +const StyledEuiFlexButtonWrapper = styled(EuiFlexItem)` + align-self: flex-start; +`; + +interface NetworkDetailsProps { + contextID: string; + expandedNetwork: { ip: string; flowTarget: FlowTarget }; + handleOnNetworkClosed: () => void; + isFlyoutView?: boolean; +} + +export const NetworkDetailsPanel = React.memo( + ({ contextID, expandedNetwork, handleOnNetworkClosed, isFlyoutView }: NetworkDetailsProps) => { + const { ip } = expandedNetwork; + + return isFlyoutView ? ( + <> + + + + + + + + + + + ) : ( + <> + + + + + + + + + + + + + + + + + + ); + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx index 1ee5e39dfaa26..16e2b28a120d7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx @@ -30,10 +30,9 @@ describe('Actions', () => { ariaRowindex={2} checked={false} columnValues={'abc def'} - expanded={false} eventId="abc" loadingEventIds={[]} - onEventToggled={jest.fn()} + onEventDetailsPanelOpened={jest.fn()} onRowSelected={jest.fn()} showCheckboxes={true} /> @@ -52,9 +51,8 @@ describe('Actions', () => { checked={false} columnValues={'abc def'} eventId="abc" - expanded={false} loadingEventIds={[]} - onEventToggled={jest.fn()} + onEventDetailsPanelOpened={jest.fn()} onRowSelected={jest.fn()} showCheckboxes={false} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx index 2bbf793b9c78f..9ce27aa936783 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -20,10 +20,9 @@ interface Props { columnValues: string; checked: boolean; onRowSelected: OnRowSelected; - expanded: boolean; eventId: string; loadingEventIds: Readonly; - onEventToggled: () => void; + onEventDetailsPanelOpened: () => void; showCheckboxes: boolean; } @@ -33,10 +32,9 @@ const ActionsComponent: React.FC = ({ additionalActions, checked, columnValues, - expanded, eventId, loadingEventIds, - onEventToggled, + onEventDetailsPanelOpened, onRowSelected, showCheckboxes, }) => { @@ -78,9 +76,8 @@ const ActionsComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx index 9be338e6b44b3..abdfda3272d6a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx @@ -51,7 +51,7 @@ describe('EventColumnView', () => { loading: false, loadingEventIds: [], notesCount: 0, - onEventToggled: jest.fn(), + onEventDetailsPanelOpened: jest.fn(), onPinEvent: jest.fn(), onRowSelected: jest.fn(), onUnPinEvent: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index 0afb31984ee8e..9d7b76af25a59 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -42,12 +42,11 @@ interface Props { data: TimelineNonEcsData[]; ecsData: Ecs; eventIdToNoteIds: Readonly>; - expanded: boolean; isEventPinned: boolean; isEventViewer?: boolean; loadingEventIds: Readonly; notesCount: number; - onEventToggled: () => void; + onEventDetailsPanelOpened: () => void; onPinEvent: OnPinEvent; onRowSelected: OnRowSelected; onUnPinEvent: OnUnPinEvent; @@ -74,12 +73,11 @@ export const EventColumnView = React.memo( data, ecsData, eventIdToNoteIds, - expanded, isEventPinned = false, isEventViewer = false, loadingEventIds, notesCount, - onEventToggled, + onEventDetailsPanelOpened, onPinEvent, onRowSelected, onUnPinEvent, @@ -220,14 +218,12 @@ export const EventColumnView = React.memo( checked={Object.keys(selectedEventIds).includes(id)} columnValues={columnValues} onRowSelected={onRowSelected} - expanded={expanded} data-test-subj="actions" eventId={id} loadingEventIds={loadingEventIds} - onEventToggled={onEventToggled} + onEventDetailsPanelOpened={onEventDetailsPanelOpened} showCheckboxes={showCheckboxes} /> - = ({ }) => { const trGroupRef = useRef(null); const dispatch = useDispatch(); + // Store context in state rather than creating object in provider value={} to prevent re-renders caused by a new object being created + const [activeStatefulEventContext] = useState({ timelineID: timelineId, tabType }); const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const expandedEvent = useDeepEqualSelector( - (state) => - (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent[ - tabType ?? TimelineTabs.query - ] ?? {} + const expandedDetail = useDeepEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedDetail ?? {} ); + const hostName = useMemo(() => { + const hostNameArr = getMappedNonEcsValue({ data: event?.data, fieldName: 'host.name' }); + return hostNameArr && hostNameArr.length > 0 ? hostNameArr[0] : null; + }, [event?.data]); + + const hostIPAddresses = useMemo(() => { + const ipList = getMappedNonEcsValue({ data: event?.data, fieldName: 'host.ip' }); + return ipList; + }, [event?.data]); + + const activeTab = tabType ?? TimelineTabs.query; + const activeExpandedDetail = expandedDetail[activeTab]; + + const isDetailPanelExpanded: boolean = + (activeExpandedDetail?.panelView === 'eventDetail' && + activeExpandedDetail?.params?.eventId === event._id) || + (activeExpandedDetail?.panelView === 'hostDetail' && + activeExpandedDetail?.params?.hostName === hostName) || + (activeExpandedDetail?.panelView === 'networkDetail' && + activeExpandedDetail?.params?.ip && + hostIPAddresses?.includes(activeExpandedDetail?.params?.ip)) || + false; + const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []); const notesById = useDeepEqualSelector(getNotesByIds); const noteIds: string[] = eventIdToNoteIds[event._id] || emptyNotes; - const isExpanded = useMemo(() => expandedEvent && expandedEvent.eventId === event._id, [ - event._id, - expandedEvent, - ]); const notes: TimelineResultNote[] = useMemo( () => @@ -153,23 +177,28 @@ const StatefulEventComponent: React.FC = ({ [dispatch, timelineId] ); - const handleOnEventToggled = useCallback(() => { + const handleOnEventDetailPanelOpened = useCallback(() => { const eventId = event._id; const indexName = event._index!; + const updatedExpandedDetail: TimelineExpandedDetailType = { + panelView: 'eventDetail', + params: { + eventId, + indexName, + }, + }; + dispatch( - timelineActions.toggleExpandedEvent({ + timelineActions.toggleDetailPanel({ + ...updatedExpandedDetail, tabType, timelineId, - event: { - eventId, - indexName, - }, }) ); if (timelineId === TimelineId.active && tabType === TimelineTabs.query) { - activeTimeline.toggleExpandedEvent({ eventId, indexName }); + activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail }); } }, [dispatch, event._id, event._index, tabType, timelineId]); @@ -209,63 +238,64 @@ const StatefulEventComponent: React.FC = ({ ); return ( - - + + + - - - - + + + + - {RowRendererContent} - - + {RowRendererContent} + + + ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event_context.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event_context.tsx new file mode 100644 index 0000000000000..34abc06371aac --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event_context.tsx @@ -0,0 +1,17 @@ +/* + * 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 { TimelineTabs } from '../../../../../../common/types/timeline'; + +interface StatefulEventContext { + tabType: TimelineTabs | undefined; + timelineID: string; +} + +// This context is available to all children of the stateful_event component where the provider is currently set +export const StatefulEventContext = React.createContext(null); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 7decff8270736..723e4c3de5c27 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -240,14 +240,15 @@ describe('Body', () => { expect(mockDispatch).toBeCalledTimes(1); expect(mockDispatch.mock.calls[0][0]).toEqual({ payload: { - event: { + panelView: 'eventDetail', + params: { eventId: '1', indexName: undefined, }, tabType: 'query', timelineId: 'timeline-test', }, - type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT', + type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL', }); }); @@ -263,14 +264,15 @@ describe('Body', () => { expect(mockDispatch).toBeCalledTimes(1); expect(mockDispatch.mock.calls[0][0]).toEqual({ payload: { - event: { + panelView: 'eventDetail', + params: { eventId: '1', indexName: undefined, }, tabType: 'pinned', timelineId: 'timeline-test', }, - type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT', + type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL', }); }); @@ -286,14 +288,15 @@ describe('Body', () => { expect(mockDispatch).toBeCalledTimes(1); expect(mockDispatch.mock.calls[0][0]).toEqual({ payload: { - event: { + panelView: 'eventDetail', + params: { eventId: '1', indexName: undefined, }, tabType: 'notes', timelineId: 'timeline-test', }, - type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT', + type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL', }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 8aa1425bbe52d..4df6eb16ccb62 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -60,6 +60,10 @@ const EXTRA_WIDTH = 4; // px export type StatefulBodyProps = OwnProps & PropsFromRedux; +/** + * The Body component is used everywhere timeline is used within the security application. It is the highest level component + * that is shared across all implementations of the timeline. + */ export const BodyComponent = React.memo( ({ activePage, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.test.tsx index e97738d95e43f..9d716f8325cbc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.test.tsx @@ -243,7 +243,7 @@ describe('Events', () => { expect(wrapper.find('[data-test-subj="truncatable-message"]').exists()).toEqual(false); }); - test('it renders a hyperlink to the hosts details page when fieldName is host.name, and a hostname is provided', () => { + test('it renders a button to open the hosts details panel when fieldName is host.name, and a hostname is provided', () => { const wrapper = mount( { /> ); - expect(wrapper.find('[data-test-subj="host-details-link"]').exists()).toEqual(true); + expect(wrapper.find('[data-test-subj="host-details-button"]').exists()).toEqual(true); }); - test('it does NOT render a hyperlink to the hosts details page when fieldName is host.name, but a hostname is NOT provided', () => { + test('it does NOT render a button to open the hosts details panel when fieldName is host.name, but a hostname is NOT provided', () => { const wrapper = mount( { /> ); - expect(wrapper.find('[data-test-subj="host-details-link"]').exists()).toEqual(false); + expect(wrapper.find('[data-test-subj="host-details-button"]').exists()).toEqual(false); }); test('it renders placeholder text when fieldName is host.name, but a hostname is NOT provided', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx index 50ed97d5fd8b6..c57cfce3cebe6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx @@ -5,13 +5,21 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useContext } from 'react'; +import { useDispatch } from 'react-redux'; import { isString } from 'lodash/fp'; - +import { LinkAnchor } from '../../../../../common/components/links'; +import { + TimelineId, + TimelineTabs, + TimelineExpandedDetailType, +} from '../../../../../../common/types/timeline'; import { DefaultDraggable } from '../../../../../common/components/draggables'; import { getEmptyTagValue } from '../../../../../common/components/empty_value'; -import { HostDetailsLink } from '../../../../../common/components/links'; import { TruncatableText } from '../../../../../common/components/truncatable_text'; +import { StatefulEventContext } from '../events/stateful_event_context'; +import { activeTimeline } from '../../../../containers/active_timeline_context'; +import { timelineActions } from '../../../../store/timeline'; interface Props { contextId: string; @@ -21,18 +29,48 @@ interface Props { } const HostNameComponent: React.FC = ({ fieldName, contextId, eventId, value }) => { - const hostname = `${value}`; + const dispatch = useDispatch(); + const eventContext = useContext(StatefulEventContext); + const hostName = `${value}`; + + const openHostDetailsSidePanel = useCallback( + (e) => { + e.preventDefault(); + if (hostName && eventContext?.tabType && eventContext?.timelineID) { + const { timelineID, tabType } = eventContext; + const updatedExpandedDetail: TimelineExpandedDetailType = { + panelView: 'hostDetail', + params: { + hostName, + }, + }; + + dispatch( + timelineActions.toggleDetailPanel({ + ...updatedExpandedDetail, + timelineId: timelineID, + tabType, + }) + ); + + if (timelineID === TimelineId.active && tabType === TimelineTabs.query) { + activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail }); + } + } + }, + [dispatch, eventContext, hostName] + ); - return isString(value) && hostname.length > 0 ? ( + return isString(value) && hostName.length > 0 ? ( - - {value} - + + {hostName} + ) : ( getEmptyTagValue() diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx deleted file mode 100644 index 6b8381c54de01..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx +++ /dev/null @@ -1,85 +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 { some } from 'lodash/fp'; -import { EuiSpacer } from '@elastic/eui'; -import React, { useMemo } from 'react'; -import deepEqual from 'fast-deep-equal'; - -import { BrowserFields, DocValueFields } from '../../../common/containers/source'; -import { - ExpandableEvent, - ExpandableEventTitle, - HandleOnEventClosed, -} from '../../../timelines/components/timeline/expandable_event'; -import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; -import { useTimelineEventsDetails } from '../../containers/details'; -import { timelineSelectors } from '../../store/timeline'; -import { timelineDefaults } from '../../store/timeline/defaults'; -import { TimelineTabs } from '../../../../common/types/timeline'; - -interface EventDetailsProps { - browserFields: BrowserFields; - docValueFields: DocValueFields[]; - tabType: TimelineTabs; - timelineId: string; - handleOnEventClosed?: HandleOnEventClosed; -} - -const EventDetailsComponent: React.FC = ({ - browserFields, - docValueFields, - tabType, - timelineId, - handleOnEventClosed, -}) => { - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const expandedEvent = useDeepEqualSelector( - (state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent[tabType] ?? {} - ); - - const [loading, detailsData] = useTimelineEventsDetails({ - docValueFields, - indexName: expandedEvent.indexName!, - eventId: expandedEvent.eventId!, - skip: !expandedEvent.eventId, - }); - - const isAlert = useMemo( - () => some({ category: 'signal', field: 'signal.rule.id' }, detailsData), - [detailsData] - ); - - return ( - <> - - - - - ); -}; - -export const EventDetails = React.memo( - EventDetailsComponent, - (prevProps, nextProps) => - deepEqual(prevProps.browserFields, nextProps.browserFields) && - deepEqual(prevProps.docValueFields, nextProps.docValueFields) && - prevProps.timelineId === nextProps.timelineId && - prevProps.handleOnEventClosed === nextProps.handleOnEventClosed -); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index 6825940f93389..e4a40f7ba7d5a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -18,7 +18,7 @@ import { isTab } from '../../../common/components/accessibility/helpers'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { FlyoutHeader, FlyoutHeaderPanel } from '../flyout/header'; -import { TimelineType, TimelineTabs, TimelineId } from '../../../../common/types/timeline'; +import { TimelineType, TimelineId } from '../../../../common/types/timeline'; import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { activeTimeline } from '../../containers/active_timeline_context'; import { EVENTS_COUNT_BUTTON_CLASS_NAME, onTimelineTabKeyPressed } from './helpers'; @@ -69,9 +69,7 @@ const StatefulTimelineComponent: React.FC = ({ timelineId }) => { id: timelineId, columns: defaultHeaders, indexNames: selectedPatterns, - expandedEvent: { - [TimelineTabs.query]: activeTimeline.getExpandedEvent(), - }, + expandedDetail: activeTimeline.getExpandedDetail(), show: false, }) ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx index 9ed230fd1e202..0d32e790dab50 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx @@ -31,8 +31,8 @@ import { CREATED_BY, NOTES } from '../../notes/translations'; import { PARTICIPANTS } from '../../../../cases/translations'; import { NotePreviews } from '../../open_timeline/note_previews'; import { TimelineResultNote } from '../../open_timeline/types'; -import { EventDetails } from '../event_details'; import { getTimelineNoteSelector } from './selectors'; +import { DetailsPanel } from '../../side_panel'; const FullWidthFlexGroup = styled(EuiFlexGroup)` width: 100%; @@ -125,7 +125,7 @@ const NotesTabContentComponent: React.FC = ({ timelineId } const getTimelineNotes = useMemo(() => getTimelineNoteSelector(), []); const { createdBy, - expandedEvent, + expandedDetail, eventIdToNoteIds, noteIds, status: timelineStatus, @@ -162,22 +162,22 @@ const NotesTabContentComponent: React.FC = ({ timelineId } [dispatch, timelineId] ); - const handleOnEventClosed = useCallback(() => { - dispatch(timelineActions.toggleExpandedEvent({ tabType: TimelineTabs.notes, timelineId })); + const handleOnPanelClosed = useCallback(() => { + dispatch(timelineActions.toggleDetailPanel({ tabType: TimelineTabs.notes, timelineId })); }, [dispatch, timelineId]); - const EventDetailsContent = useMemo( + const DetailsPanelContent = useMemo( () => - expandedEvent != null && expandedEvent.eventId != null ? ( - ) : null, - [browserFields, docValueFields, expandedEvent, handleOnEventClosed, timelineId] + [browserFields, docValueFields, expandedDetail, handleOnPanelClosed, timelineId] ); const SidebarContent = useMemo( @@ -216,7 +216,7 @@ const NotesTabContentComponent: React.FC = ({ timelineId } - {EventDetailsContent ?? SidebarContent} + {DetailsPanelContent ?? SidebarContent} ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/selectors.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/selectors.ts index 84e39e5481afd..bc0317f4c4282 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/selectors.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/selectors.ts @@ -13,7 +13,7 @@ export const getTimelineNoteSelector = () => createSelector(timelineSelectors.selectTimeline, (timeline) => { return { createdBy: timeline.createdBy, - expandedEvent: timeline.expandedEvent?.notes ?? {}, + expandedDetail: timeline.expandedDetail ?? {}, eventIdToNoteIds: timeline?.eventIdToNoteIds ?? {}, noteIds: timeline.noteIds, status: timeline.status, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap index f5064ba66cf2f..e55c1cc8f0af3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap @@ -135,7 +135,7 @@ In other use cases the message field can be used to concatenate different values } onEventClosed={[MockFunction]} pinnedEventIds={Object {}} - showEventDetails={false} + showExpandedDetails={false} sort={ Array [ Object { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx index 56d53c5fecb96..2107969df22b8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx @@ -96,7 +96,7 @@ describe('PinnedTabContent', () => { itemsPerPageOptions: [5, 10, 20], sort, pinnedEventIds: {}, - showEventDetails: false, + showExpandedDetails: false, onEventClosed: jest.fn(), }; }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx index 98cc130a38de3..68461a7234d09 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx @@ -25,11 +25,11 @@ import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { timelineDefaults } from '../../../store/timeline/defaults'; import { useSourcererScope } from '../../../../common/containers/sourcerer'; import { TimelineModel } from '../../../store/timeline/model'; -import { EventDetails } from '../event_details'; -import { ToggleExpandedEvent } from '../../../store/timeline/actions'; +import { ToggleDetailPanel } from '../../../store/timeline/actions'; import { State } from '../../../../common/store'; import { calculateTotalPages } from '../helpers'; import { TimelineTabs } from '../../../../../common/types/timeline'; +import { DetailsPanel } from '../../side_panel'; const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` overflow-y: hidden; @@ -90,7 +90,7 @@ export const PinnedTabContentComponent: React.FC = ({ itemsPerPageOptions, pinnedEventIds, onEventClosed, - showEventDetails, + showExpandedDetails, sort, }) => { const { browserFields, docValueFields, loading: loadingSourcerer } = useSourcererScope( @@ -169,7 +169,7 @@ export const PinnedTabContentComponent: React.FC = ({ timerangeKind: undefined, }); - const handleOnEventClosed = useCallback(() => { + const handleOnPanelClosed = useCallback(() => { onEventClosed({ tabType: TimelineTabs.pinned, timelineId }); }, [timelineId, onEventClosed]); @@ -217,16 +217,16 @@ export const PinnedTabContentComponent: React.FC = ({ - {showEventDetails && ( + {showExpandedDetails && ( <> - @@ -242,7 +242,7 @@ const makeMapStateToProps = () => { const timeline: TimelineModel = getTimeline(state, timelineId) ?? timelineDefaults; const { columns, - expandedEvent, + expandedDetail, itemsPerPage, itemsPerPageOptions, pinnedEventIds, @@ -255,7 +255,8 @@ const makeMapStateToProps = () => { itemsPerPage, itemsPerPageOptions, pinnedEventIds, - showEventDetails: !!expandedEvent[TimelineTabs.pinned]?.eventId, + showExpandedDetails: + !!expandedDetail[TimelineTabs.pinned] && !!expandedDetail[TimelineTabs.pinned]?.panelView, sort, }; }; @@ -263,8 +264,8 @@ const makeMapStateToProps = () => { }; const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({ - onEventClosed: (args: ToggleExpandedEvent) => { - dispatch(timelineActions.toggleExpandedEvent(args)); + onEventClosed: (args: ToggleDetailPanel) => { + dispatch(timelineActions.toggleDetailPanel(args)); }, }); @@ -278,7 +279,7 @@ const PinnedTabContent = connector( (prevProps, nextProps) => prevProps.itemsPerPage === nextProps.itemsPerPage && prevProps.onEventClosed === nextProps.onEventClosed && - prevProps.showEventDetails === nextProps.showEventDetails && + prevProps.showExpandedDetails === nextProps.showExpandedDetails && prevProps.timelineId === nextProps.timelineId && deepEqual(prevProps.columns, nextProps.columns) && deepEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) && diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap index 4fbf7788d9122..0688a10b31eef 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap @@ -262,7 +262,7 @@ In other use cases the message field can be used to concatenate different values } end="2018-03-24T03:33:52.253Z" eventType="all" - expandedEvent={Object {}} + expandedDetail={Object {}} filters={Array []} isLive={false} itemsPerPage={5} @@ -278,7 +278,7 @@ In other use cases the message field can be used to concatenate different values onEventClosed={[MockFunction]} show={true} showCallOutUnauthorizedMsg={false} - showEventDetails={false} + showExpandedDetails={false} sort={ Array [ Object { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx index 882c0c90973b3..c7d27da64c650 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx @@ -96,9 +96,8 @@ describe('Timeline', () => { columns: defaultHeaders, dataProviders: mockDataProviders, end: endDate, - expandedEvent: {}, eventType: 'all', - showEventDetails: false, + expandedDetail: {}, filters: [], timelineId: TimelineId.test, isLive: false, @@ -108,6 +107,7 @@ describe('Timeline', () => { kqlQueryExpression: '', onEventClosed: jest.fn(), showCallOutUnauthorizedMsg: false, + showExpandedDetails: false, sort, start: startDate, status: TimelineStatus.active, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx index 25acd48916944..c61be4951db76 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx @@ -46,12 +46,12 @@ import { timelineDefaults } from '../../../../timelines/store/timeline/defaults' import { useSourcererScope } from '../../../../common/containers/sourcerer'; import { useTimelineEventsCountPortal } from '../../../../common/hooks/use_timeline_events_count'; import { TimelineModel } from '../../../../timelines/store/timeline/model'; -import { EventDetails } from '../event_details'; import { TimelineDatePickerLock } from '../date_picker_lock'; import { HideShowContainer } from '../styles'; import { useTimelineFullScreen } from '../../../../common/containers/use_full_screen'; import { activeTimeline } from '../../../containers/active_timeline_context'; -import { ToggleExpandedEvent } from '../../../store/timeline/actions'; +import { ToggleDetailPanel } from '../../../store/timeline/actions'; +import { DetailsPanel } from '../../side_panel'; const TimelineHeaderContainer = styled.div` margin-top: 6px; @@ -139,7 +139,7 @@ export const QueryTabContentComponent: React.FC = ({ dataProviders, end, eventType, - expandedEvent, + expandedDetail, filters, timelineId, isLive, @@ -150,7 +150,7 @@ export const QueryTabContentComponent: React.FC = ({ onEventClosed, show, showCallOutUnauthorizedMsg, - showEventDetails, + showExpandedDetails, start, status, sort, @@ -245,16 +245,17 @@ export const QueryTabContentComponent: React.FC = ({ timerangeKind, }); - const handleOnEventClosed = useCallback(() => { + const handleOnPanelClosed = useCallback(() => { onEventClosed({ tabType: TimelineTabs.query, timelineId }); - if (timelineId === TimelineId.active) { - activeTimeline.toggleExpandedEvent({ - eventId: expandedEvent.eventId!, - indexName: expandedEvent.indexName!, - }); + if ( + expandedDetail[TimelineTabs.query]?.panelView && + timelineId === TimelineId.active && + showExpandedDetails + ) { + activeTimeline.toggleExpandedDetail({}); } - }, [timelineId, onEventClosed, expandedEvent.eventId, expandedEvent.indexName]); + }, [onEventClosed, timelineId, expandedDetail, showExpandedDetails]); useEffect(() => { setIsTimelineLoading({ id: timelineId, isLoading: isQueryLoading || loadingSourcerer }); @@ -350,16 +351,16 @@ export const QueryTabContentComponent: React.FC = ({ - {showEventDetails && ( + {showExpandedDetails && ( <> - @@ -382,7 +383,7 @@ const makeMapStateToProps = () => { columns, dataProviders, eventType, - expandedEvent, + expandedDetail, filters, itemsPerPage, itemsPerPageOptions, @@ -406,7 +407,7 @@ const makeMapStateToProps = () => { dataProviders, eventType: eventType ?? 'raw', end: input.timerange.to, - expandedEvent: expandedEvent[TimelineTabs.query] ?? {}, + expandedDetail, filters: timelineFilter, timelineId, isLive: input.policy.kind === 'interval', @@ -415,8 +416,9 @@ const makeMapStateToProps = () => { kqlMode, kqlQueryExpression, showCallOutUnauthorizedMsg: getShowCallOutUnauthorizedMsg(state), - showEventDetails: !!expandedEvent[TimelineTabs.query]?.eventId, show, + showExpandedDetails: + !!expandedDetail[TimelineTabs.query] && !!expandedDetail[TimelineTabs.query]?.panelView, sort, start: input.timerange.from, status, @@ -437,8 +439,8 @@ const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({ }) ); }, - onEventClosed: (args: ToggleExpandedEvent) => { - dispatch(timelineActions.toggleExpandedEvent(args)); + onEventClosed: (args: ToggleDetailPanel) => { + dispatch(timelineActions.toggleDetailPanel(args)); }, }); @@ -460,7 +462,7 @@ const QueryTabContent = connector( prevProps.onEventClosed === nextProps.onEventClosed && prevProps.show === nextProps.show && prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg && - prevProps.showEventDetails === nextProps.showEventDetails && + prevProps.showExpandedDetails === nextProps.showExpandedDetails && prevProps.status === nextProps.status && prevProps.timelineId === nextProps.timelineId && prevProps.updateEventTypeAndIndexesName === nextProps.updateEventTypeAndIndexesName && diff --git a/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts b/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts index 190cf53689ec0..93e53fa544bbc 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { TimelineExpandedEventType } from '../../../common/types/timeline'; +import { + TimelineExpandedDetail, + TimelineExpandedDetailType, + TimelineTabs, +} from '../../../common/types/timeline'; import { TimelineEventsAllRequestOptions } from '../../../common/search_strategy/timeline'; import { TimelineArgs } from '.'; @@ -22,7 +26,7 @@ import { TimelineArgs } from '.'; class ActiveTimelineEvents { private _activePage: number = 0; - private _expandedEvent: TimelineExpandedEventType = {}; + private _expandedDetail: TimelineExpandedDetail = {}; private _pageName: string = ''; private _request: TimelineEventsAllRequestOptions | null = null; private _response: TimelineArgs | null = null; @@ -35,20 +39,40 @@ class ActiveTimelineEvents { this._activePage = activePage; } - getExpandedEvent() { - return this._expandedEvent; + getExpandedDetail() { + return this._expandedDetail; } - toggleExpandedEvent(expandedEvent: TimelineExpandedEventType) { - if (expandedEvent.eventId === this._expandedEvent.eventId) { - this._expandedEvent = {}; + toggleExpandedDetail(expandedDetail: TimelineExpandedDetailType) { + const queryTab = TimelineTabs.query; + const currentExpandedDetail = this._expandedDetail[queryTab]; + let isSameExpandedDetail; + + // Check if the stored details matches the incoming detail + if (currentExpandedDetail?.panelView === 'eventDetail') { + isSameExpandedDetail = + expandedDetail?.panelView === 'eventDetail' && + expandedDetail?.params?.eventId === currentExpandedDetail?.params?.eventId; + } else if (currentExpandedDetail?.panelView === 'hostDetail') { + isSameExpandedDetail = + expandedDetail?.panelView === 'hostDetail' && + expandedDetail?.params?.hostName === currentExpandedDetail?.params?.hostName; + } else if (currentExpandedDetail?.panelView === 'networkDetail') { + isSameExpandedDetail = + expandedDetail?.panelView === 'networkDetail' && + expandedDetail?.params?.ip === currentExpandedDetail?.params?.ip; + } + + // if so, unset it, otherwise set it + if (isSameExpandedDetail) { + this._expandedDetail = {}; } else { - this._expandedEvent = expandedEvent; + this._expandedDetail = { [queryTab]: { ...expandedDetail } }; } } - setExpandedEvent(expandedEvent: TimelineExpandedEventType) { - this._expandedEvent = expandedEvent; + setExpandedDetail(expandedDetail: TimelineExpandedDetail) { + this._expandedDetail = expandedDetail; } getPageName() { diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx index 57815a6d6bcd7..0d53d01fa7131 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -113,7 +113,7 @@ export const useTimelineEvents = ({ clearSignalsState(); if (id === TimelineId.active) { - activeTimeline.setExpandedEvent({}); + activeTimeline.setExpandedDetail({}); activeTimeline.setActivePage(newActivePage); } @@ -178,7 +178,7 @@ export const useTimelineEvents = ({ updatedAt: Date.now(), }; if (id === TimelineId.active) { - activeTimeline.setExpandedEvent({}); + activeTimeline.setExpandedDetail({}); activeTimeline.setPageName(pageName); activeTimeline.setRequest(request); activeTimeline.setResponse(newTimelineResponse); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index a38d81a68d1bf..c9e3c8305a30d 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -20,10 +20,10 @@ import { KqlMode, TimelineModel, ColumnHeaderOptions } from './model'; import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; import { TimelineEventsType, - TimelineExpandedEventType, + TimelineExpandedDetail, + TimelineExpandedDetailType, TimelineTypeLiteral, RowRendererId, - TimelineExpandedEvent, TimelineTabs, } from '../../../../common/types/timeline'; import { InsertTimeline } from './types'; @@ -38,12 +38,12 @@ export const addNoteToEvent = actionCreator<{ id: string; noteId: string; eventI 'ADD_NOTE_TO_EVENT' ); -export interface ToggleExpandedEvent { - event?: TimelineExpandedEventType; +export type ToggleDetailPanel = TimelineExpandedDetailType & { tabType?: TimelineTabs; timelineId: string; -} -export const toggleExpandedEvent = actionCreator('TOGGLE_EXPANDED_EVENT'); +}; + +export const toggleDetailPanel = actionCreator('TOGGLE_DETAIL_PANEL'); export const upsertColumn = actionCreator<{ column: ColumnHeaderOptions; @@ -67,7 +67,7 @@ export interface TimelineInput { end: string; }; excludedRowRendererIds?: RowRendererId[]; - expandedEvent?: TimelineExpandedEvent; + expandedDetail?: TimelineExpandedDetail; filters?: Filter[]; columns: ColumnHeaderOptions[]; itemsPerPage?: number; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts index aaaf369f7bd5c..44a5c05e398f1 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts @@ -25,7 +25,7 @@ export const timelineDefaults: SubsetTimelineModel & Pick { description: '', eventIdToNoteIds: {}, eventType: 'all', - expandedEvent: {}, + expandedDetail: {}, excludedRowRendererIds: [], highlightedDropAndProviderId: '', historyIds: [], diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx index 584d270d8bea4..3d92397f4ab50 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx @@ -82,7 +82,7 @@ describe('epicLocalStorage', () => { dataProviders: mockDataProviders, end: endDate, eventType: 'all', - expandedEvent: {}, + expandedDetail: {}, filters: [], isLive: false, itemsPerPage: 5, @@ -91,7 +91,7 @@ describe('epicLocalStorage', () => { kqlQueryExpression: '', onEventClosed: jest.fn(), showCallOutUnauthorizedMsg: false, - showEventDetails: false, + showExpandedDetails: false, start: startDate, status: TimelineStatus.active, sort, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index d5d60857abb9a..864e52fc377a0 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -8,6 +8,7 @@ import { getOr, omit, uniq, isEmpty, isEqualWith, union } from 'lodash/fp'; import uuid from 'uuid'; +import { ToggleDetailPanel } from './actions'; import { Filter } from '../../../../../../../src/plugins/data/public'; import { getColumnWidthFromType } from '../../../timelines/components/timeline/body/column_headers/helpers'; @@ -24,12 +25,13 @@ import { SerializedFilterQuery } from '../../../common/store/model'; import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; import { TimelineEventsType, - TimelineExpandedEvent, + TimelineExpandedDetail, TimelineTypeLiteral, TimelineType, RowRendererId, TimelineStatus, TimelineId, + TimelineTabs, } from '../../../../common/types/timeline'; import { normalizeTimeRange } from '../../../common/components/url_state/normalize_time_range'; @@ -144,7 +146,7 @@ export const addTimelineToStore = ({ }: AddTimelineParams): TimelineById => { if (shouldResetActiveTimelineContext(id, timelineById[id], timeline)) { activeTimeline.setActivePage(0); - activeTimeline.setExpandedEvent({}); + activeTimeline.setExpandedDetail({}); } return { ...timelineById, @@ -171,7 +173,7 @@ interface AddNewTimelineParams { end: string; }; excludedRowRendererIds?: RowRendererId[]; - expandedEvent?: TimelineExpandedEvent; + expandedDetail?: TimelineExpandedDetail; filters?: Filter[]; id: string; itemsPerPage?: number; @@ -192,7 +194,7 @@ export const addNewTimeline = ({ dataProviders = [], dateRange: maybeDateRange, excludedRowRendererIds = [], - expandedEvent = {}, + expandedDetail = {}, filters = timelineDefaults.filters, id, itemsPerPage = timelineDefaults.itemsPerPage, @@ -221,7 +223,7 @@ export const addNewTimeline = ({ columns, dataProviders, dateRange, - expandedEvent, + expandedDetail, excludedRowRendererIds, filters, itemsPerPage, @@ -1431,3 +1433,21 @@ export const updateExcludedRowRenderersIds = ({ }, }; }; + +export const updateTimelineDetailsPanel = (action: ToggleDetailPanel) => { + const { tabType } = action; + + const panelViewOptions = new Set(['eventDetail', 'hostDetail', 'networkDetail']); + const expandedTabType = tabType ?? TimelineTabs.query; + + return action.panelView && panelViewOptions.has(action.panelView) + ? { + [expandedTabType]: { + params: action.params ? { ...action.params } : {}, + panelView: action.panelView, + }, + } + : { + [expandedTabType]: {}, + }; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index cc9b47383e9c9..e5036efd41df4 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -14,7 +14,7 @@ import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline' import { SerializedFilterQuery } from '../../../common/store/types'; import type { TimelineEventsType, - TimelineExpandedEvent, + TimelineExpandedDetail, TimelineType, TimelineStatus, RowRendererId, @@ -63,7 +63,8 @@ export interface TimelineModel { eventIdToNoteIds: Record; /** A list of Ids of excluded Row Renderers */ excludedRowRendererIds: RowRendererId[]; - expandedEvent: TimelineExpandedEvent; + /** This holds the view information for the flyout when viewing timeline in a consuming view (i.e. hosts page) or the side panel in the primary timeline view */ + expandedDetail: TimelineExpandedDetail; filters?: Filter[]; /** When non-empty, display a graph view for this event */ graphEventId?: string; @@ -143,7 +144,7 @@ export type SubsetTimelineModel = Readonly< | 'eventType' | 'eventIdToNoteIds' | 'excludedRowRendererIds' - | 'expandedEvent' + | 'expandedDetail' | 'graphEventId' | 'highlightedDropAndProviderId' | 'historyIds' diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts index 346a82ed0da1d..c4988673f49b6 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts @@ -79,7 +79,7 @@ const basicTimeline: TimelineModel = { description: '', eventIdToNoteIds: {}, excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, highlightedDropAndProviderId: '', historyIds: [], id: 'foo', diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index 791100a8b9e2a..7271eafa14863 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -35,7 +35,7 @@ import { showCallOutUnauthorizedMsg, showTimeline, startTimelineSaving, - toggleExpandedEvent, + toggleDetailPanel, unPinEvent, updateAutoSaveMsg, updateColumns, @@ -99,11 +99,12 @@ import { updateSavedQuery, updateGraphEventId, updateFilters, + updateTimelineDetailsPanel, updateTimelineEventType, } from './helpers'; import { TimelineState, EMPTY_TIMELINE_BY_ID } from './types'; -import { TimelineType, TimelineTabs } from '../../../../common/types/timeline'; +import { TimelineType } from '../../../../common/types/timeline'; export const initialTimelineState: TimelineState = { timelineById: EMPTY_TIMELINE_BY_ID, @@ -130,6 +131,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) dataProviders, dateRange, excludedRowRendererIds, + expandedDetail = {}, show, columns, itemsPerPage, @@ -148,6 +150,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) dataProviders, dateRange, excludedRowRendererIds, + expandedDetail, filters, id, itemsPerPage, @@ -178,22 +181,19 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state, timelineById: addTimelineNoteToEvent({ id, noteId, eventId, timelineById: state.timelineById }), })) - .case(toggleExpandedEvent, (state, { tabType, timelineId, event = {} }) => { - const expandedTabType = tabType ?? TimelineTabs.query; - return { - ...state, - timelineById: { - ...state.timelineById, - [timelineId]: { - ...state.timelineById[timelineId], - expandedEvent: { - ...state.timelineById[timelineId].expandedEvent, - [expandedTabType]: event, - }, + .case(toggleDetailPanel, (state, action) => ({ + ...state, + timelineById: { + ...state.timelineById, + [action.timelineId]: { + ...state.timelineById[action.timelineId], + expandedDetail: { + ...state.timelineById[action.timelineId].expandedDetail, + ...updateTimelineDetailsPanel(action), }, }, - }; - }) + }, + })) .case(addProvider, (state, { id, provider }) => ({ ...state, timelineById: addTimelineProvider({ id, provider, timelineById: state.timelineById }), diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 790d9139252fa..11ad4ca4c1d30 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -19523,9 +19523,7 @@ "xpack.securitySolution.timeline.eventsTableAriaLabel": "イベント; {activePage}/{totalPages} ページ", "xpack.securitySolution.timeline.expandableEvent.alertTitleLabel": "アラートの詳細", "xpack.securitySolution.timeline.expandableEvent.closeEventDetailsLabel": "閉じる", - "xpack.securitySolution.timeline.expandableEvent.copyToClipboardToolTip": "クリップボードにコピー", "xpack.securitySolution.timeline.expandableEvent.eventTitleLabel": "イベントの詳細", - "xpack.securitySolution.timeline.expandableEvent.eventToolTipTitle": "イベント", "xpack.securitySolution.timeline.expandableEvent.messageTitle": "メッセージ", "xpack.securitySolution.timeline.expandableEvent.placeholder": "イベント詳細を表示するには、イベントを選択します", "xpack.securitySolution.timeline.fieldTooltip": "フィールド", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7e09374aff931..8a2a930f619fa 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -19569,9 +19569,7 @@ "xpack.securitySolution.timeline.eventsTableAriaLabel": "事件;第 {activePage} 页,共 {totalPages} 页", "xpack.securitySolution.timeline.expandableEvent.alertTitleLabel": "告警详情", "xpack.securitySolution.timeline.expandableEvent.closeEventDetailsLabel": "关闭", - "xpack.securitySolution.timeline.expandableEvent.copyToClipboardToolTip": "复制到剪贴板", "xpack.securitySolution.timeline.expandableEvent.eventTitleLabel": "事件详情", - "xpack.securitySolution.timeline.expandableEvent.eventToolTipTitle": "事件", "xpack.securitySolution.timeline.expandableEvent.messageTitle": "消息", "xpack.securitySolution.timeline.expandableEvent.placeholder": "选择事件以显示事件详情", "xpack.securitySolution.timeline.fieldTooltip": "字段", From b3c5abdc419421fb97d279815102ba0ffd048440 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 11 Feb 2021 16:18:49 -0500 Subject: [PATCH 30/53] [Uptime] Format `PingList` duration time as seconds when appropriate (#90703) (#91196) * Introduce new formatting logic for ping list, duration strings now converted to seconds when appropriate. * Handle singular plurality case. * Make limit for conversion 10 sec instead of 1 sec. * Switch conversion threshold back to one second, add tests. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Justin Kambic --- .../monitor/ping_list/ping_list.test.tsx | 20 ++++++++++- .../monitor/ping_list/ping_list.tsx | 35 ++++++++++++++++--- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx index b96b61d874330..bf5b0215e7d7a 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { PingList } from './ping_list'; +import { formatDuration, PingList } from './ping_list'; import { Ping, PingsResponse } from '../../../../common/runtime_types'; import { ExpandedRowMap } from '../../overview/monitor_list/types'; import { rowShouldExpand, toggleDetails } from './columns/expand_row'; @@ -185,5 +185,23 @@ describe('PingList component', () => { expect(rowShouldExpand(ping)).toBe(true); }); }); + + describe('formatDuration', () => { + it('returns zero for < 1 millisecond', () => { + expect(formatDuration(984)).toBe('0 ms'); + }); + + it('returns milliseconds string if < 1 seconds', () => { + expect(formatDuration(921_039)).toBe('921 ms'); + }); + + it('returns seconds string if > 1 second', () => { + expect(formatDuration(1_032_100)).toBe('1 second'); + }); + + it('rounds to closest second', () => { + expect(formatDuration(1_832_100)).toBe('2 seconds'); + }); + }); }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx index 110c46eca31d1..18bc5f5ec3ecb 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx @@ -34,6 +34,35 @@ export const SpanWithMargin = styled.span` const DEFAULT_PAGE_SIZE = 10; +// one second = 1 million micros +const ONE_SECOND_AS_MICROS = 1000000; + +// the limit for converting to seconds is >= 1 sec +const MILLIS_LIMIT = ONE_SECOND_AS_MICROS * 1; + +export const formatDuration = (durationMicros: number) => { + if (durationMicros < MILLIS_LIMIT) { + return i18n.translate('xpack.uptime.pingList.durationMsColumnFormatting', { + values: { millis: microsToMillis(durationMicros) }, + defaultMessage: '{millis} ms', + }); + } + const seconds = (durationMicros / ONE_SECOND_AS_MICROS).toFixed(0); + + // we format seconds with correct pulralization here and not for `ms` because it is much more likely users + // will encounter times of exactly '1' second. + if (seconds === '1') { + return i18n.translate('xpack.uptime.pingist.durationSecondsColumnFormatting.singular', { + values: { seconds }, + defaultMessage: '{seconds} second', + }); + } + return i18n.translate('xpack.uptime.pingist.durationSecondsColumnFormatting', { + values: { seconds }, + defaultMessage: '{seconds} seconds', + }); +}; + export const PingList = () => { const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageIndex, setPageIndex] = useState(0); @@ -135,11 +164,7 @@ export const PingList = () => { name: i18n.translate('xpack.uptime.pingList.durationMsColumnLabel', { defaultMessage: 'Duration', }), - render: (duration: number) => - i18n.translate('xpack.uptime.pingList.durationMsColumnFormatting', { - values: { millis: microsToMillis(duration) }, - defaultMessage: '{millis} ms', - }), + render: (duration: number) => formatDuration(duration), }, { field: 'error.type', From 094d8765bb55720310a860b63473cc04516a3d1e Mon Sep 17 00:00:00 2001 From: gchaps <33642766+gchaps@users.noreply.github.com> Date: Thu, 11 Feb 2021 13:27:42 -0800 Subject: [PATCH 31/53] [DOCS] Uses variable to refer to query profiler (#90976) (#91044) --- docs/dev-tools/searchprofiler/getting-started.asciidoc | 2 +- docs/user/dev-tools.asciidoc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/dev-tools/searchprofiler/getting-started.asciidoc b/docs/dev-tools/searchprofiler/getting-started.asciidoc index 7cd54db5562b7..ad73d03bcbfd8 100644 --- a/docs/dev-tools/searchprofiler/getting-started.asciidoc +++ b/docs/dev-tools/searchprofiler/getting-started.asciidoc @@ -2,7 +2,7 @@ [[profiler-getting-started]] === Getting Started -The {searchprofiler} is automatically enabled in {kib}. Open the main menu, click *Dev Tools*, then click *Search Profiler* +The {searchprofiler} is automatically enabled in {kib}. Open the main menu, click *Dev Tools*, then click *{searchprofiler}* to get started. {searchprofiler} displays the names of the indices searched, the shards in each index, diff --git a/docs/user/dev-tools.asciidoc b/docs/user/dev-tools.asciidoc index 0ee7fbc741e00..0c5bef489dd01 100644 --- a/docs/user/dev-tools.asciidoc +++ b/docs/user/dev-tools.asciidoc @@ -15,7 +15,7 @@ a| <> | Interact with the REST API of Elasticsearch, including sending requests and viewing API documentation. -a| <> +a| <> | Inspect and analyze your search queries. From 6d0fff842865ca0624c6ec3bbeaa15ed629677a6 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Thu, 11 Feb 2021 16:11:06 -0600 Subject: [PATCH 32/53] [build] Generate ironbank docker context (#89933) * [build] Generate ironbank docker context * replace download.json with hardening_manifest.yml * update dockerfile * rm jenkinsfile Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../os_packages/create_os_package_tasks.ts | 16 +- .../docker_generator/bundle_dockerfiles.ts | 19 +- .../resources/{ => base}/bin/kibana-docker | 0 .../resources/ironbank/LICENSE | 280 ++++++++++++++++++ .../tasks/os_packages/docker_generator/run.ts | 30 +- .../docker_generator/template_context.ts | 5 +- .../templates/{ => base}/Dockerfile | 0 .../templates/build_docker_sh.template.ts | 7 +- .../templates/dockerfile.template.ts | 6 +- .../templates/ironbank/Dockerfile | 77 +++++ .../templates/ironbank/README.md | 39 +++ .../templates/ironbank/hardening_manifest.yml | 58 ++++ 12 files changed, 509 insertions(+), 28 deletions(-) rename src/dev/build/tasks/os_packages/docker_generator/resources/{ => base}/bin/kibana-docker (100%) create mode 100644 src/dev/build/tasks/os_packages/docker_generator/resources/ironbank/LICENSE rename src/dev/build/tasks/os_packages/docker_generator/templates/{ => base}/Dockerfile (100%) create mode 100644 src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/Dockerfile create mode 100644 src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/README.md create mode 100644 src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/hardening_manifest.yml diff --git a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts index e18460d65a3d0..e37a61582c6a8 100644 --- a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts +++ b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts @@ -54,15 +54,13 @@ export const CreateDockerCentOS: Task = { async run(config, log, build) { await runDockerGenerator(config, log, build, { - ubi: false, - context: false, architecture: 'x64', + context: false, image: true, }); await runDockerGenerator(config, log, build, { - ubi: false, - context: false, architecture: 'aarch64', + context: false, image: true, }); }, @@ -74,9 +72,9 @@ export const CreateDockerUBI: Task = { async run(config, log, build) { if (!build.isOss()) { await runDockerGenerator(config, log, build, { - ubi: true, - context: false, architecture: 'x64', + context: false, + ubi: true, image: true, }); } @@ -88,7 +86,6 @@ export const CreateDockerContexts: Task = { async run(config, log, build) { await runDockerGenerator(config, log, build, { - ubi: false, context: true, image: false, }); @@ -99,6 +96,11 @@ export const CreateDockerContexts: Task = { context: true, image: false, }); + await runDockerGenerator(config, log, build, { + ironbank: true, + context: true, + image: false, + }); } }, }; diff --git a/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts b/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts index 7eeeaebe6e4be..a633e919cc5db 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts @@ -7,18 +7,18 @@ */ import { resolve } from 'path'; +import { readFileSync } from 'fs'; import { ToolingLog } from '@kbn/dev-utils'; +import Mustache from 'mustache'; import { compressTar, copyAll, mkdirp, write, Config } from '../../../lib'; import { dockerfileTemplate } from './templates'; import { TemplateContext } from './template_context'; export async function bundleDockerFiles(config: Config, log: ToolingLog, scope: TemplateContext) { - log.info( - `Generating kibana${scope.imageFlavor}${scope.ubiImageFlavor} docker build context bundle` - ); - const dockerFilesDirName = `kibana${scope.imageFlavor}${scope.ubiImageFlavor}-${scope.version}-docker-build-context`; + log.info(`Generating kibana${scope.imageFlavor} docker build context bundle`); + const dockerFilesDirName = `kibana${scope.imageFlavor}-${scope.version}-docker-build-context`; const dockerFilesBuildDir = resolve(scope.dockerBuildDir, dockerFilesDirName); const dockerFilesOutputDir = config.resolveFromTarget(`${dockerFilesDirName}.tar.gz`); @@ -38,6 +38,17 @@ export async function bundleDockerFiles(config: Config, log: ToolingLog, scope: // dockerfiles folder await copyAll(resolve(scope.dockerBuildDir, 'bin'), resolve(dockerFilesBuildDir, 'bin')); await copyAll(resolve(scope.dockerBuildDir, 'config'), resolve(dockerFilesBuildDir, 'config')); + if (scope.ironbank) { + await copyAll(resolve(scope.dockerBuildDir), resolve(dockerFilesBuildDir), { + select: ['LICENSE'], + }); + const templates = ['hardening_manifest.yml', 'README.md']; + for (const template of templates) { + const file = readFileSync(resolve(__dirname, 'templates/ironbank', template)); + const output = Mustache.render(file.toString(), scope); + await write(resolve(dockerFilesBuildDir, template), output); + } + } // Compress dockerfiles dir created inside // docker build dir as output it as a target diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker similarity index 100% rename from src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker rename to src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/ironbank/LICENSE b/src/dev/build/tasks/os_packages/docker_generator/resources/ironbank/LICENSE new file mode 100644 index 0000000000000..632c3abe22e9b --- /dev/null +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/ironbank/LICENSE @@ -0,0 +1,280 @@ +ELASTIC LICENSE AGREEMENT + +PLEASE READ CAREFULLY THIS ELASTIC LICENSE AGREEMENT (THIS "AGREEMENT"), WHICH +CONSTITUTES A LEGALLY BINDING AGREEMENT AND GOVERNS ALL OF YOUR USE OF ALL OF +THE ELASTIC SOFTWARE WITH WHICH THIS AGREEMENT IS INCLUDED ("ELASTIC SOFTWARE") +THAT IS PROVIDED IN OBJECT CODE FORMAT, AND, IN ACCORDANCE WITH SECTION 2 BELOW, +CERTAIN OF THE ELASTIC SOFTWARE THAT IS PROVIDED IN SOURCE CODE FORMAT. BY +INSTALLING OR USING ANY OF THE ELASTIC SOFTWARE GOVERNED BY THIS AGREEMENT, YOU +ARE ASSENTING TO THE TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE +WITH SUCH TERMS AND CONDITIONS, YOU MAY NOT INSTALL OR USE THE ELASTIC SOFTWARE +GOVERNED BY THIS AGREEMENT. IF YOU ARE INSTALLING OR USING THE SOFTWARE ON +BEHALF OF A LEGAL ENTITY, YOU REPRESENT AND WARRANT THAT YOU HAVE THE ACTUAL +AUTHORITY TO AGREE TO THE TERMS AND CONDITIONS OF THIS AGREEMENT ON BEHALF OF +SUCH ENTITY. + +Posted Date: April 20, 2018 + +This Agreement is entered into by and between Elasticsearch BV ("Elastic") and +You, or the legal entity on behalf of whom You are acting (as applicable, +"You"). + +1. OBJECT CODE END USER LICENSES, RESTRICTIONS AND THIRD PARTY OPEN SOURCE +SOFTWARE + + 1.1 Object Code End User License. Subject to the terms and conditions of + Section 1.2 of this Agreement, Elastic hereby grants to You, AT NO CHARGE and + for so long as you are not in breach of any provision of this Agreement, a + License to the Basic Features and Functions of the Elastic Software. + + 1.2 Reservation of Rights; Restrictions. As between Elastic and You, Elastic + and its licensors own all right, title and interest in and to the Elastic + Software, and except as expressly set forth in Sections 1.1, and 2.1 of this + Agreement, no other license to the Elastic Software is granted to You under + this Agreement, by implication, estoppel or otherwise. You agree not to: (i) + reverse engineer or decompile, decrypt, disassemble or otherwise reduce any + Elastic Software provided to You in Object Code, or any portion thereof, to + Source Code, except and only to the extent any such restriction is prohibited + by applicable law, (ii) except as expressly permitted in this Agreement, + prepare derivative works from, modify, copy or use the Elastic Software Object + Code or the Commercial Software Source Code in any manner; (iii) except as + expressly permitted in Section 1.1 above, transfer, sell, rent, lease, + distribute, sublicense, loan or otherwise transfer, Elastic Software Object + Code, in whole or in part, to any third party; (iv) use Elastic Software + Object Code for providing time-sharing services, any software-as-a-service, + service bureau services or as part of an application services provider or + other service offering (collectively, "SaaS Offering") where obtaining access + to the Elastic Software or the features and functions of the Elastic Software + is a primary reason or substantial motivation for users of the SaaS Offering + to access and/or use the SaaS Offering ("Prohibited SaaS Offering"); (v) + circumvent the limitations on use of Elastic Software provided to You in + Object Code format that are imposed or preserved by any License Key, or (vi) + alter or remove any Marks and Notices in the Elastic Software. If You have any + question as to whether a specific SaaS Offering constitutes a Prohibited SaaS + Offering, or are interested in obtaining Elastic's permission to engage in + commercial or non-commercial distribution of the Elastic Software, please + contact elastic_license@elastic.co. + + 1.3 Third Party Open Source Software. The Commercial Software may contain or + be provided with third party open source libraries, components, utilities and + other open source software (collectively, "Open Source Software"), which Open + Source Software may have applicable license terms as identified on a website + designated by Elastic. Notwithstanding anything to the contrary herein, use of + the Open Source Software shall be subject to the license terms and conditions + applicable to such Open Source Software, to the extent required by the + applicable licensor (which terms shall not restrict the license rights granted + to You hereunder, but may contain additional rights). To the extent any + condition of this Agreement conflicts with any license to the Open Source + Software, the Open Source Software license will govern with respect to such + Open Source Software only. Elastic may also separately provide you with + certain open source software that is licensed by Elastic. Your use of such + Elastic open source software will not be governed by this Agreement, but by + the applicable open source license terms. + +2. COMMERCIAL SOFTWARE SOURCE CODE + + 2.1 Limited License. Subject to the terms and conditions of Section 2.2 of + this Agreement, Elastic hereby grants to You, AT NO CHARGE and for so long as + you are not in breach of any provision of this Agreement, a limited, + non-exclusive, non-transferable, fully paid up royalty free right and license + to the Commercial Software in Source Code format, without the right to grant + or authorize sublicenses, to prepare Derivative Works of the Commercial + Software, provided You (i) do not hack the licensing mechanism, or otherwise + circumvent the intended limitations on the use of Elastic Software to enable + features other than Basic Features and Functions or those features You are + entitled to as part of a Subscription, and (ii) use the resulting object code + only for reasonable testing purposes. + + 2.2 Restrictions. Nothing in Section 2.1 grants You the right to (i) use the + Commercial Software Source Code other than in accordance with Section 2.1 + above, (ii) use a Derivative Work of the Commercial Software outside of a + Non-production Environment, in any production capacity, on a temporary or + permanent basis, or (iii) transfer, sell, rent, lease, distribute, sublicense, + loan or otherwise make available the Commercial Software Source Code, in whole + or in part, to any third party. Notwithstanding the foregoing, You may + maintain a copy of the repository in which the Source Code of the Commercial + Software resides and that copy may be publicly accessible, provided that you + include this Agreement with Your copy of the repository. + +3. TERMINATION + + 3.1 Termination. This Agreement will automatically terminate, whether or not + You receive notice of such Termination from Elastic, if You breach any of its + provisions. + + 3.2 Post Termination. Upon any termination of this Agreement, for any reason, + You shall promptly cease the use of the Elastic Software in Object Code format + and cease use of the Commercial Software in Source Code format. For the + avoidance of doubt, termination of this Agreement will not affect Your right + to use Elastic Software, in either Object Code or Source Code formats, made + available under the Apache License Version 2.0. + + 3.3 Survival. Sections 1.2, 2.2. 3.3, 4 and 5 shall survive any termination or + expiration of this Agreement. + +4. DISCLAIMER OF WARRANTIES AND LIMITATION OF LIABILITY + + 4.1 Disclaimer of Warranties. TO THE MAXIMUM EXTENT PERMITTED UNDER APPLICABLE + LAW, THE ELASTIC SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, + AND ELASTIC AND ITS LICENSORS MAKE NO WARRANTIES WHETHER EXPRESSED, IMPLIED OR + STATUTORY REGARDING OR RELATING TO THE ELASTIC SOFTWARE. TO THE MAXIMUM EXTENT + PERMITTED UNDER APPLICABLE LAW, ELASTIC AND ITS LICENSORS SPECIFICALLY + DISCLAIM ALL IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE AND NON-INFRINGEMENT WITH RESPECT TO THE ELASTIC SOFTWARE, AND WITH + RESPECT TO THE USE OF THE FOREGOING. FURTHER, ELASTIC DOES NOT WARRANT RESULTS + OF USE OR THAT THE ELASTIC SOFTWARE WILL BE ERROR FREE OR THAT THE USE OF THE + ELASTIC SOFTWARE WILL BE UNINTERRUPTED. + + 4.2 Limitation of Liability. IN NO EVENT SHALL ELASTIC OR ITS LICENSORS BE + LIABLE TO YOU OR ANY THIRD PARTY FOR ANY DIRECT OR INDIRECT DAMAGES, + INCLUDING, WITHOUT LIMITATION, FOR ANY LOSS OF PROFITS, LOSS OF USE, BUSINESS + INTERRUPTION, LOSS OF DATA, COST OF SUBSTITUTE GOODS OR SERVICES, OR FOR ANY + SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES OF ANY KIND, IN CONNECTION WITH + OR ARISING OUT OF THE USE OR INABILITY TO USE THE ELASTIC SOFTWARE, OR THE + PERFORMANCE OF OR FAILURE TO PERFORM THIS AGREEMENT, WHETHER ALLEGED AS A + BREACH OF CONTRACT OR TORTIOUS CONDUCT, INCLUDING NEGLIGENCE, EVEN IF ELASTIC + HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +5. MISCELLANEOUS + + This Agreement completely and exclusively states the entire agreement of the + parties regarding the subject matter herein, and it supersedes, and its terms + govern, all prior proposals, agreements, or other communications between the + parties, oral or written, regarding such subject matter. This Agreement may be + modified by Elastic from time to time, and any such modifications will be + effective upon the "Posted Date" set forth at the top of the modified + Agreement. If any provision hereof is held unenforceable, this Agreement will + continue without said provision and be interpreted to reflect the original + intent of the parties. This Agreement and any non-contractual obligation + arising out of or in connection with it, is governed exclusively by Dutch law. + This Agreement shall not be governed by the 1980 UN Convention on Contracts + for the International Sale of Goods. All disputes arising out of or in + connection with this Agreement, including its existence and validity, shall be + resolved by the courts with jurisdiction in Amsterdam, The Netherlands, except + where mandatory law provides for the courts at another location in The + Netherlands to have jurisdiction. The parties hereby irrevocably waive any and + all claims and defenses either might otherwise have in any such action or + proceeding in any of such courts based upon any alleged lack of personal + jurisdiction, improper venue, forum non conveniens or any similar claim or + defense. A breach or threatened breach, by You of Section 2 may cause + irreparable harm for which damages at law may not provide adequate relief, and + therefore Elastic shall be entitled to seek injunctive relief without being + required to post a bond. You may not assign this Agreement (including by + operation of law in connection with a merger or acquisition), in whole or in + part to any third party without the prior written consent of Elastic, which + may be withheld or granted by Elastic in its sole and absolute discretion. + Any assignment in violation of the preceding sentence is void. Notices to + Elastic may also be sent to legal@elastic.co. + +6. DEFINITIONS + + The following terms have the meanings ascribed: + + 6.1 "Affiliate" means, with respect to a party, any entity that controls, is + controlled by, or which is under common control with, such party, where + "control" means ownership of at least fifty percent (50%) of the outstanding + voting shares of the entity, or the contractual right to establish policy for, + and manage the operations of, the entity. + + 6.2 "Basic Features and Functions" means those features and functions of the + Elastic Software that are eligible for use under a Basic license, as set forth + at https://www.elastic.co/subscriptions, as may be modified by Elastic from + time to time. + + 6.3 "Commercial Software" means the Elastic Software Source Code in any file + containing a header stating the contents are subject to the Elastic License or + which is contained in the repository folder labeled "x-pack", unless a LICENSE + file present in the directory subtree declares a different license. + + 6.4 "Derivative Work of the Commercial Software" means, for purposes of this + Agreement, any modification(s) or enhancement(s) to the Commercial Software, + which represent, as a whole, an original work of authorship. + + 6.5 "License" means a limited, non-exclusive, non-transferable, fully paid up, + royalty free, right and license, without the right to grant or authorize + sublicenses, solely for Your internal business operations to (i) install and + use the applicable Features and Functions of the Elastic Software in Object + Code, and (ii) permit Contractors and Your Affiliates to use the Elastic + software as set forth in (i) above, provided that such use by Contractors must + be solely for Your benefit and/or the benefit of Your Affiliates, and You + shall be responsible for all acts and omissions of such Contractors and + Affiliates in connection with their use of the Elastic software that are + contrary to the terms and conditions of this Agreement. + + 6.6 "License Key" means a sequence of bytes, including but not limited to a + JSON blob, that is used to enable certain features and functions of the + Elastic Software. + + 6.7 "Marks and Notices" means all Elastic trademarks, trade names, logos and + notices present on the Documentation as originally provided by Elastic. + + 6.8 "Non-production Environment" means an environment for development, testing + or quality assurance, where software is not used for production purposes. + + 6.9 "Object Code" means any form resulting from mechanical transformation or + translation of Source Code form, including but not limited to compiled object + code, generated documentation, and conversions to other media types. + + 6.10 "Source Code" means the preferred form of computer software for making + modifications, including but not limited to software source code, + documentation source, and configuration files. + + 6.11 "Subscription" means the right to receive Support Services and a License + to the Commercial Software. + + +GOVERNMENT END USER ADDENDUM TO THE ELASTIC LICENSE AGREEMENT + + This ADDENDUM TO THE ELASTIC LICENSE AGREEMENT (this "Addendum") applies +only to U.S. Federal Government, State Government, and Local Government +entities ("Government End Users") of the Elastic Software. This Addendum is +subject to, and hereby incorporated into, the Elastic License Agreement, +which is being entered into as of even date herewith, by Elastic and You (the +"Agreement"). This Addendum sets forth additional terms and conditions +related to Your use of the Elastic Software. Capitalized terms not defined in +this Addendum have the meaning set forth in the Agreement. + + 1. LIMITED LICENSE TO DISTRIBUTE (DSOP ONLY). Subject to the terms and +conditions of the Agreement (including this Addendum), Elastic grants the +Department of Defense Enterprise DevSecOps Initiative (DSOP) a royalty-free, +non-exclusive, non-transferable, limited license to reproduce and distribute +the Elastic Software solely through a software distribution repository +controlled and managed by DSOP, provided that DSOP: (i) distributes the +Elastic Software complete and unmodified, inclusive of the Agreement +(including this Addendum) and (ii) does not remove or alter any proprietary +legends or notices contained in the Elastic Software. + + 2. CHOICE OF LAW. The choice of law and venue provisions set forth shall +prevail over those set forth in Section 5 of the Agreement. + + "For U.S. Federal Government Entity End Users. This Agreement and any + non-contractual obligation arising out of or in connection with it, is + governed exclusively by U.S. Federal law. To the extent permitted by + federal law, the laws of the State of Delaware (excluding Delaware choice + of law rules) will apply in the absence of applicable federal law. + + For State and Local Government Entity End Users. This Agreement and any + non-contractual obligation arising out of or in connection with it, is + governed exclusively by the laws of the state in which you are located + without reference to conflict of laws. Furthermore, the Parties agree that + the Uniform Computer Information Transactions Act or any version thereof, + adopted by any state in any form ('UCITA'), shall not apply to this + Agreement and, to the extent that UCITA is applicable, the Parties agree to + opt out of the applicability of UCITA pursuant to the opt-out provision(s) + contained therein." + + 3. ELASTIC LICENSE MODIFICATION. Section 5 of the Agreement is hereby +amended to replace + + "This Agreement may be modified by Elastic from time to time, and any + such modifications will be effective upon the "Posted Date" set forth at + the top of the modified Agreement." + + with: + + "This Agreement may be modified by Elastic from time to time; provided, + however, that any such modifications shall apply only to Elastic Software + that is installed after the "Posted Date" set forth at the top of the + modified Agreement." + +V100820.0 diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index 18c04b0428afa..21d2582f205f3 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -12,6 +12,7 @@ import { promisify } from 'util'; import { ToolingLog } from '@kbn/dev-utils'; +import { branch } from '../../../../../../package.json'; import { write, copyAll, mkdirp, exec, Config, Build } from '../../../lib'; import * as dockerTemplates from './templates'; import { TemplateContext } from './template_context'; @@ -30,21 +31,26 @@ export async function runDockerGenerator( architecture?: string; context: boolean; image: boolean; - ubi: boolean; + ubi?: boolean; + ironbank?: boolean; } ) { // UBI var config const baseOSImage = flags.ubi ? 'docker.elastic.co/ubi8/ubi-minimal:latest' : 'centos:8'; const ubiVersionTag = 'ubi8'; - const ubiImageFlavor = flags.ubi ? `-${ubiVersionTag}` : ''; + + let imageFlavor = ''; + if (flags.ubi) imageFlavor += `-${ubiVersionTag}`; + if (flags.ironbank) imageFlavor += '-ironbank'; + if (build.isOss()) imageFlavor += '-oss'; // General docker var config const license = build.isOss() ? 'ASL 2.0' : 'Elastic License'; - const imageFlavor = build.isOss() ? '-oss' : ''; const imageTag = 'docker.elastic.co/kibana/kibana'; const version = config.getBuildVersion(); const artifactArchitecture = flags.architecture === 'aarch64' ? 'aarch64' : 'x86_64'; - const artifactPrefix = `kibana${imageFlavor}-${version}-linux`; + const artifactFlavor = build.isOss() ? '-oss' : ''; + const artifactPrefix = `kibana${artifactFlavor}-${version}-linux`; const artifactTarball = `${artifactPrefix}-${artifactArchitecture}.tar.gz`; const artifactsDir = config.resolveFromTarget('.'); const dockerBuildDate = new Date().toISOString(); @@ -52,26 +58,27 @@ export async function runDockerGenerator( const dockerBuildDir = config.resolveFromRepo( 'build', 'kibana-docker', - build.isOss() ? `oss` : `default${ubiImageFlavor}` + build.isOss() ? `oss` : `default${imageFlavor}` ); const imageArchitecture = flags.architecture === 'aarch64' ? '-aarch64' : ''; const dockerTargetFilename = config.resolveFromTarget( - `kibana${imageFlavor}${ubiImageFlavor}-${version}-docker-image${imageArchitecture}.tar.gz` + `kibana${imageFlavor}-${version}-docker-image${imageArchitecture}.tar.gz` ); const scope: TemplateContext = { artifactPrefix, artifactTarball, imageFlavor, version, + branch, license, artifactsDir, imageTag, dockerBuildDir, dockerTargetFilename, baseOSImage, - ubiImageFlavor, dockerBuildDate, ubi: flags.ubi, + ironbank: flags.ironbank, architecture: flags.architecture, revision: config.getBuildSha(), }; @@ -107,10 +114,17 @@ export async function runDockerGenerator( // in order to build the docker image accordingly the dockerfile defined // under templates/kibana_yml.template/js await copyAll( - config.resolveFromRepo('src/dev/build/tasks/os_packages/docker_generator/resources'), + config.resolveFromRepo('src/dev/build/tasks/os_packages/docker_generator/resources/base'), dockerBuildDir ); + if (flags.ironbank) { + await copyAll( + config.resolveFromRepo('src/dev/build/tasks/os_packages/docker_generator/resources/ironbank'), + dockerBuildDir + ); + } + // Build docker image into the target folder // In order to do this we just call the file we // created from the templates/build_docker_sh.template.js diff --git a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts index 845d0449437ba..9c9949c9f57ea 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts @@ -9,6 +9,7 @@ export interface TemplateContext { artifactPrefix: string; artifactTarball: string; + branch: string; imageFlavor: string; version: string; license: string; @@ -17,10 +18,10 @@ export interface TemplateContext { dockerBuildDir: string; dockerTargetFilename: string; baseOSImage: string; - ubiImageFlavor: string; dockerBuildDate: string; usePublicArtifact?: boolean; - ubi: boolean; + ubi?: boolean; + ironbank?: boolean; revision: string; architecture?: string; } diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile similarity index 100% rename from src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile rename to src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts index 89e6cc1040a02..05b9b4d100c53 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts @@ -16,7 +16,6 @@ function generator({ version, dockerTargetFilename, baseOSImage, - ubiImageFlavor, architecture, }: TemplateContext) { return dedent(` @@ -54,10 +53,10 @@ function generator({ retry_docker_pull ${baseOSImage} - echo "Building: kibana${imageFlavor}${ubiImageFlavor}-docker"; \\ - docker build -t ${imageTag}${imageFlavor}${ubiImageFlavor}:${version} -f Dockerfile . || exit 1; + echo "Building: kibana${imageFlavor}-docker"; \\ + docker build -t ${imageTag}${imageFlavor}:${version} -f Dockerfile . || exit 1; - docker save ${imageTag}${imageFlavor}${ubiImageFlavor}:${version} | gzip -c > ${dockerTargetFilename} + docker save ${imageTag}${imageFlavor}:${version} | gzip -c > ${dockerTargetFilename} exit 0 `); diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts index 01a45a4809431..e668299a3acc3 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts @@ -13,10 +13,10 @@ import Mustache from 'mustache'; import { TemplateContext } from '../template_context'; function generator(options: TemplateContext) { - const template = readFileSync(resolve(__dirname, './Dockerfile')); + const dir = options.ironbank ? 'ironbank' : 'base'; + const template = readFileSync(resolve(__dirname, dir, './Dockerfile')); return Mustache.render(template.toString(), { - packageManager: options.ubiImageFlavor ? 'microdnf' : 'yum', - tiniBin: options.architecture === 'aarch64' ? 'tini-arm64' : 'tini-amd64', + packageManager: options.ubi ? 'microdnf' : 'yum', ...options, }); } diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/Dockerfile new file mode 100644 index 0000000000000..6893883bf16a4 --- /dev/null +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/Dockerfile @@ -0,0 +1,77 @@ +################################################################################ +# Build stage 0 +# Extract Kibana and make various file manipulations. +################################################################################ +ARG BASE_REGISTRY=registry1.dsop.io +ARG BASE_IMAGE=redhat/ubi/ubi8 +ARG BASE_TAG=8.3 + +FROM ${BASE_REGISTRY}/${BASE_IMAGE}:${BASE_TAG} as prep_files + +RUN yum update --setopt=tsflags=nodocs -y && \ + yum install -y tar gzip && \ + yum clean all + +RUN mkdir /usr/share/kibana +WORKDIR /usr/share/kibana +COPY --chown=1000:0 {{artifactTarball}} . +RUN tar --strip-components=1 -zxf {{artifactTarball}} + +# Ensure that group permissions are the same as user permissions. +# This will help when relying on GID-0 to run Kibana, rather than UID-1000. +# OpenShift does this, for example. +# REF: https://docs.openshift.org/latest/creating_images/guidelines.html +RUN chmod -R g=u /usr/share/kibana + + +################################################################################ +# Build stage 1 +# Copy prepared files from the previous stage and complete the image. +################################################################################ +FROM ${BASE_REGISTRY}/${BASE_IMAGE}:${BASE_TAG} +EXPOSE 5601 + +RUN yum update --setopt=tsflags=nodocs -y && \ + yum install -y fontconfig freetype shadow-utils nss && \ + yum clean all + +COPY LICENSE /licenses/elastic-kibana + +# Add a dumb init process +COPY tini /bin/tini +RUN chmod +x /bin/tini + +# Noto Fonts +RUN mkdir /usr/share/fonts/local +COPY NotoSansCJK-Regular.ttc /usr/share/fonts/local/NotoSansCJK-Regular.ttc +RUN fc-cache -v + +# Bring in Kibana from the initial stage. +COPY --from=prep_files --chown=1000:0 /usr/share/kibana /usr/share/kibana +WORKDIR /usr/share/kibana +RUN ln -s /usr/share/kibana /opt/kibana + +ENV ELASTIC_CONTAINER true +ENV PATH=/usr/share/kibana/bin:$PATH + +# Set some Kibana configuration defaults. +COPY --chown=1000:0 config/kibana.yml /usr/share/kibana/config/kibana.yml + +# Add the launcher/wrapper script. It knows how to interpret environment +# variables and translate them to Kibana CLI options. +COPY --chown=1000:0 scripts/kibana-docker /usr/local/bin/ + +# Remove the suid bit everywhere to mitigate "Stack Clash" +RUN find / -xdev -perm -4000 -exec chmod u-s {} + + +# Provide a non-root user to run the process. +RUN groupadd --gid 1000 kibana && \ + useradd --uid 1000 --gid 1000 -G 0 \ + --home-dir /usr/share/kibana --no-create-home \ + kibana + +ENTRYPOINT ["/bin/tini", "--"] + +CMD ["/usr/local/bin/kibana-docker"] + +HEALTHCHECK --interval=10s --timeout=5s --start-period=1m --retries=5 CMD curl -I -f --max-time 5 http://localhost:5601 || exit 1 diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/README.md b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/README.md new file mode 100644 index 0000000000000..d297d135149f4 --- /dev/null +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/README.md @@ -0,0 +1,39 @@ +# Kibana + +**Kibana** lets you visualize your Elasticsearch data and navigate the Elastic Stack, +so you can do anything from learning why you're getting paged at 2:00 a.m. to +understanding the impact rain might have on your quarterly numbers. + +For more information about Kibana, please visit +https://www.elastic.co/products/kibana. + +### Installation instructions + +Please follow the documentation on [running Kibana on Docker](https://www.elastic.co/guide/en/kibana/{{branch}}/docker.html). + +### Where to file issues and PRs + +- [Issues](https://github.com/elastic/kibana/issues) +- [PRs](https://github.com/elastic/kibana/pulls) + +### DoD Restrictions + +Due to the [NODE-SECURITY-1184](https://www.npmjs.com/advisories/1184) issue, Kibana users should not use the `ALL_PROXY` environment variable to specify a proxy when installing Kibana plugins with the kibana-plugin command line application. + +### Where to get help + +- [Kibana Discuss Forums](https://discuss.elastic.co/c/kibana) +- [Kibana Documentation](https://www.elastic.co/guide/en/kibana/current/index.html) + +### Still need help? + +You can learn more about the Elastic Community and also understand how to get more help +visiting [Elastic Community](https://www.elastic.co/community). + +This software is governed by the [Elastic +License](https://github.com/elastic/elasticsearch/blob/{{branch}}/licenses/ELASTIC-LICENSE.txt), +and includes the full set of [free +features](https://www.elastic.co/subscriptions). + +View the detailed release notes +[here](https://www.elastic.co/guide/en/elasticsearch/reference/{{branch}}/es-release-notes.html). diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/hardening_manifest.yml b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/hardening_manifest.yml new file mode 100644 index 0000000000000..8de5ac2973358 --- /dev/null +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/hardening_manifest.yml @@ -0,0 +1,58 @@ +--- +apiVersion: v1 + +# The repository name in registry1, excluding /ironbank/ +name: 'elastic/kibana/kibana' + +# List of tags to push for the repository in registry1 +# The most specific version should be the first tag and will be shown +# on ironbank.dsop.io +tags: + - '{{version}}' + - 'latest' + +# Build args passed to Dockerfile ARGs +args: + BASE_IMAGE: 'redhat/ubi/ubi8' + BASE_TAG: '8.3' + +# Docker image labels +labels: + org.opencontainers.image.title: 'kibana' + org.opencontainers.image.description: 'Your window into the Elastic Stack.' + org.opencontainers.image.licenses: 'Elastic License' + org.opencontainers.image.url: 'https://www.elastic.co/products/kibana' + org.opencontainers.image.vendor: 'Elastic' + org.opencontainers.image.version: '{{version}}' + # mil.dso.ironbank.image.keywords: "" + # mil.dso.ironbank.image.type: "commercial" + mil.dso.ironbank.product.name: 'Kibana' + +# List of resources to make available to the offline build context +resources: + - filename: kibana-{{version}}-linux-x86_64.tar.gz + url: /kibana-{{version}}-linux-x86_64.tar.gz + validation: + type: sha512 + value: aa68f850cc09cf5dcb7c0b48bb8df788ca58eaad38d96141b8e59917fd38b42c728c0968f7cb2c8132c5aaeb595525cdde0859554346c496f53c569e03abe412 + - filename: tini + url: https://github.com/krallin/tini/releases/download/v0.19.0/tini-amd64 + validation: + type: sha512 + value: 8053cc21a3a9bdd6042a495349d1856ae8d3b3e7664c9654198de0087af031f5d41139ec85a2f5d7d2febd22ec3f280767ff23b9d5f63d490584e2b7ad3c218c + - filename: NotoSansCJK-Regular.ttc + url: https://github.com/googlefonts/noto-cjk/raw/NotoSansV2.001/NotoSansCJK-Regular.ttc + validation: + type: sha512 + value: 0ce56bde1853fed3e53282505bac65707385275a27816c29712ab04c187aa249797c82c58759b2b36c210d4e2683eda92359d739a8045cb8385c2c34d37cc9e1 + +# List of project maintainers +maintainers: + - email: 'tyler.smalley@elastic.co' + name: 'Tyler Smalley' + username: 'tylersmalley' + cht_member: false + - email: 'klepal_alexander@bah.com' + name: 'Alexander Klepal' + username: 'alexander.klepal' + cht_member: true From dae1793a34c1decc296928f9b011a06560987e61 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 11 Feb 2021 17:38:38 -0500 Subject: [PATCH 33/53] Changing the saved-object usage collector's alias from text to keyword (#91064) (#91170) Co-authored-by: Brandon Kobel --- .../server/collectors/core/core_usage_collector.ts | 2 +- src/plugins/telemetry/schema/oss_plugins.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts index 76ec60bfbefcb..4a1848586114a 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts @@ -98,7 +98,7 @@ export function getCoreUsageCollector( items: { docsCount: { type: 'long' }, docsDeleted: { type: 'long' }, - alias: { type: 'text' }, + alias: { type: 'keyword' }, primaryStoreSizeBytes: { type: 'long' }, storeSizeBytes: { type: 'long' }, }, diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index ff2dbd1be59ef..c192e1d629037 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -3705,7 +3705,7 @@ "type": "long" }, "alias": { - "type": "text" + "type": "keyword" }, "primaryStoreSizeBytes": { "type": "long" From 046bdb05c3a3e0060a800978e4f538d2176776b6 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Thu, 11 Feb 2021 16:52:16 -0600 Subject: [PATCH 34/53] [7.x] [Metrics UI] Fix alert preview accuracy with new Notify settings (#89939) (#91216) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../infra/common/alerting/metrics/types.ts | 1 + .../common/components/alert_preview.tsx | 20 ++++++- .../inventory/components/expression.test.tsx | 3 +- .../inventory/components/expression.tsx | 56 +++++++++++-------- .../components/expression.test.tsx | 3 +- .../metric_anomaly/components/expression.tsx | 51 ++++++++++------- .../components/expression.test.tsx | 3 +- .../components/expression.tsx | 40 +++++++------ .../components/expression_chart.tsx | 4 +- .../public/alerting/metric_threshold/types.ts | 8 ++- ...review_inventory_metric_threshold_alert.ts | 39 ++++++++----- .../preview_metric_anomaly_alert.ts | 28 +++++++--- .../preview_metric_threshold_alert.test.ts | 33 +++++++++++ .../preview_metric_threshold_alert.ts | 32 ++++++++--- .../alerting/metric_threshold/test_mocks.ts | 9 +++ .../infra/server/routes/alerting/preview.ts | 4 ++ .../alert_types/es_query/expression.test.tsx | 1 + .../alert_types/threshold/expression.test.tsx | 1 + .../sections/alert_form/alert_form.tsx | 1 + .../triggers_actions_ui/public/types.ts | 1 + 20 files changed, 237 insertions(+), 101 deletions(-) diff --git a/x-pack/plugins/infra/common/alerting/metrics/types.ts b/x-pack/plugins/infra/common/alerting/metrics/types.ts index 7a4edb8f49189..70515bde4b3fa 100644 --- a/x-pack/plugins/infra/common/alerting/metrics/types.ts +++ b/x-pack/plugins/infra/common/alerting/metrics/types.ts @@ -75,6 +75,7 @@ const baseAlertRequestParamsRT = rt.intersection([ alertInterval: rt.string, alertThrottle: rt.string, alertOnNoData: rt.boolean, + alertNotifyWhen: rt.string, }), ]); diff --git a/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx b/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx index 57c6f695453ef..010d8bd84bf34 100644 --- a/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx +++ b/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx @@ -21,6 +21,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { AlertNotifyWhenType } from '../../../../../alerts/common'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { FORMATTERS } from '../../../../common/formatters'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths @@ -36,6 +37,7 @@ import { getAlertPreview, PreviewableAlertTypes } from './get_alert_preview'; interface Props { alertInterval: string; alertThrottle: string; + alertNotifyWhen: AlertNotifyWhenType; alertType: PreviewableAlertTypes; alertParams: { criteria?: any[]; sourceId: string } & Record; validate: (params: any) => ValidationResult; @@ -48,6 +50,7 @@ export const AlertPreview: React.FC = (props) => { alertParams, alertInterval, alertThrottle, + alertNotifyWhen, alertType, validate, showNoDataResults, @@ -78,6 +81,7 @@ export const AlertPreview: React.FC = (props) => { lookback: previewLookbackInterval as 'h' | 'd' | 'w' | 'M', alertInterval, alertThrottle, + alertNotifyWhen, alertOnNoData: showNoDataResults ?? false, } as AlertPreviewRequestParams, alertType, @@ -92,6 +96,7 @@ export const AlertPreview: React.FC = (props) => { alertParams, alertInterval, alertType, + alertNotifyWhen, groupByDisplayName, previewLookbackInterval, alertThrottle, @@ -119,10 +124,11 @@ export const AlertPreview: React.FC = (props) => { const showNumberOfNotifications = useMemo(() => { if (!previewResult) return false; + if (alertNotifyWhen === 'onActiveAlert') return false; const { notifications, fired, noData, error } = previewResult.resultTotals; const unthrottledNotifications = fired + (showNoDataResults ? noData + error : 0); return unthrottledNotifications > notifications; - }, [previewResult, showNoDataResults]); + }, [previewResult, showNoDataResults, alertNotifyWhen]); const hasWarningThreshold = useMemo( () => alertParams.criteria?.some((c) => Reflect.has(c, 'warningThreshold')) ?? false, @@ -213,9 +219,17 @@ export const AlertPreview: React.FC = (props) => { {i18n.translate( diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx index 01720173a3438..891e98606264e 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx @@ -48,8 +48,9 @@ describe('Expression', () => { Reflect.set(alertParams, key, value)} setAlertProperty={() => {}} metadata={currentOptions} diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx index 4a05521e9fc87..d43bbb6888a6e 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx @@ -38,8 +38,10 @@ import { ForLastExpression, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../../triggers_actions_ui/public/common'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; +import { + IErrorObject, + AlertTypeParamsExpressionProps, +} from '../../../../../triggers_actions_ui/public'; import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar'; import { useSourceViaHttp } from '../../../containers/source/use_source_via_http'; import { sqsMetricTypes } from '../../../../common/inventory_models/aws_sqs/toolbar_items'; @@ -78,22 +80,21 @@ export interface AlertContextMeta { customMetrics?: SnapshotCustomMetricInput[]; } -interface Props { - errors: IErrorObject[]; - alertParams: { - criteria: InventoryMetricConditions[]; - nodeType: InventoryItemType; - filterQuery?: string; - filterQueryText?: string; - sourceId: string; - alertOnNoData?: boolean; - }; - alertInterval: string; - alertThrottle: string; - setAlertParams(key: string, value: any): void; - setAlertProperty(key: string, value: any): void; - metadata: AlertContextMeta; -} +type Criteria = InventoryMetricConditions[]; +type Props = Omit< + AlertTypeParamsExpressionProps< + { + criteria: Criteria; + nodeType: InventoryItemType; + filterQuery?: string; + filterQueryText?: string; + sourceId: string; + alertOnNoData?: boolean; + }, + AlertContextMeta + >, + 'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' +>; export const defaultExpression = { metric: 'cpu' as SnapshotMetricType, @@ -111,7 +112,15 @@ export const defaultExpression = { export const Expressions: React.FC = (props) => { const { http, notifications } = useKibanaContextForPlugin().services; - const { setAlertParams, alertParams, errors, alertInterval, alertThrottle, metadata } = props; + const { + setAlertParams, + alertParams, + errors, + alertInterval, + alertThrottle, + metadata, + alertNotifyWhen, + } = props; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', type: 'metrics', @@ -186,7 +195,7 @@ export const Expressions: React.FC = (props) => { timeSize: ts, })); setTimeSize(ts || undefined); - setAlertParams('criteria', criteria); + setAlertParams('criteria', criteria as Criteria); }, [alertParams.criteria, setAlertParams] ); @@ -198,7 +207,7 @@ export const Expressions: React.FC = (props) => { timeUnit: tu, })); setTimeUnit(tu as Unit); - setAlertParams('criteria', criteria); + setAlertParams('criteria', criteria as Criteria); }, [alertParams.criteria, setAlertParams] ); @@ -301,7 +310,7 @@ export const Expressions: React.FC = (props) => { key={idx} // idx's don't usually make good key's but here the index has semantic meaning expressionId={idx} setAlertParams={updateParams} - errors={errors[idx] || emptyError} + errors={(errors[idx] as IErrorObject) || emptyError} expression={e || {}} fields={derivedIndexPattern.fields} /> @@ -385,6 +394,7 @@ export const Expressions: React.FC = (props) => { & { metric?: SnapshotMetricType; }; - errors: IErrorObject; + errors: AlertTypeParamsExpressionProps['errors']; canDelete: boolean; addExpression(): void; remove(id: number): void; diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx index ae2c6ed81badb..3b3bece47e53f 100644 --- a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx @@ -43,8 +43,9 @@ describe('Expression', () => { Reflect.set(alertParams, key, value)} setAlertProperty={() => {}} metadata={currentOptions} diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx index 5938c7119616f..5f034a600ecc6 100644 --- a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx @@ -22,8 +22,11 @@ import { WhenExpression, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../../triggers_actions_ui/public/common'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; +import { + AlertTypeParams, + AlertTypeParamsExpressionProps, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../triggers_actions_ui/public/types'; import { useSourceViaHttp } from '../../../containers/source/use_source_via_http'; import { findInventoryModel } from '../../../../common/inventory_models'; import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; @@ -41,29 +44,32 @@ export interface AlertContextMeta { nodeType?: InventoryItemType; } -interface Props { - errors: IErrorObject[]; - alertParams: MetricAnomalyParams & { - sourceId: string; - }; - alertInterval: string; - alertThrottle: string; - setAlertParams(key: string, value: any): void; - setAlertProperty(key: string, value: any): void; - metadata: AlertContextMeta; -} +type AlertParams = AlertTypeParams & + MetricAnomalyParams & { sourceId: string; hasInfraMLCapabilities: boolean }; + +type Props = Omit< + AlertTypeParamsExpressionProps, + 'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' +>; export const defaultExpression = { metric: 'memory_usage' as MetricAnomalyParams['metric'], - threshold: ANOMALY_THRESHOLD.MAJOR, - nodeType: 'hosts', + threshold: ANOMALY_THRESHOLD.MAJOR as MetricAnomalyParams['threshold'], + nodeType: 'hosts' as MetricAnomalyParams['nodeType'], influencerFilter: undefined, }; export const Expression: React.FC = (props) => { const { hasInfraMLCapabilities, isLoading: isLoadingMLCapabilities } = useInfraMLCapabilities(); const { http, notifications } = useKibanaContextForPlugin().services; - const { setAlertParams, alertParams, alertInterval, alertThrottle, metadata } = props; + const { + setAlertParams, + alertParams, + alertInterval, + alertThrottle, + alertNotifyWhen, + metadata, + } = props; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', type: 'metrics', @@ -97,7 +103,7 @@ export const Expression: React.FC = (props) => { setAlertParams('influencerFilter', { ...alertParams.influencerFilter, fieldValue: value, - }); + } as MetricAnomalyParams['influencerFilter']); } else { setAlertParams('influencerFilter', undefined); } @@ -118,7 +124,7 @@ export const Expression: React.FC = (props) => { const updateMetric = useCallback( (metric: string) => { - setAlertParams('metric', metric); + setAlertParams('metric', metric as MetricAnomalyParams['metric']); }, [setAlertParams] ); @@ -249,6 +255,7 @@ export const Expression: React.FC = (props) => { { +const getMLMetricFromInventoryMetric: ( + metric: SnapshotMetricType +) => MetricAnomalyParams['metric'] | null = (metric) => { switch (metric) { case 'memory': return 'memory_usage'; @@ -308,7 +317,9 @@ const getMLMetricFromInventoryMetric = (metric: SnapshotMetricType) => { } }; -const getMLNodeTypeFromInventoryNodeType = (nodeType: InventoryItemType) => { +const getMLNodeTypeFromInventoryNodeType: ( + nodeType: InventoryItemType +) => MetricAnomalyParams['nodeType'] | null = (nodeType) => { switch (nodeType) { case 'host': return 'hosts'; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx index 7ceb37c4a2f6e..a6d74d4f461a6 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx @@ -44,8 +44,9 @@ describe('Expression', () => { Reflect.set(alertParams, key, value)} setAlertProperty={() => {}} metadata={{ diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx index c3c3c20c4dd43..64190f5557707 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -30,8 +30,12 @@ import { ForLastExpression, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../../triggers_actions_ui/public/common'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; +import { + IErrorObject, + AlertTypeParams, + AlertTypeParamsExpressionProps, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../triggers_actions_ui/public/types'; import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar'; import { MetricsExplorerOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; import { MetricsExplorerGroupBy } from '../../../pages/metrics/metrics_explorer/components/group_by'; @@ -46,15 +50,10 @@ import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; const FILTER_TYPING_DEBOUNCE_MS = 500; -interface Props { - errors: IErrorObject[]; - alertParams: AlertParams; - alertInterval: string; - alertThrottle: string; - setAlertParams(key: string, value: any): void; - setAlertProperty(key: string, value: any): void; - metadata: AlertContextMeta; -} +type Props = Omit< + AlertTypeParamsExpressionProps, + 'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' +>; const defaultExpression = { aggType: Aggregators.AVERAGE, @@ -66,7 +65,15 @@ const defaultExpression = { export { defaultExpression }; export const Expressions: React.FC = (props) => { - const { setAlertParams, alertParams, errors, alertInterval, alertThrottle, metadata } = props; + const { + setAlertParams, + alertParams, + errors, + alertInterval, + alertThrottle, + metadata, + alertNotifyWhen, + } = props; const { http, notifications } = useKibanaContextForPlugin().services; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', @@ -76,7 +83,7 @@ export const Expressions: React.FC = (props) => { }); const [timeSize, setTimeSize] = useState(1); - const [timeUnit, setTimeUnit] = useState('m'); + const [timeUnit, setTimeUnit] = useState('m'); const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ createDerivedIndexPattern, ]); @@ -174,7 +181,7 @@ export const Expressions: React.FC = (props) => { timeUnit: tu, })) || []; setTimeUnit(tu as Unit); - setAlertParams('criteria', criteria); + setAlertParams('criteria', criteria as AlertParams['criteria']); }, [alertParams.criteria, setAlertParams] ); @@ -191,7 +198,7 @@ export const Expressions: React.FC = (props) => { timeSize, timeUnit, aggType: metric.aggregation, - })) + })) as AlertParams['criteria'] ); } else { setAlertParams('criteria', [defaultExpression]); @@ -280,7 +287,7 @@ export const Expressions: React.FC = (props) => { key={idx} // idx's don't usually make good key's but here the index has semantic meaning expressionId={idx} setAlertParams={updateParams} - errors={errors[idx] || emptyError} + errors={(errors[idx] as IErrorObject) || emptyError} expression={e || {}} > = (props) => { = ({ ) : ( @@ -336,7 +336,7 @@ export const ExpressionChart: React.FC = ({ )} diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts index c49918d3dd379..fca4160199030 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts @@ -17,8 +17,10 @@ export interface AlertContextMeta { series?: MetricsExplorerSeries; } -export type MetricExpression = Omit & { - metric?: string; +export type MetricExpression = Omit & { + metric?: MetricExpressionParams['metric']; + timeSize?: MetricExpressionParams['timeSize']; + timeUnit?: MetricExpressionParams['timeUnit']; }; export enum AGGREGATION_TYPES { @@ -54,7 +56,7 @@ export interface ExpressionChartData { export interface AlertParams { criteria: MetricExpression[]; - groupBy?: string[]; + groupBy?: string | string[]; filterQuery?: string; sourceId: string; filterQueryText?: string; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts index 5fff76260e5c6..6f3299a2cc126 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts @@ -34,6 +34,7 @@ interface PreviewInventoryMetricThresholdAlertParams { alertInterval: string; alertThrottle: string; alertOnNoData: boolean; + alertNotifyWhen: string; } export const previewInventoryMetricThresholdAlert: ( @@ -46,7 +47,8 @@ export const previewInventoryMetricThresholdAlert: ( alertInterval, alertThrottle, alertOnNoData, -}) => { + alertNotifyWhen, +}: PreviewInventoryMetricThresholdAlertParams) => { const { criteria, filterQuery, nodeType } = params as InventoryMetricThresholdParams; if (criteria.length === 0) throw new Error('Cannot execute an alert with 0 conditions'); @@ -62,9 +64,7 @@ export const previewInventoryMetricThresholdAlert: ( const alertIntervalInSeconds = getIntervalInSeconds(alertInterval); const alertResultsPerExecution = alertIntervalInSeconds / bucketIntervalInSeconds; const throttleIntervalInSeconds = getIntervalInSeconds(alertThrottle); - const executionsPerThrottle = Math.floor( - (throttleIntervalInSeconds / alertIntervalInSeconds) * alertResultsPerExecution - ); + try { const results = await Promise.all( criteria.map((c) => @@ -82,9 +82,17 @@ export const previewInventoryMetricThresholdAlert: ( let numberOfErrors = 0; let numberOfNotifications = 0; let throttleTracker = 0; - const notifyWithThrottle = () => { - if (throttleTracker === 0) numberOfNotifications++; - throttleTracker++; + let previousActionGroup: string | null = null; + const notifyWithThrottle = (actionGroup: string) => { + if (alertNotifyWhen === 'onActionGroupChange') { + if (previousActionGroup !== actionGroup) numberOfNotifications++; + } else if (alertNotifyWhen === 'onThrottleInterval') { + if (throttleTracker === 0) numberOfNotifications++; + throttleTracker += alertIntervalInSeconds; + } else { + numberOfNotifications++; + } + previousActionGroup = actionGroup; }; for (let i = 0; i < numberOfExecutionBuckets; i++) { const mappedBucketIndex = Math.floor(i * alertResultsPerExecution); @@ -105,23 +113,26 @@ export const previewInventoryMetricThresholdAlert: ( if (someConditionsErrorInMappedBucket) { numberOfErrors++; if (alertOnNoData) { - notifyWithThrottle(); + notifyWithThrottle('fired'); // TODO: Update this when No Data alerts move to an action group } } else if (someConditionsNoDataInMappedBucket) { numberOfNoDataResults++; if (alertOnNoData) { - notifyWithThrottle(); + notifyWithThrottle('fired'); // TODO: Update this when No Data alerts move to an action group } } else if (allConditionsFiredInMappedBucket) { numberOfTimesFired++; - notifyWithThrottle(); + notifyWithThrottle('fired'); } else if (allConditionsWarnInMappedBucket) { numberOfTimesWarned++; - notifyWithThrottle(); - } else if (throttleTracker > 0) { - throttleTracker++; + notifyWithThrottle('warning'); + } else { + previousActionGroup = 'recovered'; + if (throttleTracker > 0) { + throttleTracker += alertIntervalInSeconds; + } } - if (throttleTracker === executionsPerThrottle) { + if (throttleTracker >= throttleIntervalInSeconds) { throttleTracker = 0; } } diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/preview_metric_anomaly_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/preview_metric_anomaly_alert.ts index 98992701e3bb4..b5033bb9a6043 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/preview_metric_anomaly_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/preview_metric_anomaly_alert.ts @@ -27,6 +27,7 @@ interface PreviewMetricAnomalyAlertParams { alertInterval: string; alertThrottle: string; alertOnNoData: boolean; + alertNotifyWhen: string; } export const previewMetricAnomalyAlert = async ({ @@ -38,12 +39,12 @@ export const previewMetricAnomalyAlert = async ({ lookback, alertInterval, alertThrottle, + alertNotifyWhen, }: PreviewMetricAnomalyAlertParams) => { const { metric, threshold, influencerFilter, nodeType } = params as MetricAnomalyParams; const alertIntervalInSeconds = getIntervalInSeconds(alertInterval); const throttleIntervalInSeconds = getIntervalInSeconds(alertThrottle); - const executionsPerThrottle = Math.floor(throttleIntervalInSeconds / alertIntervalInSeconds); const lookbackInterval = `1${lookback}`; const lookbackIntervalInSeconds = getIntervalInSeconds(lookbackInterval); @@ -78,9 +79,17 @@ export const previewMetricAnomalyAlert = async ({ let numberOfTimesFired = 0; let numberOfNotifications = 0; let throttleTracker = 0; - const notifyWithThrottle = () => { - if (throttleTracker === 0) numberOfNotifications++; - throttleTracker++; + let previousActionGroup: string | null = null; + const notifyWithThrottle = (actionGroup: string) => { + if (alertNotifyWhen === 'onActionGroupChange') { + if (previousActionGroup !== actionGroup) numberOfNotifications++; + } else if (alertNotifyWhen === 'onThrottleInterval') { + if (throttleTracker === 0) numberOfNotifications++; + throttleTracker += alertIntervalInSeconds; + } else { + numberOfNotifications++; + } + previousActionGroup = actionGroup; }; // Mock each alert evaluation for (let i = 0; i < numberOfExecutions; i++) { @@ -102,11 +111,14 @@ export const previewMetricAnomalyAlert = async ({ if (anomaliesDetectedInBuckets) { numberOfTimesFired++; - notifyWithThrottle(); - } else if (throttleTracker > 0) { - throttleTracker++; + notifyWithThrottle('fired'); + } else { + previousActionGroup = 'recovered'; + if (throttleTracker > 0) { + throttleTracker += alertIntervalInSeconds; + } } - if (throttleTracker === executionsPerThrottle) { + if (throttleTracker >= throttleIntervalInSeconds) { throttleTracker = 0; } } diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts index 1adca25504b1f..c9616377acf8f 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts @@ -19,6 +19,7 @@ describe('Previewing the metric threshold alert type', () => { alertInterval: '1m', alertThrottle: '1m', alertOnNoData: true, + alertNotifyWhen: 'onThrottleInterval', }); const { fired, noData, error, notifications } = ungroupedResult; expect(fired).toBe(30); @@ -34,6 +35,7 @@ describe('Previewing the metric threshold alert type', () => { alertInterval: '3m', alertThrottle: '3m', alertOnNoData: true, + alertNotifyWhen: 'onThrottleInterval', }); const { fired, noData, error, notifications } = ungroupedResult; expect(fired).toBe(10); @@ -48,6 +50,7 @@ describe('Previewing the metric threshold alert type', () => { alertInterval: '30s', alertThrottle: '30s', alertOnNoData: true, + alertNotifyWhen: 'onThrottleInterval', }); const { fired, noData, error, notifications } = ungroupedResult; expect(fired).toBe(60); @@ -62,6 +65,7 @@ describe('Previewing the metric threshold alert type', () => { alertInterval: '1m', alertThrottle: '3m', alertOnNoData: true, + alertNotifyWhen: 'onThrottleInterval', }); const { fired, noData, error, notifications } = ungroupedResult; expect(fired).toBe(30); @@ -69,6 +73,30 @@ describe('Previewing the metric threshold alert type', () => { expect(error).toBe(0); expect(notifications).toBe(15); }); + test('returns the expected results using a notify setting of Only on Status Change', async () => { + const [ungroupedResult] = await previewMetricThresholdAlert({ + ...baseParams, + params: { + ...baseParams.params, + criteria: [ + { + ...baseCriterion, + metric: 'test.metric.3', + } as MetricExpressionParams, + ], + }, + lookback: 'h', + alertInterval: '1m', + alertThrottle: '1m', + alertOnNoData: true, + alertNotifyWhen: 'onActionGroupChange', + }); + const { fired, noData, error, notifications } = ungroupedResult; + expect(fired).toBe(20); + expect(noData).toBe(0); + expect(error).toBe(0); + expect(notifications).toBe(20); + }); }); describe('querying with a groupBy parameter', () => { test('returns the expected results', async () => { @@ -82,6 +110,7 @@ describe('Previewing the metric threshold alert type', () => { alertInterval: '1m', alertThrottle: '1m', alertOnNoData: true, + alertNotifyWhen: 'onThrottleInterval', }); const { fired: firedA, @@ -122,6 +151,7 @@ describe('Previewing the metric threshold alert type', () => { alertInterval: '1m', alertThrottle: '1m', alertOnNoData: true, + alertNotifyWhen: 'onThrottleInterval', }); const { fired, noData, error, notifications } = ungroupedResult; expect(fired).toBe(25); @@ -144,6 +174,9 @@ services.callCluster.mockImplementation(async (_: string, { body, index }: any) if (metric === 'test.metric.2') { return mocks.alternateMetricPreviewResponse; } + if (metric === 'test.metric.3') { + return mocks.repeatingMetricPreviewResponse; + } return mocks.basicMetricPreviewResponse; }); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts index b9fa6659d5fcd..fe2a88d89bf4a 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts @@ -31,6 +31,7 @@ interface PreviewMetricThresholdAlertParams { lookback: Unit; alertInterval: string; alertThrottle: string; + alertNotifyWhen: string; alertOnNoData: boolean; end?: number; overrideLookbackIntervalInSeconds?: number; @@ -48,6 +49,7 @@ export const previewMetricThresholdAlert: ( lookback, alertInterval, alertThrottle, + alertNotifyWhen, alertOnNoData, end = Date.now(), overrideLookbackIntervalInSeconds, @@ -104,9 +106,17 @@ export const previewMetricThresholdAlert: ( let numberOfErrors = 0; let numberOfNotifications = 0; let throttleTracker = 0; - const notifyWithThrottle = () => { - if (throttleTracker === 0) numberOfNotifications++; - throttleTracker += alertIntervalInSeconds; + let previousActionGroup: string | null = null; + const notifyWithThrottle = (actionGroup: string) => { + if (alertNotifyWhen === 'onActionGroupChange') { + if (previousActionGroup !== actionGroup) numberOfNotifications++; + previousActionGroup = actionGroup; + } else if (alertNotifyWhen === 'onThrottleInterval') { + if (throttleTracker === 0) numberOfNotifications++; + throttleTracker += alertIntervalInSeconds; + } else { + numberOfNotifications++; + } }; for (let i = 0; i < numberOfExecutionBuckets; i++) { const mappedBucketIndex = Math.floor(i * alertResultsPerExecution); @@ -126,21 +136,24 @@ export const previewMetricThresholdAlert: ( if (someConditionsErrorInMappedBucket) { numberOfErrors++; if (alertOnNoData) { - notifyWithThrottle(); + notifyWithThrottle('fired'); // TODO: Update this when No Data alerts move to an action group } } else if (someConditionsNoDataInMappedBucket) { numberOfNoDataResults++; if (alertOnNoData) { - notifyWithThrottle(); + notifyWithThrottle('fired'); // TODO: Update this when No Data alerts move to an action group } } else if (allConditionsFiredInMappedBucket) { numberOfTimesFired++; - notifyWithThrottle(); + notifyWithThrottle('fired'); } else if (allConditionsWarnInMappedBucket) { numberOfTimesWarned++; - notifyWithThrottle(); - } else if (throttleTracker > 0) { - throttleTracker += alertIntervalInSeconds; + notifyWithThrottle('warning'); + } else { + previousActionGroup = 'recovered'; + if (throttleTracker > 0) { + throttleTracker += alertIntervalInSeconds; + } } if (throttleTracker >= throttleIntervalInSeconds) { throttleTracker = 0; @@ -168,6 +181,7 @@ export const previewMetricThresholdAlert: ( alertInterval, alertThrottle, alertOnNoData, + alertNotifyWhen, }; const { maxBuckets } = e; // If this is still the first iteration, try to get the number of groups in order to diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts index 20736db5425de..2d4f2b16c78a4 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts @@ -45,6 +45,7 @@ const previewBucketsWithNulls = [ ...Array.from(Array(10), (_, i) => ({ aggregatedValue: { value: null } })), ...previewBucketsA.slice(10), ]; +const previewBucketsRepeat = Array.from(Array(60), (_, i) => bucketsA[Math.max(0, (i % 3) - 1)]); export const basicMetricResponse = { aggregations: { @@ -175,6 +176,14 @@ export const alternateMetricPreviewResponse = { }, }; +export const repeatingMetricPreviewResponse = { + aggregations: { + aggregatedIntervals: { + buckets: previewBucketsRepeat, + }, + }, +}; + export const basicCompositePreviewResponse = { aggregations: { groupings: { diff --git a/x-pack/plugins/infra/server/routes/alerting/preview.ts b/x-pack/plugins/infra/server/routes/alerting/preview.ts index 3da560135eaf4..d1807583acd39 100644 --- a/x-pack/plugins/infra/server/routes/alerting/preview.ts +++ b/x-pack/plugins/infra/server/routes/alerting/preview.ts @@ -43,6 +43,7 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) alertInterval, alertThrottle, alertOnNoData, + alertNotifyWhen, } = request.body; const callCluster = (endpoint: string, opts: Record) => { @@ -69,6 +70,7 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) config: source.configuration, alertInterval, alertThrottle, + alertNotifyWhen, alertOnNoData, }); @@ -90,6 +92,7 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) source, alertInterval, alertThrottle, + alertNotifyWhen, alertOnNoData, }); @@ -119,6 +122,7 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) alertInterval, alertThrottle, alertOnNoData, + alertNotifyWhen, }); return response.ok({ diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx index 27ddb28eed779..f475d97e2f39d 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx @@ -147,6 +147,7 @@ describe('EsQueryAlertTypeExpression', () => { {}} setAlertProperty={() => {}} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.test.tsx index 01c2bc18f35e8..28f0f3db19614 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.test.tsx @@ -89,6 +89,7 @@ describe('IndexThresholdAlertTypeExpression', () => { {}} setAlertProperty={() => {}} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 7358facf215c3..bd1e3b19e6510 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -591,6 +591,7 @@ export const AlertForm = ({ alertParams={alert.params} alertInterval={`${alertInterval ?? 1}${alertIntervalUnit}`} alertThrottle={`${alertThrottle ?? 1}${alertThrottleUnit}`} + alertNotifyWhen={alert.notifyWhen ?? 'onActionGroupChange'} errors={errors} setAlertParams={setAlertParams} setAlertProperty={setAlertProperty} diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 6fb52cf1151d5..3e41d27596c34 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -198,6 +198,7 @@ export interface AlertTypeParamsExpressionProps< alertParams: Params; alertInterval: string; alertThrottle: string; + alertNotifyWhen: AlertNotifyWhenType; setAlertParams: (property: Key, value: Params[Key] | undefined) => void; setAlertProperty: ( key: Prop, From 4a732b31f136cf6fb9e29da87b8749ce55fc4a0a Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Thu, 11 Feb 2021 16:54:03 -0600 Subject: [PATCH 35/53] [7.x] [Metrics UI] Fix alerts repeatedly firing "recovered" actions (#91038) (#91214) --- .../inventory_metric_threshold_executor.ts | 9 +--- .../metric_anomaly/metric_anomaly_executor.ts | 3 +- .../metric_threshold_executor.test.ts | 42 +------------------ .../metric_threshold_executor.ts | 11 ++--- 4 files changed, 7 insertions(+), 58 deletions(-) diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index a15f1010194a5..17a6761a8b8b7 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -74,8 +74,6 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = const inventoryItems = Object.keys(first(results)!); for (const item of inventoryItems) { - const alertInstance = services.alertInstanceFactory(`${item}`); - const prevState = alertInstance.getState(); // AND logic; all criteria must be across the threshold const shouldAlertFire = results.every((result) => // Grab the result of the most recent bucket @@ -109,12 +107,12 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = ) ) .join('\n'); - } else if (nextState === AlertStates.OK && prevState?.alertState === AlertStates.ALERT) { /* * Custom recovery actions aren't yet available in the alerting framework * Uncomment the code below once they've been implemented * Reference: https://github.com/elastic/kibana/issues/87048 */ + // } else if (nextState === AlertStates.OK && prevState?.alertState === AlertStates.ALERT) { // reason = results // .map((result) => buildReasonWithVerboseMetricName(result[item], buildRecoveredAlertReason)) // .join('\n'); @@ -139,6 +137,7 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = : nextState === AlertStates.WARNING ? WARNING_ACTIONS.id : FIRED_ACTIONS.id; + const alertInstance = services.alertInstanceFactory(`${item}`); alertInstance.scheduleActions( /** * TODO: We're lying to the compiler here as explicitly calling `scheduleActions` on @@ -158,10 +157,6 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = } ); } - - alertInstance.replaceState({ - alertState: nextState, - }); } }; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts index ec95aac7268ad..a5297f81bbaca 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts @@ -55,8 +55,6 @@ export const createMetricAnomalyExecutor = (libs: InfraBackendLibs, ml?: MlPlugi threshold, } = params as MetricAnomalyParams; - const alertInstance = services.alertInstanceFactory(`${nodeType}-${metric}`); - const bucketInterval = getIntervalInSeconds('15m') * 1000; const alertIntervalInMs = getIntervalInSeconds(alertInterval ?? '1m') * 1000; @@ -86,6 +84,7 @@ export const createMetricAnomalyExecutor = (libs: InfraBackendLibs, ml?: MlPlugi const { startTime: anomalyStartTime, anomalyScore, actual, typical, influencers } = first( data as MappedAnomalyHit[] )!; + const alertInstance = services.alertInstanceFactory(`${nodeType}-${metric}`); alertInstance.scheduleActions(FIRED_ACTIONS_ID, { alertState: stateToAlertMessage[AlertStates.ALERT], diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index 13c5ea4c701af..fa435f8cfe2c4 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -6,7 +6,7 @@ */ import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; -import { Comparator, AlertStates } from './types'; +import { Comparator } from './types'; import * as mocks from './test_mocks'; // import { RecoveredActionGroup } from '../../../../../alerts/common'; import { @@ -60,56 +60,42 @@ describe('The metric threshold alert type', () => { test('alerts as expected with the > comparator', async () => { await execute(Comparator.GT, [0.75]); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.GT, [1.5]); expect(mostRecentAction(instanceID)).toBe(undefined); - expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); test('alerts as expected with the < comparator', async () => { await execute(Comparator.LT, [1.5]); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.LT, [0.75]); expect(mostRecentAction(instanceID)).toBe(undefined); - expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); test('alerts as expected with the >= comparator', async () => { await execute(Comparator.GT_OR_EQ, [0.75]); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.GT_OR_EQ, [1.0]); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.GT_OR_EQ, [1.5]); expect(mostRecentAction(instanceID)).toBe(undefined); - expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); test('alerts as expected with the <= comparator', async () => { await execute(Comparator.LT_OR_EQ, [1.5]); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.LT_OR_EQ, [1.0]); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.LT_OR_EQ, [0.75]); expect(mostRecentAction(instanceID)).toBe(undefined); - expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); test('alerts as expected with the between comparator', async () => { await execute(Comparator.BETWEEN, [0, 1.5]); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.BETWEEN, [0, 0.75]); expect(mostRecentAction(instanceID)).toBe(undefined); - expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); test('alerts as expected with the outside range comparator', async () => { await execute(Comparator.OUTSIDE_RANGE, [0, 0.75]); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.OUTSIDE_RANGE, [0, 1.5]); expect(mostRecentAction(instanceID)).toBe(undefined); - expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); test('reports expected values to the action context', async () => { const now = 1577858400000; @@ -144,23 +130,17 @@ describe('The metric threshold alert type', () => { test('sends an alert when all groups pass the threshold', async () => { await execute(Comparator.GT, [0.75]); expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceIdA).alertState).toBe(AlertStates.ALERT); expect(mostRecentAction(instanceIdB).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceIdB).alertState).toBe(AlertStates.ALERT); }); test('sends an alert when only some groups pass the threshold', async () => { await execute(Comparator.LT, [1.5]); expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceIdA).alertState).toBe(AlertStates.ALERT); expect(mostRecentAction(instanceIdB)).toBe(undefined); - expect(getState(instanceIdB).alertState).toBe(AlertStates.OK); }); test('sends no alert when no groups pass the threshold', async () => { await execute(Comparator.GT, [5]); expect(mostRecentAction(instanceIdA)).toBe(undefined); - expect(getState(instanceIdA).alertState).toBe(AlertStates.OK); expect(mostRecentAction(instanceIdB)).toBe(undefined); - expect(getState(instanceIdB).alertState).toBe(AlertStates.OK); }); test('reports group values to the action context', async () => { await execute(Comparator.GT, [0.75]); @@ -200,22 +180,18 @@ describe('The metric threshold alert type', () => { const instanceID = '*'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0]); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); }); test('sends no alert when some, but not all, criteria cross the threshold', async () => { const instanceID = '*'; await execute(Comparator.LT_OR_EQ, [1.0], [3.0]); expect(mostRecentAction(instanceID)).toBe(undefined); - expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); test('alerts only on groups that meet all criteria when querying with a groupBy parameter', async () => { const instanceIdA = 'a'; const instanceIdB = 'b'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0], 'something'); expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceIdA).alertState).toBe(AlertStates.ALERT); expect(mostRecentAction(instanceIdB)).toBe(undefined); - expect(getState(instanceIdB).alertState).toBe(AlertStates.OK); }); test('sends all criteria to the action context', async () => { const instanceID = '*'; @@ -252,10 +228,8 @@ describe('The metric threshold alert type', () => { test('alerts based on the doc_count value instead of the aggregatedValue', async () => { await execute(Comparator.GT, [2]); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.LT, [1.5]); expect(mostRecentAction(instanceID)).toBe(undefined); - expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); }); describe('querying with the p99 aggregator', () => { @@ -279,10 +253,8 @@ describe('The metric threshold alert type', () => { test('alerts based on the p99 values', async () => { await execute(Comparator.GT, [1]); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.LT, [1]); expect(mostRecentAction(instanceID)).toBe(undefined); - expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); }); describe('querying with the p95 aggregator', () => { @@ -306,10 +278,8 @@ describe('The metric threshold alert type', () => { test('alerts based on the p95 values', async () => { await execute(Comparator.GT, [0.25]); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.LT, [0.95]); expect(mostRecentAction(instanceID)).toBe(undefined); - expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); }); describe("querying a metric that hasn't reported data", () => { @@ -333,12 +303,10 @@ describe('The metric threshold alert type', () => { test('sends a No Data alert when configured to do so', async () => { await execute(true); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceID).alertState).toBe(AlertStates.NO_DATA); }); test('does not send a No Data alert when not configured to do so', async () => { await execute(false); expect(mostRecentAction(instanceID)).toBe(undefined); - expect(getState(instanceID).alertState).toBe(AlertStates.NO_DATA); }); }); @@ -364,7 +332,6 @@ describe('The metric threshold alert type', () => { test('sends a No Data alert', async () => { await execute(); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceID).alertState).toBe(AlertStates.NO_DATA); }); }); @@ -516,9 +483,6 @@ services.alertInstanceFactory.mockImplementation((instanceID: string) => { : newAlertInstance; alertInstances.set(instanceID, alertInstance); - alertInstance.instance.getState.mockImplementation(() => { - return alertInstance.state; - }); alertInstance.instance.replaceState.mockImplementation((newState: any) => { alertInstance.state = newState; return alertInstance.instance; @@ -534,10 +498,6 @@ function mostRecentAction(id: string) { return alertInstances.get(id)!.actionQueue.pop(); } -function getState(id: string) { - return alertInstances.get(id)!.state; -} - const baseCriterion = { aggType: 'avg', metric: 'test.metric.1', diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index b822d71b3f812..f9a09429942ee 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -52,9 +52,6 @@ export const createMetricThresholdExecutor = ( // Because each alert result has the same group definitions, just grab the groups from the first one. const groups = Object.keys(first(alertResults)!); for (const group of groups) { - const alertInstance = services.alertInstanceFactory(`${group}`); - const prevState = alertInstance.getState(); - // AND logic; all criteria must be across the threshold const shouldAlertFire = alertResults.every((result) => // Grab the result of the most recent bucket @@ -85,12 +82,12 @@ export const createMetricThresholdExecutor = ( ) ) .join('\n'); - } else if (nextState === AlertStates.OK && prevState?.alertState === AlertStates.ALERT) { /* * Custom recovery actions aren't yet available in the alerting framework * Uncomment the code below once they've been implemented * Reference: https://github.com/elastic/kibana/issues/87048 */ + // } else if (nextState === AlertStates.OK && prevState?.alertState === AlertStates.ALERT) { // reason = alertResults // .map((result) => buildRecoveredAlertReason(formatAlertResult(result[group]))) // .join('\n'); @@ -117,6 +114,8 @@ export const createMetricThresholdExecutor = ( : nextState === AlertStates.WARNING ? WARNING_ACTIONS.id : FIRED_ACTIONS.id; + const alertInstance = services.alertInstanceFactory(`${group}`); + alertInstance.scheduleActions(actionGroupId, { group, alertState: stateToAlertMessage[nextState], @@ -133,10 +132,6 @@ export const createMetricThresholdExecutor = ( metric: mapToConditionsLookup(criteria, (c) => c.metric), }); } - - alertInstance.replaceState({ - alertState: nextState, - }); } }; From ee4b72ee29cbfef9e5b0ec052e03de914dc2aa21 Mon Sep 17 00:00:00 2001 From: Luke Elmers Date: Thu, 11 Feb 2021 16:00:01 -0700 Subject: [PATCH 36/53] [7.x] Support `pit` and `search_after` in server `savedObjects.find` (#89915) (#91217) --- ...gin-core-public.savedobjectsfindoptions.md | 2 + ...core-public.savedobjectsfindoptions.pit.md | 13 + ...lic.savedobjectsfindoptions.searchafter.md | 13 + .../core/server/kibana-plugin-core-server.md | 5 + ...ver.savedobjectsclient.closepointintime.md | 25 + ...a-plugin-core-server.savedobjectsclient.md | 2 + ...vedobjectsclient.openpointintimefortype.md | 25 + ...ver.savedobjectsclosepointintimeoptions.md | 12 + ...er.savedobjectsclosepointintimeresponse.md | 20 + ...jectsclosepointintimeresponse.num_freed.md | 13 + ...jectsclosepointintimeresponse.succeeded.md | 13 + ...rver.savedobjectsexporter._constructor_.md | 5 +- ...plugin-core-server.savedobjectsexporter.md | 2 +- ...gin-core-server.savedobjectsfindoptions.md | 2 + ...core-server.savedobjectsfindoptions.pit.md | 13 + ...ver.savedobjectsfindoptions.searchafter.md | 13 + ...in-core-server.savedobjectsfindresponse.md | 1 + ...-server.savedobjectsfindresponse.pit_id.md | 11 + ...ugin-core-server.savedobjectsfindresult.md | 1 + ...core-server.savedobjectsfindresult.sort.md | 41 ++ ...objectsopenpointintimeoptions.keepalive.md | 13 + ...rver.savedobjectsopenpointintimeoptions.md | 20 + ...bjectsopenpointintimeoptions.preference.md | 13 + ....savedobjectsopenpointintimeresponse.id.md | 13 + ...ver.savedobjectsopenpointintimeresponse.md | 19 + ...in-core-server.savedobjectspitparams.id.md | 11 + ...-server.savedobjectspitparams.keepalive.md | 11 + ...lugin-core-server.savedobjectspitparams.md | 20 + ...savedobjectsrepository.closepointintime.md | 58 ++ ...ugin-core-server.savedobjectsrepository.md | 2 + ...bjectsrepository.openpointintimefortype.md | 57 ++ ...-plugin-core-server.searchresponse.hits.md | 2 +- ...ibana-plugin-core-server.searchresponse.md | 3 +- ...lugin-core-server.searchresponse.pit_id.md | 11 + docs/user/security/audit-logging.asciidoc | 8 + src/core/public/public.api.md | 4 + .../saved_objects/saved_objects_client.ts | 11 +- src/core/server/elasticsearch/client/types.ts | 3 +- src/core/server/index.ts | 5 + .../export/point_in_time_finder.test.ts | 321 +++++++++++ .../export/point_in_time_finder.ts | 192 +++++++ .../export/saved_objects_exporter.test.ts | 512 +++++++++++++----- .../export/saved_objects_exporter.ts | 35 +- .../saved_objects/saved_objects_service.ts | 1 + .../service/lib/repository.mock.ts | 2 + .../service/lib/repository.test.js | 165 ++++++ .../saved_objects/service/lib/repository.ts | 138 ++++- .../service/lib/repository_es_client.ts | 2 + .../service/lib/search_dsl/pit_params.test.ts | 28 + .../service/lib/search_dsl/pit_params.ts | 18 + .../service/lib/search_dsl/search_dsl.test.ts | 35 +- .../service/lib/search_dsl/search_dsl.ts | 10 +- .../lib/search_dsl/sorting_params.test.ts | 26 + .../service/lib/search_dsl/sorting_params.ts | 12 +- .../service/saved_objects_client.mock.ts | 2 + .../service/saved_objects_client.test.js | 30 + .../service/saved_objects_client.ts | 95 ++++ src/core/server/saved_objects/types.ts | 16 + src/core/server/server.api.md | 44 +- src/core/server/types.ts | 1 + src/plugins/data/server/server.api.md | 2 +- .../apis/saved_objects/export.ts | 248 +++++---- .../apis/saved_objects/import.ts | 4 +- .../saved_objects/resolve_import_errors.ts | 6 +- test/api_integration/config.js | 1 + ...ypted_saved_objects_client_wrapper.test.ts | 62 +++ .../encrypted_saved_objects_client_wrapper.ts | 13 + .../security/server/audit/audit_events.ts | 14 + .../feature_privilege_builder/saved_object.ts | 8 +- .../privileges/privileges.test.ts | 212 ++++++++ ...ecure_saved_objects_client_wrapper.test.ts | 69 +++ .../secure_saved_objects_client_wrapper.ts | 58 ++ .../spaces_saved_objects_client.test.ts | 52 ++ .../spaces_saved_objects_client.ts | 40 ++ 74 files changed, 2723 insertions(+), 267 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.pit.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.closepointintime.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.pit.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.sort.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.id.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.keepalive.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.closepointintime.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.searchresponse.pit_id.md create mode 100644 src/core/server/saved_objects/export/point_in_time_finder.test.ts create mode 100644 src/core/server/saved_objects/export/point_in_time_finder.ts create mode 100644 src/core/server/saved_objects/service/lib/search_dsl/pit_params.test.ts create mode 100644 src/core/server/saved_objects/service/lib/search_dsl/pit_params.ts diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md index 8bd87c2f6ea35..69cfb818561e5 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md @@ -23,9 +23,11 @@ export interface SavedObjectsFindOptions | [namespaces](./kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md) | string[] | | | [page](./kibana-plugin-core-public.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-core-public.savedobjectsfindoptions.perpage.md) | number | | +| [pit](./kibana-plugin-core-public.savedobjectsfindoptions.pit.md) | SavedObjectsPitParams | Search against a specific Point In Time (PIT) that you've opened with . | | [preference](./kibana-plugin-core-public.savedobjectsfindoptions.preference.md) | string | An optional ES preference value to be used for the query \* | | [rootSearchFields](./kibana-plugin-core-public.savedobjectsfindoptions.rootsearchfields.md) | string[] | The fields to perform the parsed query against. Unlike the searchFields argument, these are expected to be root fields and will not be modified. If used in conjunction with searchFields, both are concatenated together. | | [search](./kibana-plugin-core-public.savedobjectsfindoptions.search.md) | string | Search documents using the Elasticsearch Simple Query String syntax. See Elasticsearch Simple Query String query argument for more information | +| [searchAfter](./kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md) | unknown[] | Use the sort values from the previous page to retrieve the next page of results. | | [searchFields](./kibana-plugin-core-public.savedobjectsfindoptions.searchfields.md) | string[] | The fields to perform the parsed query against. See Elasticsearch Simple Query String fields argument for more information | | [sortField](./kibana-plugin-core-public.savedobjectsfindoptions.sortfield.md) | string | | | [sortOrder](./kibana-plugin-core-public.savedobjectsfindoptions.sortorder.md) | string | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.pit.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.pit.md new file mode 100644 index 0000000000000..2284a4d8d210d --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.pit.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) > [pit](./kibana-plugin-core-public.savedobjectsfindoptions.pit.md) + +## SavedObjectsFindOptions.pit property + +Search against a specific Point In Time (PIT) that you've opened with . + +Signature: + +```typescript +pit?: SavedObjectsPitParams; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md new file mode 100644 index 0000000000000..99ca2c34e77be --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) > [searchAfter](./kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md) + +## SavedObjectsFindOptions.searchAfter property + +Use the sort values from the previous page to retrieve the next page of results. + +Signature: + +```typescript +searchAfter?: unknown[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 5fe5eda7a8172..1791335d58fef 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -155,6 +155,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsCheckConflictsResponse](./kibana-plugin-core-server.savedobjectscheckconflictsresponse.md) | | | [SavedObjectsClientProviderOptions](./kibana-plugin-core-server.savedobjectsclientprovideroptions.md) | Options to control the creation of the Saved Objects Client. | | [SavedObjectsClientWrapperOptions](./kibana-plugin-core-server.savedobjectsclientwrapperoptions.md) | Options passed to each SavedObjectsClientWrapperFactory to aid in creating the wrapper instance. | +| [SavedObjectsClosePointInTimeResponse](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md) | | | [SavedObjectsComplexFieldMapping](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation. | | [SavedObjectsCoreFieldMapping](./kibana-plugin-core-server.savedobjectscorefieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation. | | [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) | | @@ -188,6 +189,9 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsMappingProperties](./kibana-plugin-core-server.savedobjectsmappingproperties.md) | Describe the fields of a [saved object type](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md). | | [SavedObjectsMigrationLogger](./kibana-plugin-core-server.savedobjectsmigrationlogger.md) | | | [SavedObjectsMigrationVersion](./kibana-plugin-core-server.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [SavedObjectsOpenPointInTimeOptions](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md) | | +| [SavedObjectsOpenPointInTimeResponse](./kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md) | | +| [SavedObjectsPitParams](./kibana-plugin-core-server.savedobjectspitparams.md) | | | [SavedObjectsRawDoc](./kibana-plugin-core-server.savedobjectsrawdoc.md) | A raw document as represented directly in the saved object index. | | [SavedObjectsRawDocParseOptions](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.md) | Options that can be specified when using the saved objects serializer to parse a raw document. | | [SavedObjectsRemoveReferencesToOptions](./kibana-plugin-core-server.savedobjectsremovereferencestooptions.md) | | @@ -301,6 +305,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md) | Describes the factory used to create instances of the Saved Objects Client. | | [SavedObjectsClientFactoryProvider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) | Provider to invoke to retrieve a [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md). | | [SavedObjectsClientWrapperFactory](./kibana-plugin-core-server.savedobjectsclientwrapperfactory.md) | Describes the factory used to create instances of Saved Objects Client Wrappers. | +| [SavedObjectsClosePointInTimeOptions](./kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md) | | | [SavedObjectsExportTransform](./kibana-plugin-core-server.savedobjectsexporttransform.md) | Transformation function used to mutate the exported objects of the associated type.A type's export transform function will be executed once per user-initiated export, for all objects of that type. | | [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation | | [SavedObjectsImportHook](./kibana-plugin-core-server.savedobjectsimporthook.md) | A hook associated with a specific saved object type, that will be invoked during the import process. The hook will have access to the objects of the registered type.Currently, the only supported feature for import hooks is to return warnings to be displayed in the UI when the import succeeds. The only interactions the hook can have with the import process is via the hook's response. Mutating the objects inside the hook's code will have no effect. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.closepointintime.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.closepointintime.md new file mode 100644 index 0000000000000..dc765260a08ca --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.closepointintime.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [closePointInTime](./kibana-plugin-core-server.savedobjectsclient.closepointintime.md) + +## SavedObjectsClient.closePointInTime() method + +Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). + +Signature: + +```typescript +closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| id | string | | +| options | SavedObjectsClosePointInTimeOptions | | + +Returns: + +`Promise` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md index da1f4d029ea2b..887f7f7d93a87 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md @@ -30,11 +30,13 @@ The constructor for this class is marked as internal. Third-party code should no | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkget.md) | | Returns an array of objects by id | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkupdate.md) | | Bulk Updates multiple SavedObject at once | | [checkConflicts(objects, options)](./kibana-plugin-core-server.savedobjectsclient.checkconflicts.md) | | Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. | +| [closePointInTime(id, options)](./kibana-plugin-core-server.savedobjectsclient.closepointintime.md) | | Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.create.md) | | Persists a SavedObject | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.delete.md) | | Deletes a SavedObject | | [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md) | | Removes namespaces from a SavedObject | | [find(options)](./kibana-plugin-core-server.savedobjectsclient.find.md) | | Find all SavedObjects matching the search query | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.get.md) | | Retrieves a single object | +| [openPointInTimeForType(type, options)](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md) | | Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned id can then be passed to [SavedObjectsClient.find()](./kibana-plugin-core-server.savedobjectsclient.find.md) to search against that PIT. | | [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.removereferencesto.md) | | Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. | | [resolve(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.resolve.md) | | Resolves a single object, using any legacy URL alias if it exists | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.update.md) | | Updates an SavedObject | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md new file mode 100644 index 0000000000000..56c1d6d1ddc33 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [openPointInTimeForType](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md) + +## SavedObjectsClient.openPointInTimeForType() method + +Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned `id` can then be passed to [SavedObjectsClient.find()](./kibana-plugin-core-server.savedobjectsclient.find.md) to search against that PIT. + +Signature: + +```typescript +openPointInTimeForType(type: string | string[], options?: SavedObjectsOpenPointInTimeOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | string[] | | +| options | SavedObjectsOpenPointInTimeOptions | | + +Returns: + +`Promise` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md new file mode 100644 index 0000000000000..27432a8805b06 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClosePointInTimeOptions](./kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md) + +## SavedObjectsClosePointInTimeOptions type + + +Signature: + +```typescript +export declare type SavedObjectsClosePointInTimeOptions = SavedObjectsBaseOptions; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md new file mode 100644 index 0000000000000..43ecd1298d5d9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClosePointInTimeResponse](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md) + +## SavedObjectsClosePointInTimeResponse interface + + +Signature: + +```typescript +export interface SavedObjectsClosePointInTimeResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [num\_freed](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md) | number | The number of search contexts that have been successfully closed. | +| [succeeded](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md) | boolean | If true, all search contexts associated with the PIT id are successfully closed. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md new file mode 100644 index 0000000000000..b64932fcee8f6 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClosePointInTimeResponse](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md) > [num\_freed](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md) + +## SavedObjectsClosePointInTimeResponse.num\_freed property + +The number of search contexts that have been successfully closed. + +Signature: + +```typescript +num_freed: number; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md new file mode 100644 index 0000000000000..225a549a4cf59 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClosePointInTimeResponse](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md) > [succeeded](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md) + +## SavedObjectsClosePointInTimeResponse.succeeded property + +If true, all search contexts associated with the PIT id are successfully closed. + +Signature: + +```typescript +succeeded: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter._constructor_.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter._constructor_.md index 5e959bbee7beb..3f3d708c590ee 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter._constructor_.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter._constructor_.md @@ -9,10 +9,11 @@ Constructs a new instance of the `SavedObjectsExporter` class Signature: ```typescript -constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, }: { +constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, logger, }: { savedObjectsClient: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; exportSizeLimit: number; + logger: Logger; }); ``` @@ -20,5 +21,5 @@ constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, }: { | Parameter | Type | Description | | --- | --- | --- | -| { savedObjectsClient, typeRegistry, exportSizeLimit, } | {
savedObjectsClient: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
exportSizeLimit: number;
} | | +| { savedObjectsClient, typeRegistry, exportSizeLimit, logger, } | {
savedObjectsClient: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
exportSizeLimit: number;
logger: Logger;
} | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter.md index 727108b824c84..ce23e91633b07 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter.md @@ -15,7 +15,7 @@ export declare class SavedObjectsExporter | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)({ savedObjectsClient, typeRegistry, exportSizeLimit, })](./kibana-plugin-core-server.savedobjectsexporter._constructor_.md) | | Constructs a new instance of the SavedObjectsExporter class | +| [(constructor)({ savedObjectsClient, typeRegistry, exportSizeLimit, logger, })](./kibana-plugin-core-server.savedobjectsexporter._constructor_.md) | | Constructs a new instance of the SavedObjectsExporter class | ## Properties diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md index d393d579dbdd2..6f7c05ea469bc 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md @@ -23,9 +23,11 @@ export interface SavedObjectsFindOptions | [namespaces](./kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md) | string[] | | | [page](./kibana-plugin-core-server.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-core-server.savedobjectsfindoptions.perpage.md) | number | | +| [pit](./kibana-plugin-core-server.savedobjectsfindoptions.pit.md) | SavedObjectsPitParams | Search against a specific Point In Time (PIT) that you've opened with [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). | | [preference](./kibana-plugin-core-server.savedobjectsfindoptions.preference.md) | string | An optional ES preference value to be used for the query \* | | [rootSearchFields](./kibana-plugin-core-server.savedobjectsfindoptions.rootsearchfields.md) | string[] | The fields to perform the parsed query against. Unlike the searchFields argument, these are expected to be root fields and will not be modified. If used in conjunction with searchFields, both are concatenated together. | | [search](./kibana-plugin-core-server.savedobjectsfindoptions.search.md) | string | Search documents using the Elasticsearch Simple Query String syntax. See Elasticsearch Simple Query String query argument for more information | +| [searchAfter](./kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md) | unknown[] | Use the sort values from the previous page to retrieve the next page of results. | | [searchFields](./kibana-plugin-core-server.savedobjectsfindoptions.searchfields.md) | string[] | The fields to perform the parsed query against. See Elasticsearch Simple Query String fields argument for more information | | [sortField](./kibana-plugin-core-server.savedobjectsfindoptions.sortfield.md) | string | | | [sortOrder](./kibana-plugin-core-server.savedobjectsfindoptions.sortorder.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.pit.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.pit.md new file mode 100644 index 0000000000000..fac333227088c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.pit.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) > [pit](./kibana-plugin-core-server.savedobjectsfindoptions.pit.md) + +## SavedObjectsFindOptions.pit property + +Search against a specific Point In Time (PIT) that you've opened with [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). + +Signature: + +```typescript +pit?: SavedObjectsPitParams; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md new file mode 100644 index 0000000000000..6364370948976 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) > [searchAfter](./kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md) + +## SavedObjectsFindOptions.searchAfter property + +Use the sort values from the previous page to retrieve the next page of results. + +Signature: + +```typescript +searchAfter?: unknown[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md index 4ed069d1598fe..fd56e8ce40e24 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md @@ -20,6 +20,7 @@ export interface SavedObjectsFindResponse | --- | --- | --- | | [page](./kibana-plugin-core-server.savedobjectsfindresponse.page.md) | number | | | [per\_page](./kibana-plugin-core-server.savedobjectsfindresponse.per_page.md) | number | | +| [pit\_id](./kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md) | string | | | [saved\_objects](./kibana-plugin-core-server.savedobjectsfindresponse.saved_objects.md) | Array<SavedObjectsFindResult<T>> | | | [total](./kibana-plugin-core-server.savedobjectsfindresponse.total.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md new file mode 100644 index 0000000000000..dc4f9b509d606 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindResponse](./kibana-plugin-core-server.savedobjectsfindresponse.md) > [pit\_id](./kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md) + +## SavedObjectsFindResponse.pit\_id property + +Signature: + +```typescript +pit_id?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md index e455074a7d11b..0f8e9c59236bb 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md @@ -16,4 +16,5 @@ export interface SavedObjectsFindResult extends SavedObject | Property | Type | Description | | --- | --- | --- | | [score](./kibana-plugin-core-server.savedobjectsfindresult.score.md) | number | The Elasticsearch _score of this result. | +| [sort](./kibana-plugin-core-server.savedobjectsfindresult.sort.md) | unknown[] | The Elasticsearch sort value of this result. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.sort.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.sort.md new file mode 100644 index 0000000000000..3cc02c404c8d7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.sort.md @@ -0,0 +1,41 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindResult](./kibana-plugin-core-server.savedobjectsfindresult.md) > [sort](./kibana-plugin-core-server.savedobjectsfindresult.sort.md) + +## SavedObjectsFindResult.sort property + +The Elasticsearch `sort` value of this result. + +Signature: + +```typescript +sort?: unknown[]; +``` + +## Remarks + +This can be passed directly to the `searchAfter` param in the [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) in order to page through large numbers of hits. It is recommended you use this alongside a Point In Time (PIT) that was opened with [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). + +## Example + + +```ts +const { id } = await savedObjectsClient.openPointInTimeForType('visualization'); +const page1 = await savedObjectsClient.find({ + type: 'visualization', + sortField: 'updated_at', + sortOrder: 'asc', + pit, +}); +const lastHit = page1.saved_objects[page1.saved_objects.length - 1]; +const page2 = await savedObjectsClient.find({ + type: 'visualization', + sortField: 'updated_at', + sortOrder: 'asc', + pit: { id: page1.pit_id }, + searchAfter: lastHit.sort, +}); +await savedObjectsClient.closePointInTime(page2.pit_id); + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md new file mode 100644 index 0000000000000..57752318cb96a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsOpenPointInTimeOptions](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md) > [keepAlive](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md) + +## SavedObjectsOpenPointInTimeOptions.keepAlive property + +Optionally specify how long ES should keep the PIT alive until the next request. Defaults to `5m`. + +Signature: + +```typescript +keepAlive?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md new file mode 100644 index 0000000000000..46516be2329e9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsOpenPointInTimeOptions](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md) + +## SavedObjectsOpenPointInTimeOptions interface + + +Signature: + +```typescript +export interface SavedObjectsOpenPointInTimeOptions extends SavedObjectsBaseOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [keepAlive](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md) | string | Optionally specify how long ES should keep the PIT alive until the next request. Defaults to 5m. | +| [preference](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md) | string | An optional ES preference value to be used for the query. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md new file mode 100644 index 0000000000000..7a9f3a49e8663 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsOpenPointInTimeOptions](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md) > [preference](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md) + +## SavedObjectsOpenPointInTimeOptions.preference property + +An optional ES preference value to be used for the query. + +Signature: + +```typescript +preference?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md new file mode 100644 index 0000000000000..66387e5b3b89f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsOpenPointInTimeResponse](./kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md) > [id](./kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md) + +## SavedObjectsOpenPointInTimeResponse.id property + +PIT ID returned from ES. + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md new file mode 100644 index 0000000000000..c4be2692763a5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsOpenPointInTimeResponse](./kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md) + +## SavedObjectsOpenPointInTimeResponse interface + + +Signature: + +```typescript +export interface SavedObjectsOpenPointInTimeResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md) | string | PIT ID returned from ES. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.id.md new file mode 100644 index 0000000000000..cb4d4a65727d7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsPitParams](./kibana-plugin-core-server.savedobjectspitparams.md) > [id](./kibana-plugin-core-server.savedobjectspitparams.id.md) + +## SavedObjectsPitParams.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.keepalive.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.keepalive.md new file mode 100644 index 0000000000000..1011a908f210a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.keepalive.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsPitParams](./kibana-plugin-core-server.savedobjectspitparams.md) > [keepAlive](./kibana-plugin-core-server.savedobjectspitparams.keepalive.md) + +## SavedObjectsPitParams.keepAlive property + +Signature: + +```typescript +keepAlive?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.md new file mode 100644 index 0000000000000..7bffca7cda281 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsPitParams](./kibana-plugin-core-server.savedobjectspitparams.md) + +## SavedObjectsPitParams interface + + +Signature: + +```typescript +export interface SavedObjectsPitParams +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-core-server.savedobjectspitparams.id.md) | string | | +| [keepAlive](./kibana-plugin-core-server.savedobjectspitparams.keepalive.md) | string | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.closepointintime.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.closepointintime.md new file mode 100644 index 0000000000000..8f9dca35fa362 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.closepointintime.md @@ -0,0 +1,58 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [closePointInTime](./kibana-plugin-core-server.savedobjectsrepository.closepointintime.md) + +## SavedObjectsRepository.closePointInTime() method + +Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using `openPointInTimeForType`. + +Signature: + +```typescript +closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| id | string | | +| options | SavedObjectsClosePointInTimeOptions | | + +Returns: + +`Promise` + +{promise} - [SavedObjectsClosePointInTimeResponse](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md) + +## Remarks + +While the `keepAlive` that is provided will cause a PIT to automatically close, it is highly recommended to explicitly close a PIT when you are done with it in order to avoid consuming unneeded resources in Elasticsearch. + +## Example + + +```ts +const repository = coreStart.savedObjects.createInternalRepository(); + +const { id } = await repository.openPointInTimeForType( + type: 'index-pattern', + { keepAlive: '2m' }, +); + +const response = await repository.find({ + type: 'index-pattern', + search: 'foo*', + sortField: 'name', + sortOrder: 'desc', + pit: { + id: 'abc123', + keepAlive: '2m', + }, + searchAfter: [1234, 'abcd'], +}); + +await repository.closePointInTime(response.pit_id); + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md index 4d13fea12572c..632d9c279cb88 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -20,6 +20,7 @@ export declare class SavedObjectsRepository | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkget.md) | | Returns an array of objects by id | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkupdate.md) | | Updates multiple objects in bulk | | [checkConflicts(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md) | | Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. | +| [closePointInTime(id, options)](./kibana-plugin-core-server.savedobjectsrepository.closepointintime.md) | | Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using openPointInTimeForType. | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.create.md) | | Persists an object | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.delete.md) | | Deletes an object | | [deleteByNamespace(namespace, options)](./kibana-plugin-core-server.savedobjectsrepository.deletebynamespace.md) | | Deletes all objects from the provided namespace. | @@ -27,6 +28,7 @@ export declare class SavedObjectsRepository | [find(options)](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object | | [incrementCounter(type, id, counterFields, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increments all the specified counter fields (by one by default). Creates the document if one doesn't exist for the given id. | +| [openPointInTimeForType(type, { keepAlive, preference })](./kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md) | | Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned id can then be passed to SavedObjects.find to search against that PIT. | | [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.removereferencesto.md) | | Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. | | [resolve(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.resolve.md) | | Resolves a single object, using any legacy URL alias if it exists | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.update.md) | | Updates an object | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md new file mode 100644 index 0000000000000..63956ebee68f7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md @@ -0,0 +1,57 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [openPointInTimeForType](./kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md) + +## SavedObjectsRepository.openPointInTimeForType() method + +Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned `id` can then be passed to `SavedObjects.find` to search against that PIT. + +Signature: + +```typescript +openPointInTimeForType(type: string | string[], { keepAlive, preference }?: SavedObjectsOpenPointInTimeOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | string[] | | +| { keepAlive, preference } | SavedObjectsOpenPointInTimeOptions | | + +Returns: + +`Promise` + +{promise} - { id: string } + +## Example + + +```ts +const repository = coreStart.savedObjects.createInternalRepository(); + +const { id } = await repository.openPointInTimeForType( + type: 'index-pattern', + { keepAlive: '2m' }, +); +const page1 = await savedObjectsClient.find({ + type: 'visualization', + sortField: 'updated_at', + sortOrder: 'asc', + pit, +}); + +const lastHit = page1.saved_objects[page1.saved_objects.length - 1]; +const page2 = await savedObjectsClient.find({ + type: 'visualization', + sortField: 'updated_at', + sortOrder: 'asc', + pit: { id: page1.pit_id }, + searchAfter: lastHit.sort, +}); + +await savedObjectsClient.closePointInTime(page2.pit_id); + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.searchresponse.hits.md b/docs/development/core/server/kibana-plugin-core-server.searchresponse.hits.md index 1629e77425525..599c4e3ad6319 100644 --- a/docs/development/core/server/kibana-plugin-core-server.searchresponse.hits.md +++ b/docs/development/core/server/kibana-plugin-core-server.searchresponse.hits.md @@ -22,7 +22,7 @@ hits: { highlight?: any; inner_hits?: any; matched_queries?: string[]; - sort?: string[]; + sort?: unknown[]; }>; }; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.searchresponse.md b/docs/development/core/server/kibana-plugin-core-server.searchresponse.md index b53cbf0d87f24..cbaab4632014d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.searchresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.searchresponse.md @@ -18,7 +18,8 @@ export interface SearchResponse | [\_scroll\_id](./kibana-plugin-core-server.searchresponse._scroll_id.md) | string | | | [\_shards](./kibana-plugin-core-server.searchresponse._shards.md) | ShardsResponse | | | [aggregations](./kibana-plugin-core-server.searchresponse.aggregations.md) | any | | -| [hits](./kibana-plugin-core-server.searchresponse.hits.md) | {
total: number;
max_score: number;
hits: Array<{
_index: string;
_type: string;
_id: string;
_score: number;
_source: T;
_version?: number;
_explanation?: Explanation;
fields?: any;
highlight?: any;
inner_hits?: any;
matched_queries?: string[];
sort?: string[];
}>;
} | | +| [hits](./kibana-plugin-core-server.searchresponse.hits.md) | {
total: number;
max_score: number;
hits: Array<{
_index: string;
_type: string;
_id: string;
_score: number;
_source: T;
_version?: number;
_explanation?: Explanation;
fields?: any;
highlight?: any;
inner_hits?: any;
matched_queries?: string[];
sort?: unknown[];
}>;
} | | +| [pit\_id](./kibana-plugin-core-server.searchresponse.pit_id.md) | string | | | [timed\_out](./kibana-plugin-core-server.searchresponse.timed_out.md) | boolean | | | [took](./kibana-plugin-core-server.searchresponse.took.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-core-server.searchresponse.pit_id.md b/docs/development/core/server/kibana-plugin-core-server.searchresponse.pit_id.md new file mode 100644 index 0000000000000..f214bc0538045 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.searchresponse.pit_id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SearchResponse](./kibana-plugin-core-server.searchresponse.md) > [pit\_id](./kibana-plugin-core-server.searchresponse.pit_id.md) + +## SearchResponse.pit\_id property + +Signature: + +```typescript +pit_id?: string; +``` diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index 12a87b1422c5c..b9fc0c9c4ac46 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -85,6 +85,10 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is creating a saved object. | `failure` | User is not authorized to create a saved object. +.2+| `saved_object_open_point_in_time` +| `unknown` | User is creating a Point In Time to use when querying saved objects. +| `failure` | User is not authorized to create a Point In Time for the provided saved object types. + .2+| `connector_create` | `unknown` | User is creating a connector. | `failure` | User is not authorized to create a connector. @@ -171,6 +175,10 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is deleting a saved object. | `failure` | User is not authorized to delete a saved object. +.2+| `saved_object_close_point_in_time` +| `unknown` | User is deleting a Point In Time that was used to query saved objects. +| `failure` | User is not authorized to delete a Point In Time. + .2+| `connector_delete` | `unknown` | User is deleting a connector. | `failure` | User is not authorized to delete a connector. diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index b4a2c40f3003b..0cf3e476d330a 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1204,9 +1204,13 @@ export interface SavedObjectsFindOptions { page?: number; // (undocumented) perPage?: number; + // Warning: (ae-forgotten-export) The symbol "SavedObjectsPitParams" needs to be exported by the entry point index.d.ts + // Warning: (ae-unresolved-link) The @link reference could not be resolved: No member was found with name "openPointInTimeForType" + pit?: SavedObjectsPitParams; preference?: string; rootSearchFields?: string[]; search?: string; + searchAfter?: unknown[]; searchFields?: string[]; // (undocumented) sortField?: string; diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 9c0a44b2d3da0..44466025de7e3 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -21,12 +21,14 @@ import { import { SimpleSavedObject } from './simple_saved_object'; import { HttpFetchOptions, HttpSetup } from '../http'; +type PromiseType> = T extends Promise ? U : never; + type SavedObjectsFindOptions = Omit< SavedObjectFindOptionsServer, - 'sortOrder' | 'rootSearchFields' | 'typeToNamespacesMap' + 'pit' | 'rootSearchFields' | 'searchAfter' | 'sortOrder' | 'typeToNamespacesMap' >; -type PromiseType> = T extends Promise ? U : never; +type SavedObjectsFindResponse = Omit>, 'pit_id'>; /** @public */ export interface SavedObjectsCreateOptions { @@ -345,10 +347,7 @@ export class SavedObjectsClient { query, }); return request.then((resp) => { - return renameKeys< - PromiseType>, - SavedObjectsFindResponsePublic - >( + return renameKeys( { saved_objects: 'savedObjects', total: 'total', diff --git a/src/core/server/elasticsearch/client/types.ts b/src/core/server/elasticsearch/client/types.ts index 2e99398efdfba..f5a6fa1f0b1fd 100644 --- a/src/core/server/elasticsearch/client/types.ts +++ b/src/core/server/elasticsearch/client/types.ts @@ -96,10 +96,11 @@ export interface SearchResponse { highlight?: any; inner_hits?: any; matched_queries?: string[]; - sort?: string[]; + sort?: unknown[]; }>; }; aggregations?: any; + pit_id?: string; } /** diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 6f478004c204e..dac2d210eb395 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -260,6 +260,8 @@ export { SavedObjectsClientWrapperOptions, SavedObjectsClientFactory, SavedObjectsClientFactoryProvider, + SavedObjectsClosePointInTimeOptions, + SavedObjectsClosePointInTimeResponse, SavedObjectsCreateOptions, SavedObjectsErrorHelpers, SavedObjectsExportResultDetails, @@ -277,6 +279,8 @@ export { SavedObjectsImportUnsupportedTypeError, SavedObjectMigrationContext, SavedObjectsMigrationLogger, + SavedObjectsOpenPointInTimeOptions, + SavedObjectsOpenPointInTimeResponse, SavedObjectsRawDoc, SavedObjectsRawDocParseOptions, SavedObjectSanitizedDoc, @@ -373,6 +377,7 @@ export { SavedObjectsClientContract, SavedObjectsFindOptions, SavedObjectsFindOptionsReference, + SavedObjectsPitParams, SavedObjectsMigrationVersion, } from './types'; diff --git a/src/core/server/saved_objects/export/point_in_time_finder.test.ts b/src/core/server/saved_objects/export/point_in_time_finder.test.ts new file mode 100644 index 0000000000000..cd79c7a4b81e5 --- /dev/null +++ b/src/core/server/saved_objects/export/point_in_time_finder.test.ts @@ -0,0 +1,321 @@ +/* + * 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 { savedObjectsClientMock } from '../service/saved_objects_client.mock'; +import { loggerMock, MockedLogger } from '../../logging/logger.mock'; +import { SavedObjectsFindOptions } from '../types'; +import { SavedObjectsFindResult } from '../service'; + +import { createPointInTimeFinder } from './point_in_time_finder'; + +const mockHits = [ + { + id: '2', + type: 'search', + attributes: {}, + score: 1, + references: [ + { + name: 'name', + type: 'visualization', + id: '1', + }, + ], + sort: [], + }, + { + id: '1', + type: 'visualization', + attributes: {}, + score: 1, + references: [], + sort: [], + }, +]; + +describe('createPointInTimeFinder()', () => { + let logger: MockedLogger; + let savedObjectsClient: ReturnType; + + beforeEach(() => { + logger = loggerMock.create(); + savedObjectsClient = savedObjectsClientMock.create(); + }); + + describe('#find', () => { + test('throws if a PIT is already open', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 1, + page: 1, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 1, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + await finder.find().next(); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + savedObjectsClient.find.mockClear(); + + expect(async () => { + await finder.find().next(); + }).rejects.toThrowErrorMatchingInlineSnapshot( + `"Point In Time has already been opened for this finder instance. Please call \`close()\` before calling \`find()\` again."` + ); + expect(savedObjectsClient.find).toHaveBeenCalledTimes(0); + }); + + test('works with a single page of results', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 2, + page: 0, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + } + + expect(hits.length).toBe(2); + expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }), + sortField: 'updated_at', + sortOrder: 'desc', + type: ['visualization'], + }) + ); + }); + + test('works with multiple pages of results', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [mockHits[0]], + pit_id: 'abc123', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [mockHits[1]], + pit_id: 'abc123', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [], + per_page: 1, + pit_id: 'abc123', + page: 0, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 1, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + } + + expect(hits.length).toBe(2); + expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + // called 3 times since we need a 3rd request to check if we + // are done paginating through results. + expect(savedObjectsClient.find).toHaveBeenCalledTimes(3); + expect(savedObjectsClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }), + sortField: 'updated_at', + sortOrder: 'desc', + type: ['visualization'], + }) + ); + }); + }); + + describe('#close', () => { + test('calls closePointInTime with correct ID', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'test', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + saved_objects: [mockHits[0]], + pit_id: 'test', + per_page: 1, + page: 0, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 2, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + await finder.close(); + } + + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledWith('test'); + }); + + test('causes generator to stop', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'test', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [mockHits[0]], + pit_id: 'test', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [mockHits[1]], + pit_id: 'test', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [], + per_page: 1, + pit_id: 'test', + page: 0, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 1, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + await finder.close(); + } + + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(hits.length).toBe(1); + }); + + test('is called if `find` throws an error', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'test', + }); + savedObjectsClient.find.mockRejectedValueOnce(new Error('oops')); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 2, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const hits: SavedObjectsFindResult[] = []; + try { + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + } + } catch (e) { + // intentionally empty + } + + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledWith('test'); + }); + + test('finder can be reused after closing', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 1, + page: 1, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 1, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + + const findA = finder.find(); + await findA.next(); + await finder.close(); + + const findB = finder.find(); + await findB.next(); + await finder.close(); + + expect((await findA.next()).done).toBe(true); + expect((await findB.next()).done).toBe(true); + expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(2); + expect(savedObjectsClient.find).toHaveBeenCalledTimes(2); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/core/server/saved_objects/export/point_in_time_finder.ts b/src/core/server/saved_objects/export/point_in_time_finder.ts new file mode 100644 index 0000000000000..dc0bac6b6bfd9 --- /dev/null +++ b/src/core/server/saved_objects/export/point_in_time_finder.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 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 { Logger } from '../../logging'; +import { SavedObjectsClientContract, SavedObjectsFindOptions } from '../types'; +import { SavedObjectsFindResponse } from '../service'; + +/** + * Returns a generator to help page through large sets of saved objects. + * + * The generator wraps calls to `SavedObjects.find` and iterates over + * multiple pages of results using `_pit` and `search_after`. This will + * open a new Point In Time (PIT), and continue paging until a set of + * results is received that's smaller than the designated `perPage`. + * + * Once you have retrieved all of the results you need, it is recommended + * to call `close()` to clean up the PIT and prevent Elasticsearch from + * consuming resources unnecessarily. This will automatically be done for + * you if you reach the last page of results. + * + * @example + * ```ts + * const findOptions: SavedObjectsFindOptions = { + * type: 'visualization', + * search: 'foo*', + * perPage: 100, + * }; + * + * const finder = createPointInTimeFinder({ + * logger, + * savedObjectsClient, + * findOptions, + * }); + * + * const responses: SavedObjectFindResponse[] = []; + * for await (const response of finder.find()) { + * responses.push(...response); + * if (doneSearching) { + * await finder.close(); + * } + * } + * ``` + */ +export function createPointInTimeFinder({ + findOptions, + logger, + savedObjectsClient, +}: { + findOptions: SavedObjectsFindOptions; + logger: Logger; + savedObjectsClient: SavedObjectsClientContract; +}) { + return new PointInTimeFinder({ findOptions, logger, savedObjectsClient }); +} + +/** + * @internal + */ +export class PointInTimeFinder { + readonly #log: Logger; + readonly #savedObjectsClient: SavedObjectsClientContract; + readonly #findOptions: SavedObjectsFindOptions; + #open: boolean = false; + #pitId?: string; + + constructor({ + findOptions, + logger, + savedObjectsClient, + }: { + findOptions: SavedObjectsFindOptions; + logger: Logger; + savedObjectsClient: SavedObjectsClientContract; + }) { + this.#log = logger; + this.#savedObjectsClient = savedObjectsClient; + this.#findOptions = { + // Default to 1000 items per page as a tradeoff between + // speed and memory consumption. + perPage: 1000, + ...findOptions, + }; + } + + async *find() { + if (this.#open) { + throw new Error( + 'Point In Time has already been opened for this finder instance. ' + + 'Please call `close()` before calling `find()` again.' + ); + } + + // Open PIT and request our first page of hits + await this.open(); + + let lastResultsCount: number; + let lastHitSortValue: unknown[] | undefined; + do { + const results = await this.findNext({ + findOptions: this.#findOptions, + id: this.#pitId, + ...(lastHitSortValue ? { searchAfter: lastHitSortValue } : {}), + }); + this.#pitId = results.pit_id; + lastResultsCount = results.saved_objects.length; + lastHitSortValue = this.getLastHitSortValue(results); + + this.#log.debug(`Collected [${lastResultsCount}] saved objects for export.`); + + // Close PIT if this was our last page + if (this.#pitId && lastResultsCount < this.#findOptions.perPage!) { + await this.close(); + } + + yield results; + // We've reached the end when there are fewer hits than our perPage size, + // or when `close()` has been called. + } while (this.#open && lastResultsCount >= this.#findOptions.perPage!); + + return; + } + + async close() { + try { + if (this.#pitId) { + this.#log.debug(`Closing PIT for types [${this.#findOptions.type}]`); + await this.#savedObjectsClient.closePointInTime(this.#pitId); + this.#pitId = undefined; + } + this.#open = false; + } catch (e) { + this.#log.error(`Failed to close PIT for types [${this.#findOptions.type}]`); + throw e; + } + } + + private async open() { + try { + const { id } = await this.#savedObjectsClient.openPointInTimeForType(this.#findOptions.type); + this.#pitId = id; + this.#open = true; + } catch (e) { + // Since `find` swallows 404s, it is expected that exporter will do the same, + // so we only rethrow non-404 errors here. + if (e.output.statusCode !== 404) { + throw e; + } + this.#log.debug(`Unable to open PIT for types [${this.#findOptions.type}]: 404 ${e}`); + } + } + + private async findNext({ + findOptions, + id, + searchAfter, + }: { + findOptions: SavedObjectsFindOptions; + id?: string; + searchAfter?: unknown[]; + }) { + try { + return await this.#savedObjectsClient.find({ + // Sort fields are required to use searchAfter, so we set some defaults here + sortField: 'updated_at', + sortOrder: 'desc', + // Bump keep_alive by 2m on every new request to allow for the ES client + // to make multiple retries in the event of a network failure. + ...(id ? { pit: { id, keepAlive: '2m' } } : {}), + ...(searchAfter ? { searchAfter } : {}), + ...findOptions, + }); + } catch (e) { + if (id) { + // Clean up PIT on any errors. + await this.close(); + } + throw e; + } + } + + private getLastHitSortValue(res: SavedObjectsFindResponse): unknown[] | undefined { + if (res.saved_objects.length < 1) { + return undefined; + } + return res.saved_objects[res.saved_objects.length - 1].sort; + } +} diff --git a/src/core/server/saved_objects/export/saved_objects_exporter.test.ts b/src/core/server/saved_objects/export/saved_objects_exporter.test.ts index c16623f785b08..cf60ada5ba90a 100644 --- a/src/core/server/saved_objects/export/saved_objects_exporter.test.ts +++ b/src/core/server/saved_objects/export/saved_objects_exporter.test.ts @@ -11,6 +11,7 @@ import { SavedObjectsExporter } from './saved_objects_exporter'; import { savedObjectsClientMock } from '../service/saved_objects_client.mock'; import { SavedObjectTypeRegistry } from '../saved_objects_type_registry'; import { httpServerMock } from '../../http/http_server.mocks'; +import { loggerMock, MockedLogger } from '../../logging/logger.mock'; import { Readable } from 'stream'; import { createPromiseFromStreams, createConcatStream } from '@kbn/utils'; @@ -18,18 +19,25 @@ async function readStreamToCompletion(stream: Readable): Promise { + let logger: MockedLogger; let savedObjectsClient: ReturnType; let typeRegistry: SavedObjectTypeRegistry; let exporter: SavedObjectsExporter; beforeEach(() => { + logger = loggerMock.create(); typeRegistry = new SavedObjectTypeRegistry(); savedObjectsClient = savedObjectsClientMock.create(); - exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit, typeRegistry }); + exporter = new SavedObjectsExporter({ + exportSizeLimit, + logger, + savedObjectsClient, + typeRegistry, + }); }); describe('#exportByTypes', () => { @@ -58,7 +66,7 @@ describe('getSortedObjectsForExport()', () => { references: [], }, ], - per_page: 1, + per_page: 1000, page: 0, }); const exportStream = await exporter.exportByTypes({ @@ -96,30 +104,232 @@ describe('getSortedObjectsForExport()', () => { ] `); expect(savedObjectsClient.find).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "hasReference": undefined, - "hasReferenceOperator": undefined, - "namespaces": undefined, - "perPage": 500, - "search": undefined, - "type": Array [ - "index-pattern", - "search", - ], - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, + [MockFunction] { + "calls": Array [ + Array [ + Object { + "hasReference": undefined, + "hasReferenceOperator": undefined, + "namespaces": undefined, + "perPage": 1000, + "pit": Object { + "id": "some_pit_id", + "keepAlive": "2m", + }, + "search": undefined, + "sortField": "updated_at", + "sortOrder": "desc", + "type": Array [ + "index-pattern", + "search", ], - } + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); + }); + + describe('pages through results with PIT', () => { + function generateHits( + hitCount: number, + { + attributes = {}, + sort = [], + type = 'index-pattern', + }: { + attributes?: Record; + sort?: unknown[]; + type?: string; + } = {} + ) { + const hits = []; + for (let i = 1; i <= hitCount; i++) { + hits.push({ + id: `${i}`, + type, + attributes, + sort, + score: 1, + references: [], + }); + } + return hits; + } + + describe('<1k hits', () => { + const mockHits = generateHits(20); + + test('requests a single page', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 20, + saved_objects: mockHits, + per_page: 1000, + page: 0, + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + const response = await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + expect(response[response.length - 1]).toMatchInlineSnapshot(` + Object { + "exportedCount": 20, + "missingRefCount": 0, + "missingReferences": Array [], + } + `); + }); + + test('opens and closes PIT', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 20, + saved_objects: mockHits, + per_page: 1000, + page: 0, + pit_id: 'abc123', + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + }); + + test('passes correct PIT ID to `find`', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 20, + saved_objects: mockHits, + per_page: 1000, + page: 0, + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }), + sortField: 'updated_at', + sortOrder: 'desc', + type: ['index-pattern'], + }) + ); + }); + }); + + describe('>1k hits', () => { + const firstMockHits = generateHits(1000, { sort: ['a', 'b'] }); + const secondMockHits = generateHits(500); + + test('requests multiple pages', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: firstMockHits, + per_page: 1000, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: secondMockHits, + per_page: 500, + page: 1, + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + const response = await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(2); + expect(response[response.length - 1]).toMatchInlineSnapshot(` + Object { + "exportedCount": 1500, + "missingRefCount": 0, + "missingReferences": Array [], + } `); + }); + + test('opens and closes PIT', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: firstMockHits, + per_page: 1000, + page: 0, + pit_id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: secondMockHits, + per_page: 500, + page: 1, + pit_id: 'abc123', + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + }); + + test('passes sort values to searchAfter', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: firstMockHits, + per_page: 1000, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: secondMockHits, + per_page: 500, + page: 1, + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.find.mock.calls[1][0]).toEqual( + expect.objectContaining({ + searchAfter: ['a', 'b'], + }) + ); + }); + }); }); test('applies the export transforms', async () => { @@ -138,7 +348,12 @@ describe('getSortedObjectsForExport()', () => { }, }, }); - exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit, typeRegistry }); + exporter = new SavedObjectsExporter({ + exportSizeLimit, + logger, + savedObjectsClient, + typeRegistry, + }); savedObjectsClient.find.mockResolvedValueOnce({ total: 1, @@ -233,30 +448,36 @@ describe('getSortedObjectsForExport()', () => { ] `); expect(savedObjectsClient.find).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "hasReference": undefined, - "hasReferenceOperator": undefined, - "namespaces": undefined, - "perPage": 500, - "search": undefined, - "type": Array [ - "index-pattern", - "search", - ], - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, + [MockFunction] { + "calls": Array [ + Array [ + Object { + "hasReference": undefined, + "hasReferenceOperator": undefined, + "namespaces": undefined, + "perPage": 1000, + "pit": Object { + "id": "some_pit_id", + "keepAlive": "2m", + }, + "search": undefined, + "sortField": "updated_at", + "sortOrder": "desc", + "type": Array [ + "index-pattern", + "search", ], - } - `); + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('exclude export details if option is specified', async () => { @@ -383,30 +604,36 @@ describe('getSortedObjectsForExport()', () => { ] `); expect(savedObjectsClient.find).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "hasReference": undefined, - "hasReferenceOperator": undefined, - "namespaces": undefined, - "perPage": 500, - "search": "foo", - "type": Array [ - "index-pattern", - "search", - ], - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, + [MockFunction] { + "calls": Array [ + Array [ + Object { + "hasReference": undefined, + "hasReferenceOperator": undefined, + "namespaces": undefined, + "perPage": 1000, + "pit": Object { + "id": "some_pit_id", + "keepAlive": "2m", + }, + "search": "foo", + "sortField": "updated_at", + "sortOrder": "desc", + "type": Array [ + "index-pattern", + "search", ], - } - `); + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('exports selected types with references when present', async () => { @@ -465,35 +692,41 @@ describe('getSortedObjectsForExport()', () => { ] `); expect(savedObjectsClient.find).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "hasReference": Array [ - Object { - "id": "1", - "type": "index-pattern", - }, - ], - "hasReferenceOperator": "OR", - "namespaces": undefined, - "perPage": 500, - "search": undefined, - "type": Array [ - "index-pattern", - "search", - ], - }, - ], - ], - "results": Array [ + [MockFunction] { + "calls": Array [ + Array [ + Object { + "hasReference": Array [ Object { - "type": "return", - "value": Promise {}, + "id": "1", + "type": "index-pattern", }, ], - } - `); + "hasReferenceOperator": "OR", + "namespaces": undefined, + "perPage": 1000, + "pit": Object { + "id": "some_pit_id", + "keepAlive": "2m", + }, + "search": undefined, + "sortField": "updated_at", + "sortOrder": "desc", + "type": Array [ + "index-pattern", + "search", + ], + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('exports from the provided namespace when present', async () => { @@ -521,7 +754,7 @@ describe('getSortedObjectsForExport()', () => { references: [], }, ], - per_page: 1, + per_page: 1000, page: 0, }); const exportStream = await exporter.exportByTypes({ @@ -560,36 +793,56 @@ describe('getSortedObjectsForExport()', () => { ] `); expect(savedObjectsClient.find).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "hasReference": undefined, - "hasReferenceOperator": undefined, - "namespaces": Array [ - "foo", - ], - "perPage": 500, - "search": undefined, - "type": Array [ - "index-pattern", - "search", - ], - }, - ], + [MockFunction] { + "calls": Array [ + Array [ + Object { + "hasReference": undefined, + "hasReferenceOperator": undefined, + "namespaces": Array [ + "foo", ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, + "perPage": 1000, + "pit": Object { + "id": "some_pit_id", + "keepAlive": "2m", + }, + "search": undefined, + "sortField": "updated_at", + "sortOrder": "desc", + "type": Array [ + "index-pattern", + "search", ], - } - `); + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('export selected types throws error when exceeding exportSizeLimit', async () => { - exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit: 1, typeRegistry }); + exporter = new SavedObjectsExporter({ + exportSizeLimit: 1, + logger, + savedObjectsClient, + typeRegistry, + }); + + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + + savedObjectsClient.closePointInTime.mockResolvedValueOnce({ + succeeded: true, + num_freed: 1, + }); savedObjectsClient.find.mockResolvedValueOnce({ total: 2, @@ -617,6 +870,7 @@ describe('getSortedObjectsForExport()', () => { ], per_page: 1, page: 0, + pit_id: 'abc123', }); await expect( exporter.exportByTypes({ @@ -624,12 +878,13 @@ describe('getSortedObjectsForExport()', () => { types: ['index-pattern', 'search'], }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"Can't export more than 1 objects"`); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); }); test('sorts objects within type', async () => { savedObjectsClient.find.mockResolvedValueOnce({ total: 3, - per_page: 10000, + per_page: 1000, page: 1, saved_objects: [ { @@ -836,7 +1091,12 @@ describe('getSortedObjectsForExport()', () => { }); test('export selected objects throws error when exceeding exportSizeLimit', async () => { - exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit: 1, typeRegistry }); + exporter = new SavedObjectsExporter({ + exportSizeLimit: 1, + logger, + savedObjectsClient, + typeRegistry, + }); const exportOpts = { request, diff --git a/src/core/server/saved_objects/export/saved_objects_exporter.ts b/src/core/server/saved_objects/export/saved_objects_exporter.ts index 295a3d7a119d4..c1c0ea73f0bd3 100644 --- a/src/core/server/saved_objects/export/saved_objects_exporter.ts +++ b/src/core/server/saved_objects/export/saved_objects_exporter.ts @@ -8,7 +8,9 @@ import { createListStream } from '@kbn/utils'; import { PublicMethodsOf } from '@kbn/utility-types'; -import { SavedObject, SavedObjectsClientContract } from '../types'; +import { Logger } from '../../logging'; +import { SavedObject, SavedObjectsClientContract, SavedObjectsFindOptions } from '../types'; +import { SavedObjectsFindResult } from '../service'; import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry'; import { fetchNestedDependencies } from './fetch_nested_dependencies'; import { sortObjects } from './sort_objects'; @@ -21,6 +23,7 @@ import { } from './types'; import { SavedObjectsExportError } from './errors'; import { applyExportTransforms } from './apply_export_transforms'; +import { createPointInTimeFinder } from './point_in_time_finder'; import { byIdAscComparator, getPreservedOrderComparator, SavedObjectComparator } from './utils'; /** @@ -35,16 +38,20 @@ export class SavedObjectsExporter { readonly #savedObjectsClient: SavedObjectsClientContract; readonly #exportTransforms: Record; readonly #exportSizeLimit: number; + readonly #log: Logger; constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, + logger, }: { savedObjectsClient: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; exportSizeLimit: number; + logger: Logger; }) { + this.#log = logger; this.#savedObjectsClient = savedObjectsClient; this.#exportSizeLimit = exportSizeLimit; this.#exportTransforms = typeRegistry.getAllTypes().reduce((transforms, type) => { @@ -66,6 +73,7 @@ export class SavedObjectsExporter { * @throws SavedObjectsExportError */ public async exportByTypes(options: SavedObjectsExportByTypeOptions) { + this.#log.debug(`Initiating export for types: [${options.types}]`); const objects = await this.fetchByTypes(options); return this.processObjects(objects, byIdAscComparator, { request: options.request, @@ -83,6 +91,7 @@ export class SavedObjectsExporter { * @throws SavedObjectsExportError */ public async exportByObjects(options: SavedObjectsExportByObjectOptions) { + this.#log.debug(`Initiating export of [${options.objects.length}] objects.`); if (options.objects.length > this.#exportSizeLimit) { throw SavedObjectsExportError.exportSizeExceeded(this.#exportSizeLimit); } @@ -106,6 +115,7 @@ export class SavedObjectsExporter { namespace, }: SavedObjectExportBaseOptions ) { + this.#log.debug(`Processing [${savedObjects.length}] saved objects.`); let exportedObjects: Array>; let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = []; @@ -117,6 +127,7 @@ export class SavedObjectsExporter { }); if (includeReferencesDeep) { + this.#log.debug(`Fetching saved objects references.`); const fetchResult = await fetchNestedDependencies( savedObjects, this.#savedObjectsClient, @@ -138,6 +149,7 @@ export class SavedObjectsExporter { missingRefCount: missingReferences.length, missingReferences, }; + this.#log.debug(`Exporting [${redactedObjects.length}] saved objects.`); return createListStream([...redactedObjects, ...(excludeExportDetails ? [] : [exportDetails])]); } @@ -156,21 +168,32 @@ export class SavedObjectsExporter { hasReference, search, }: SavedObjectsExportByTypeOptions) { - const findResponse = await this.#savedObjectsClient.find({ + const findOptions: SavedObjectsFindOptions = { type: types, hasReference, hasReferenceOperator: hasReference ? 'OR' : undefined, search, - perPage: this.#exportSizeLimit, namespaces: namespace ? [namespace] : undefined, + }; + + const finder = createPointInTimeFinder({ + findOptions, + logger: this.#log, + savedObjectsClient: this.#savedObjectsClient, }); - if (findResponse.total > this.#exportSizeLimit) { - throw SavedObjectsExportError.exportSizeExceeded(this.#exportSizeLimit); + + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + if (hits.length > this.#exportSizeLimit) { + await finder.close(); + throw SavedObjectsExportError.exportSizeExceeded(this.#exportSizeLimit); + } } // sorts server-side by _id, since it's only available in fielddata return ( - findResponse.saved_objects + hits // exclude the find-specific `score` property from the exported objects .map(({ score, ...obj }) => obj) .sort(byIdAscComparator) diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 4ad0a34acc2ef..fce7f12384456 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -459,6 +459,7 @@ export class SavedObjectsService savedObjectsClient, typeRegistry: this.typeRegistry, exportSizeLimit: this.config!.maxImportExportSize, + logger: this.logger.get('exporter'), }), createImporter: (savedObjectsClient) => new SavedObjectsImporter({ diff --git a/src/core/server/saved_objects/service/lib/repository.mock.ts b/src/core/server/saved_objects/service/lib/repository.mock.ts index c853e208f27aa..a3610b1e437e2 100644 --- a/src/core/server/saved_objects/service/lib/repository.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.mock.ts @@ -17,6 +17,8 @@ const create = (): jest.Mocked => ({ bulkGet: jest.fn(), find: jest.fn(), get: jest.fn(), + closePointInTime: jest.fn(), + openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }), resolve: jest.fn(), update: jest.fn(), addToNamespaces: jest.fn(), diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 0a1c18c01ad82..c31c0350fe041 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -2812,6 +2812,13 @@ describe('SavedObjectsRepository', () => { expect(client.search).not.toHaveBeenCalled(); }); + it(`throws when a preference is provided with pit`, async () => { + await expect( + savedObjectsRepository.find({ type: 'foo', pit: { id: 'abc123' }, preference: 'hi' }) + ).rejects.toThrowError('options.preference must be excluded when options.pit is used'); + expect(client.search).not.toHaveBeenCalled(); + }); + it(`throws when KQL filter syntax is invalid`, async () => { const findOpts = { namespaces: [namespace], @@ -2972,6 +2979,32 @@ describe('SavedObjectsRepository', () => { }); }); + it(`accepts searchAfter`, async () => { + const relevantOpts = { + ...commonOptions, + searchAfter: [1, 'a'], + }; + + await findSuccess(relevantOpts, namespace); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { + ...relevantOpts, + searchAfter: [1, 'a'], + }); + }); + + it(`accepts pit`, async () => { + const relevantOpts = { + ...commonOptions, + pit: { id: 'abc123', keepAlive: '2m' }, + }; + + await findSuccess(relevantOpts, namespace); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { + ...relevantOpts, + pit: { id: 'abc123', keepAlive: '2m' }, + }); + }); + it(`accepts KQL expression filter and passes KueryNode to getSearchDsl`, async () => { const findOpts = { namespaces: [namespace], @@ -4386,4 +4419,136 @@ describe('SavedObjectsRepository', () => { }); }); }); + + describe('#openPointInTimeForType', () => { + const type = 'index-pattern'; + + const generateResults = (id) => ({ id: id || null }); + const successResponse = async (type, options) => { + client.openPointInTime.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(generateResults()) + ); + const result = await savedObjectsRepository.openPointInTimeForType(type, options); + expect(client.openPointInTime).toHaveBeenCalledTimes(1); + return result; + }; + + describe('client calls', () => { + it(`should use the ES PIT API`, async () => { + await successResponse(type); + expect(client.openPointInTime).toHaveBeenCalledTimes(1); + }); + + it(`accepts preference`, async () => { + await successResponse(type, { preference: 'pref' }); + expect(client.openPointInTime).toHaveBeenCalledWith( + expect.objectContaining({ + preference: 'pref', + }), + expect.anything() + ); + }); + + it(`accepts keepAlive`, async () => { + await successResponse(type, { keepAlive: '2m' }); + expect(client.openPointInTime).toHaveBeenCalledWith( + expect.objectContaining({ + keep_alive: '2m', + }), + expect.anything() + ); + }); + + it(`defaults keepAlive to 5m`, async () => { + await successResponse(type); + expect(client.openPointInTime).toHaveBeenCalledWith( + expect.objectContaining({ + keep_alive: '5m', + }), + expect.anything() + ); + }); + }); + + describe('errors', () => { + const expectNotFoundError = async (types) => { + await expect(savedObjectsRepository.openPointInTimeForType(types)).rejects.toThrowError( + createGenericNotFoundError() + ); + }; + + it(`throws when ES is unable to find the index`, async () => { + client.openPointInTime.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + await expectNotFoundError(type); + expect(client.openPointInTime).toHaveBeenCalledTimes(1); + }); + + it(`should return generic not found error when attempting to find only invalid or hidden types`, async () => { + const test = async (types) => { + await expectNotFoundError(types); + expect(client.openPointInTime).not.toHaveBeenCalled(); + }; + + await test('unknownType'); + await test(HIDDEN_TYPE); + await test(['unknownType', HIDDEN_TYPE]); + }); + }); + + describe('returns', () => { + it(`returns id in the expected format`, async () => { + const id = 'abc123'; + const results = generateResults(id); + client.openPointInTime.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(results) + ); + const response = await savedObjectsRepository.openPointInTimeForType(type); + expect(response).toEqual({ id }); + }); + }); + }); + + describe('#closePointInTime', () => { + const generateResults = () => ({ succeeded: true, num_freed: 3 }); + const successResponse = async (id) => { + client.closePointInTime.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(generateResults()) + ); + const result = await savedObjectsRepository.closePointInTime(id); + expect(client.closePointInTime).toHaveBeenCalledTimes(1); + return result; + }; + + describe('client calls', () => { + it(`should use the ES PIT API`, async () => { + await successResponse('abc123'); + expect(client.closePointInTime).toHaveBeenCalledTimes(1); + }); + + it(`accepts id`, async () => { + await successResponse('abc123'); + expect(client.closePointInTime).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + id: 'abc123', + }), + }), + expect.anything() + ); + }); + }); + + describe('returns', () => { + it(`returns response body from ES`, async () => { + const results = generateResults('abc123'); + client.closePointInTime.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(results) + ); + const response = await savedObjectsRepository.closePointInTime('abc123'); + expect(response).toEqual(results); + }); + }); + }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index a662a374b063e..a368060024d34 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -36,6 +36,10 @@ import { SavedObjectsCreateOptions, SavedObjectsFindResponse, SavedObjectsFindResult, + SavedObjectsClosePointInTimeOptions, + SavedObjectsClosePointInTimeResponse, + SavedObjectsOpenPointInTimeOptions, + SavedObjectsOpenPointInTimeResponse, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, SavedObjectsBulkUpdateObject, @@ -706,11 +710,13 @@ export class SavedObjectsRepository { * Query field argument for more information * @property {integer} [options.page=1] * @property {integer} [options.perPage=20] + * @property {Array} [options.searchAfter] * @property {string} [options.sortField] * @property {string} [options.sortOrder] * @property {Array} [options.fields] * @property {string} [options.namespace] * @property {object} [options.hasReference] - { type, id } + * @property {string} [options.pit] * @property {string} [options.preference] * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } */ @@ -724,6 +730,8 @@ export class SavedObjectsRepository { hasReferenceOperator, page = FIND_DEFAULT_PAGE, perPage = FIND_DEFAULT_PER_PAGE, + pit, + searchAfter, sortField, sortOrder, fields, @@ -750,6 +758,10 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createBadRequestError( 'options.namespaces must be an empty array when options.typeToNamespacesMap is used' ); + } else if (preference?.length && pit) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.preference must be excluded when options.pit is used' + ); } const types = type @@ -785,20 +797,24 @@ export class SavedObjectsRepository { } const esOptions = { - index: this.getIndicesForTypes(allowedTypes), - size: perPage, - from: perPage * (page - 1), + // If `pit` is provided, we drop the `index`, otherwise ES returns 400. + ...(pit ? {} : { index: this.getIndicesForTypes(allowedTypes) }), + // If `searchAfter` is provided, we drop `from` as it will not be used for pagination. + ...(searchAfter ? {} : { from: perPage * (page - 1) }), _source: includedFields(type, fields), - rest_total_hits_as_int: true, preference, + rest_total_hits_as_int: true, + size: perPage, body: { seq_no_primary_term: true, ...getSearchDsl(this._mappings, this._registry, { search, defaultSearchOperator, searchFields, + pit, rootSearchFields, type: allowedTypes, + searchAfter, sortField, sortOrder, namespaces, @@ -832,8 +848,10 @@ export class SavedObjectsRepository { (hit: SavedObjectsRawDoc): SavedObjectsFindResult => ({ ...this._rawToSavedObject(hit), score: (hit as any)._score, + ...((hit as any).sort && { sort: (hit as any).sort }), }) ), + ...(body.pit_id && { pit_id: body.pit_id }), } as SavedObjectsFindResponse; } @@ -1759,6 +1777,118 @@ export class SavedObjectsRepository { }; } + /** + * Opens a Point In Time (PIT) against the indices for the specified Saved Object types. + * The returned `id` can then be passed to `SavedObjects.find` to search against that PIT. + * + * @example + * ```ts + * const { id } = await savedObjectsClient.openPointInTimeForType( + * type: 'visualization', + * { keepAlive: '5m' }, + * ); + * const page1 = await savedObjectsClient.find({ + * type: 'visualization', + * sortField: 'updated_at', + * sortOrder: 'asc', + * pit: { id, keepAlive: '2m' }, + * }); + * const lastHit = page1.saved_objects[page1.saved_objects.length - 1]; + * const page2 = await savedObjectsClient.find({ + * type: 'visualization', + * sortField: 'updated_at', + * sortOrder: 'asc', + * pit: { id: page1.pit_id }, + * searchAfter: lastHit.sort, + * }); + * await savedObjectsClient.closePointInTime(page2.pit_id); + * ``` + * + * @param {string|Array} type + * @param {object} [options] - {@link SavedObjectsOpenPointInTimeOptions} + * @property {string} [options.keepAlive] + * @property {string} [options.preference] + * @returns {promise} - { id: string } + */ + async openPointInTimeForType( + type: string | string[], + { keepAlive = '5m', preference }: SavedObjectsOpenPointInTimeOptions = {} + ): Promise { + const types = Array.isArray(type) ? type : [type]; + const allowedTypes = types.filter((t) => this._allowedTypes.includes(t)); + if (allowedTypes.length === 0) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(); + } + + const esOptions = { + index: this.getIndicesForTypes(allowedTypes), + keep_alive: keepAlive, + ...(preference ? { preference } : {}), + }; + + const { + body, + statusCode, + } = await this.client.openPointInTime(esOptions, { + ignore: [404], + }); + if (statusCode === 404) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(); + } + + return { + id: body.id, + }; + } + + /** + * Closes a Point In Time (PIT) by ID. This simply proxies the request to ES + * via the Elasticsearch client, and is included in the Saved Objects Client + * as a convenience for consumers who are using `openPointInTimeForType`. + * + * @remarks + * While the `keepAlive` that is provided will cause a PIT to automatically close, + * it is highly recommended to explicitly close a PIT when you are done with it + * in order to avoid consuming unneeded resources in Elasticsearch. + * + * @example + * ```ts + * const repository = coreStart.savedObjects.createInternalRepository(); + * + * const { id } = await repository.openPointInTimeForType( + * type: 'index-pattern', + * { keepAlive: '2m' }, + * ); + * + * const response = await repository.find({ + * type: 'index-pattern', + * search: 'foo*', + * sortField: 'name', + * sortOrder: 'desc', + * pit: { + * id: 'abc123', + * keepAlive: '2m', + * }, + * searchAfter: [1234, 'abcd'], + * }); + * + * await repository.closePointInTime(response.pit_id); + * ``` + * + * @param {string} id + * @param {object} [options] - {@link SavedObjectsClosePointInTimeOptions} + * @returns {promise} - {@link SavedObjectsClosePointInTimeResponse} + */ + async closePointInTime( + id: string, + options?: SavedObjectsClosePointInTimeOptions + ): Promise { + const { body } = await this.client.closePointInTime({ + body: { id }, + }); + return body; + } + /** * Returns index specified by the given type or the default index * diff --git a/src/core/server/saved_objects/service/lib/repository_es_client.ts b/src/core/server/saved_objects/service/lib/repository_es_client.ts index dae72819726ad..6a601b1ed0c83 100644 --- a/src/core/server/saved_objects/service/lib/repository_es_client.ts +++ b/src/core/server/saved_objects/service/lib/repository_es_client.ts @@ -14,11 +14,13 @@ import { decorateEsError } from './decorate_es_error'; const methods = [ 'bulk', + 'closePointInTime', 'create', 'delete', 'get', 'index', 'mget', + 'openPointInTime', 'search', 'update', 'updateByQuery', diff --git a/src/core/server/saved_objects/service/lib/search_dsl/pit_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/pit_params.test.ts new file mode 100644 index 0000000000000..5a99168792e83 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/search_dsl/pit_params.test.ts @@ -0,0 +1,28 @@ +/* + * 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 { getPitParams } from './pit_params'; + +describe('searchDsl/getPitParams', () => { + it('returns only an ID by default', () => { + expect(getPitParams({ id: 'abc123' })).toEqual({ + pit: { + id: 'abc123', + }, + }); + }); + + it('includes keepAlive if provided and rewrites to snake case', () => { + expect(getPitParams({ id: 'abc123', keepAlive: '2m' })).toEqual({ + pit: { + id: 'abc123', + keep_alive: '2m', + }, + }); + }); +}); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/pit_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/pit_params.ts new file mode 100644 index 0000000000000..1a8dcb5cca2e9 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/search_dsl/pit_params.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. + */ + +import { SavedObjectsPitParams } from '../../../types'; + +export function getPitParams(pit: SavedObjectsPitParams) { + return { + pit: { + id: pit.id, + ...(pit.keepAlive ? { keep_alive: pit.keepAlive } : {}), + }, + }; +} diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index 9e91e585f74f0..fc26c837d5e52 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -6,14 +6,17 @@ * Side Public License, v 1. */ +jest.mock('./pit_params'); jest.mock('./query_params'); jest.mock('./sorting_params'); import { typeRegistryMock } from '../../../saved_objects_type_registry.mock'; +import * as pitParamsNS from './pit_params'; import * as queryParamsNS from './query_params'; import { getSearchDsl } from './search_dsl'; import * as sortParamsNS from './sorting_params'; +const getPitParams = pitParamsNS.getPitParams as jest.Mock; const getQueryParams = queryParamsNS.getQueryParams as jest.Mock; const getSortingParams = sortParamsNS.getSortingParams as jest.Mock; @@ -84,6 +87,7 @@ describe('getSearchDsl', () => { type: 'foo', sortField: 'bar', sortOrder: 'baz', + pit: { id: 'abc123' }, }; getSearchDsl(mappings, registry, opts); @@ -92,7 +96,8 @@ describe('getSearchDsl', () => { mappings, opts.type, opts.sortField, - opts.sortOrder + opts.sortOrder, + opts.pit ); }); @@ -101,5 +106,33 @@ describe('getSearchDsl', () => { getSortingParams.mockReturnValue({ b: 'b' }); expect(getSearchDsl(mappings, registry, { type: 'foo' })).toEqual({ a: 'a', b: 'b' }); }); + + it('returns searchAfter if provided', () => { + getQueryParams.mockReturnValue({ a: 'a' }); + getSortingParams.mockReturnValue({ b: 'b' }); + expect(getSearchDsl(mappings, registry, { type: 'foo', searchAfter: [1, 'bar'] })).toEqual({ + a: 'a', + b: 'b', + search_after: [1, 'bar'], + }); + }); + + it('returns pit if provided', () => { + getQueryParams.mockReturnValue({ a: 'a' }); + getSortingParams.mockReturnValue({ b: 'b' }); + getPitParams.mockReturnValue({ pit: { id: 'abc123' } }); + expect( + getSearchDsl(mappings, registry, { + type: 'foo', + searchAfter: [1, 'bar'], + pit: { id: 'abc123' }, + }) + ).toEqual({ + a: 'a', + b: 'b', + pit: { id: 'abc123' }, + search_after: [1, 'bar'], + }); + }); }); }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index 4b4fa8865ee9d..cae5e43897bcf 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -9,7 +9,9 @@ import Boom from '@hapi/boom'; import { IndexMapping } from '../../../mappings'; +import { SavedObjectsPitParams } from '../../../types'; import { getQueryParams, HasReferenceQueryParams, SearchOperator } from './query_params'; +import { getPitParams } from './pit_params'; import { getSortingParams } from './sorting_params'; import { ISavedObjectTypeRegistry } from '../../../saved_objects_type_registry'; @@ -21,9 +23,11 @@ interface GetSearchDslOptions { defaultSearchOperator?: SearchOperator; searchFields?: string[]; rootSearchFields?: string[]; + searchAfter?: unknown[]; sortField?: string; sortOrder?: string; namespaces?: string[]; + pit?: SavedObjectsPitParams; typeToNamespacesMap?: Map; hasReference?: HasReferenceQueryParams | HasReferenceQueryParams[]; hasReferenceOperator?: SearchOperator; @@ -41,9 +45,11 @@ export function getSearchDsl( defaultSearchOperator, searchFields, rootSearchFields, + searchAfter, sortField, sortOrder, namespaces, + pit, typeToNamespacesMap, hasReference, hasReferenceOperator, @@ -72,6 +78,8 @@ export function getSearchDsl( hasReferenceOperator, kueryNode, }), - ...getSortingParams(mappings, type, sortField, sortOrder), + ...getSortingParams(mappings, type, sortField, sortOrder, pit), + ...(pit ? getPitParams(pit) : {}), + ...(searchAfter ? { search_after: searchAfter } : {}), }; } diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts index 1376f0d50a9da..73c7065705fc5 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts @@ -79,6 +79,11 @@ describe('searchDsl/getSortParams', () => { ], }); }); + it('appends tiebreaker when PIT is provided', () => { + expect(getSortingParams(MAPPINGS, 'saved', 'title', undefined, { id: 'abc' }).sort).toEqual( + expect.arrayContaining([{ _shard_doc: 'asc' }]) + ); + }); }); describe('sortField is simple root property with multiple types', () => { it('returns correct params', () => { @@ -93,6 +98,11 @@ describe('searchDsl/getSortParams', () => { ], }); }); + it('appends tiebreaker when PIT is provided', () => { + expect( + getSortingParams(MAPPINGS, ['saved', 'pending'], 'type', undefined, { id: 'abc' }).sort + ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); + }); }); describe('sortField is simple non-root property with multiple types', () => { it('returns correct params', () => { @@ -114,6 +124,11 @@ describe('searchDsl/getSortParams', () => { ], }); }); + it('appends tiebreaker when PIT is provided', () => { + expect( + getSortingParams(MAPPINGS, 'saved', 'title.raw', undefined, { id: 'abc' }).sort + ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); + }); }); describe('sortField is multi-field with single type as array', () => { it('returns correct params', () => { @@ -128,6 +143,11 @@ describe('searchDsl/getSortParams', () => { ], }); }); + it('appends tiebreaker when PIT is provided', () => { + expect( + getSortingParams(MAPPINGS, ['saved'], 'title.raw', undefined, { id: 'abc' }).sort + ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); + }); }); describe('sortField is root multi-field with multiple types', () => { it('returns correct params', () => { @@ -142,6 +162,12 @@ describe('searchDsl/getSortParams', () => { ], }); }); + it('appends tiebreaker when PIT is provided', () => { + expect( + getSortingParams(MAPPINGS, ['saved', 'pending'], 'type.raw', undefined, { id: 'abc' }) + .sort + ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); + }); }); describe('sortField is not-root multi-field with multiple types', () => { it('returns correct params', () => { diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts index e3bfba6a80f59..abef9bfa0a300 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts @@ -8,6 +8,12 @@ import Boom from '@hapi/boom'; import { getProperty, IndexMapping } from '../../../mappings'; +import { SavedObjectsPitParams } from '../../../types'; + +// TODO: The plan is for ES to automatically add this tiebreaker when +// using PIT. We should remove this logic once that is resolved. +// https://github.com/elastic/elasticsearch/issues/56828 +const ES_PROVIDED_TIEBREAKER = { _shard_doc: 'asc' }; const TOP_LEVEL_FIELDS = ['_id', '_score']; @@ -15,7 +21,8 @@ export function getSortingParams( mappings: IndexMapping, type: string | string[], sortField?: string, - sortOrder?: string + sortOrder?: string, + pit?: SavedObjectsPitParams ) { if (!sortField) { return {}; @@ -31,6 +38,7 @@ export function getSortingParams( order: sortOrder, }, }, + ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } @@ -51,6 +59,7 @@ export function getSortingParams( unmapped_type: rootField.type, }, }, + ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } @@ -75,6 +84,7 @@ export function getSortingParams( unmapped_type: field.type, }, }, + ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } diff --git a/src/core/server/saved_objects/service/saved_objects_client.mock.ts b/src/core/server/saved_objects/service/saved_objects_client.mock.ts index 72f5561aa7027..ecca652cace37 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.mock.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.mock.ts @@ -20,6 +20,8 @@ const create = () => bulkGet: jest.fn(), find: jest.fn(), get: jest.fn(), + closePointInTime: jest.fn(), + openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }), resolve: jest.fn(), update: jest.fn(), addToNamespaces: jest.fn(), diff --git a/src/core/server/saved_objects/service/saved_objects_client.test.js b/src/core/server/saved_objects/service/saved_objects_client.test.js index 45b0cf70b0dc6..7cbddaf195dc9 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.test.js +++ b/src/core/server/saved_objects/service/saved_objects_client.test.js @@ -115,6 +115,36 @@ test(`#get`, async () => { expect(result).toBe(returnValue); }); +test(`#openPointInTimeForType`, async () => { + const returnValue = Symbol(); + const mockRepository = { + openPointInTimeForType: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const type = Symbol(); + const options = Symbol(); + const result = await client.openPointInTimeForType(type, options); + + expect(mockRepository.openPointInTimeForType).toHaveBeenCalledWith(type, options); + expect(result).toBe(returnValue); +}); + +test(`#closePointInTime`, async () => { + const returnValue = Symbol(); + const mockRepository = { + closePointInTime: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const id = Symbol(); + const options = Symbol(); + const result = await client.closePointInTime(id, options); + + expect(mockRepository.closePointInTime).toHaveBeenCalledWith(id, options); + expect(result).toBe(returnValue); +}); + test(`#resolve`, async () => { const returnValue = Symbol(); const mockRepository = { diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index b90540fbfa971..b93f3022e4236 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -129,6 +129,35 @@ export interface SavedObjectsFindResult extends SavedObject { * The Elasticsearch `_score` of this result. */ score: number; + /** + * The Elasticsearch `sort` value of this result. + * + * @remarks + * This can be passed directly to the `searchAfter` param in the {@link SavedObjectsFindOptions} + * in order to page through large numbers of hits. It is recommended you use this alongside + * a Point In Time (PIT) that was opened with {@link SavedObjectsClient.openPointInTimeForType}. + * + * @example + * ```ts + * const { id } = await savedObjectsClient.openPointInTimeForType('visualization'); + * const page1 = await savedObjectsClient.find({ + * type: 'visualization', + * sortField: 'updated_at', + * sortOrder: 'asc', + * pit: { id }, + * }); + * const lastHit = page1.saved_objects[page1.saved_objects.length - 1]; + * const page2 = await savedObjectsClient.find({ + * type: 'visualization', + * sortField: 'updated_at', + * sortOrder: 'asc', + * pit: { id: page1.pit_id }, + * searchAfter: lastHit.sort, + * }); + * await savedObjectsClient.closePointInTime(page2.pit_id); + * ``` + */ + sort?: unknown[]; } /** @@ -144,6 +173,7 @@ export interface SavedObjectsFindResponse { total: number; per_page: number; page: number; + pit_id?: string; } /** @@ -311,6 +341,50 @@ export interface SavedObjectsResolveResponse { outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; } +/** + * @public + */ +export interface SavedObjectsOpenPointInTimeOptions extends SavedObjectsBaseOptions { + /** + * Optionally specify how long ES should keep the PIT alive until the next request. Defaults to `5m`. + */ + keepAlive?: string; + /** + * An optional ES preference value to be used for the query. + */ + preference?: string; +} + +/** + * @public + */ +export interface SavedObjectsOpenPointInTimeResponse { + /** + * PIT ID returned from ES. + */ + id: string; +} + +/** + * @public + */ +export type SavedObjectsClosePointInTimeOptions = SavedObjectsBaseOptions; + +/** + * @public + */ +export interface SavedObjectsClosePointInTimeResponse { + /** + * If true, all search contexts associated with the PIT id are + * successfully closed. + */ + succeeded: boolean; + /** + * The number of search contexts that have been successfully closed. + */ + num_freed: number; +} + /** * * @public @@ -504,4 +578,25 @@ export class SavedObjectsClient { ) { return await this._repository.removeReferencesTo(type, id, options); } + + /** + * Opens a Point In Time (PIT) against the indices for the specified Saved Object types. + * The returned `id` can then be passed to {@link SavedObjectsClient.find} to search + * against that PIT. + */ + async openPointInTimeForType( + type: string | string[], + options: SavedObjectsOpenPointInTimeOptions = {} + ) { + return await this._repository.openPointInTimeForType(type, options); + } + + /** + * Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the + * Elasticsearch client, and is included in the Saved Objects Client as a convenience + * for consumers who are using {@link SavedObjectsClient.openPointInTimeForType}. + */ + async closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions) { + return await this._repository.closePointInTime(id, options); + } } diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index d122e92aba398..66110d096213f 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -62,6 +62,14 @@ export interface SavedObjectsFindOptionsReference { id: string; } +/** + * @public + */ +export interface SavedObjectsPitParams { + id: string; + keepAlive?: string; +} + /** * * @public @@ -82,6 +90,10 @@ export interface SavedObjectsFindOptions { search?: string; /** The fields to perform the parsed query against. See Elasticsearch Simple Query String `fields` argument for more information */ searchFields?: string[]; + /** + * Use the sort values from the previous page to retrieve the next page of results. + */ + searchAfter?: unknown[]; /** * The fields to perform the parsed query against. Unlike the `searchFields` argument, these are expected to be root fields and will not * be modified. If used in conjunction with `searchFields`, both are concatenated together. @@ -114,6 +126,10 @@ export interface SavedObjectsFindOptions { typeToNamespacesMap?: Map; /** An optional ES preference value to be used for the query **/ preference?: string; + /** + * Search against a specific Point In Time (PIT) that you've opened with {@link SavedObjectsClient.openPointInTimeForType}. + */ + pit?: SavedObjectsPitParams; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 459e4d42eaf1b..71ce1ccba4f67 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2223,6 +2223,7 @@ export class SavedObjectsClient { bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; + closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise; @@ -2232,6 +2233,7 @@ export class SavedObjectsClient { errors: typeof SavedObjectsErrorHelpers; find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; + openPointInTimeForType(type: string | string[], options?: SavedObjectsOpenPointInTimeOptions): Promise; removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; @@ -2270,6 +2272,15 @@ export interface SavedObjectsClientWrapperOptions { typeRegistry: ISavedObjectTypeRegistry; } +// @public (undocumented) +export type SavedObjectsClosePointInTimeOptions = SavedObjectsBaseOptions; + +// @public (undocumented) +export interface SavedObjectsClosePointInTimeResponse { + num_freed: number; + succeeded: boolean; +} + // @public export interface SavedObjectsComplexFieldMapping { // (undocumented) @@ -2414,10 +2425,11 @@ export interface SavedObjectsExportByTypeOptions extends SavedObjectExportBaseOp export class SavedObjectsExporter { // (undocumented) #private; - constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, }: { + constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, logger, }: { savedObjectsClient: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; exportSizeLimit: number; + logger: Logger; }); exportByObjects(options: SavedObjectsExportByObjectOptions): Promise; exportByTypes(options: SavedObjectsExportByTypeOptions): Promise; @@ -2475,9 +2487,11 @@ export interface SavedObjectsFindOptions { page?: number; // (undocumented) perPage?: number; + pit?: SavedObjectsPitParams; preference?: string; rootSearchFields?: string[]; search?: string; + searchAfter?: unknown[]; searchFields?: string[]; // (undocumented) sortField?: string; @@ -2503,6 +2517,8 @@ export interface SavedObjectsFindResponse { // (undocumented) per_page: number; // (undocumented) + pit_id?: string; + // (undocumented) saved_objects: Array>; // (undocumented) total: number; @@ -2511,6 +2527,7 @@ export interface SavedObjectsFindResponse { // @public (undocumented) export interface SavedObjectsFindResult extends SavedObject { score: number; + sort?: unknown[]; } // @public @@ -2737,6 +2754,25 @@ export interface SavedObjectsMigrationVersion { // @public export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +// @public (undocumented) +export interface SavedObjectsOpenPointInTimeOptions extends SavedObjectsBaseOptions { + keepAlive?: string; + preference?: string; +} + +// @public (undocumented) +export interface SavedObjectsOpenPointInTimeResponse { + id: string; +} + +// @public (undocumented) +export interface SavedObjectsPitParams { + // (undocumented) + id: string; + // (undocumented) + keepAlive?: string; +} + // @public export interface SavedObjectsRawDoc { // (undocumented) @@ -2773,6 +2809,7 @@ export class SavedObjectsRepository { bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; + closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; // Warning: (ae-forgotten-export) The symbol "IKibanaMigrator" needs to be exported by the entry point index.d.ts // @@ -2785,6 +2822,7 @@ export class SavedObjectsRepository { find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; incrementCounter(type: string, id: string, counterFields: Array, options?: SavedObjectsIncrementCounterOptions): Promise>; + openPointInTimeForType(type: string | string[], { keepAlive, preference }?: SavedObjectsOpenPointInTimeOptions): Promise; removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; @@ -2949,10 +2987,12 @@ export interface SearchResponse { highlight?: any; inner_hits?: any; matched_queries?: string[]; - sort?: string[]; + sort?: unknown[]; }>; }; // (undocumented) + pit_id?: string; + // (undocumented) _scroll_id?: string; // (undocumented) _shards: ShardsResponse; diff --git a/src/core/server/types.ts b/src/core/server/types.ts index 1839ee68190aa..2ae51d4452a4e 100644 --- a/src/core/server/types.ts +++ b/src/core/server/types.ts @@ -31,6 +31,7 @@ export type { SavedObjectStatusMeta, SavedObjectsFindOptionsReference, SavedObjectsFindOptions, + SavedObjectsPitParams, SavedObjectsBaseOptions, MutatingOperationRefreshSetting, SavedObjectsClientContract, diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index d579663eeca43..7fa1d727259f3 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -1142,7 +1142,7 @@ export class Plugin implements Plugin_2 Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }; diff --git a/test/api_integration/apis/saved_objects/export.ts b/test/api_integration/apis/saved_objects/export.ts index 32a72f374cbe1..2be86bda774fc 100644 --- a/test/api_integration/apis/saved_objects/export.ts +++ b/test/api_integration/apis/saved_objects/export.ts @@ -295,43 +295,43 @@ export default function ({ getService }: FtrProviderContext) { ); expect(resp.header['content-type']).to.eql('application/ndjson'); const objects = ndjsonToObject(resp.text); - expect(objects).to.eql([ - { - attributes: { - description: '', - hits: 0, - kibanaSavedObjectMeta: { - searchSourceJSON: - objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON, - }, - optionsJSON: objects[0].attributes.optionsJSON, - panelsJSON: objects[0].attributes.panelsJSON, - refreshInterval: { - display: 'Off', - pause: false, - value: 0, - }, - timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700', - timeRestore: true, - timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700', - title: 'Requests', - version: 1, + + // Sort values aren't deterministic so we need to exclude them + const { sort, ...obj } = objects[0]; + expect(obj).to.eql({ + attributes: { + description: '', + hits: 0, + kibanaSavedObjectMeta: { + searchSourceJSON: objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON, }, - id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', - migrationVersion: objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - references: [ - { - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - name: 'panel_0', - type: 'visualization', - }, - ], - type: 'dashboard', - updated_at: '2017-09-21T18:57:40.826Z', - version: objects[0].version, + optionsJSON: objects[0].attributes.optionsJSON, + panelsJSON: objects[0].attributes.panelsJSON, + refreshInterval: { + display: 'Off', + pause: false, + value: 0, + }, + timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700', + timeRestore: true, + timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700', + title: 'Requests', + version: 1, }, - ]); + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + migrationVersion: objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, + references: [ + { + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + name: 'panel_0', + type: 'visualization', + }, + ], + type: 'dashboard', + updated_at: '2017-09-21T18:57:40.826Z', + version: objects[0].version, + }); expect(objects[0].migrationVersion).to.be.ok(); expect(() => JSON.parse(objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON) @@ -355,43 +355,43 @@ export default function ({ getService }: FtrProviderContext) { ); expect(resp.header['content-type']).to.eql('application/ndjson'); const objects = ndjsonToObject(resp.text); - expect(objects).to.eql([ - { - attributes: { - description: '', - hits: 0, - kibanaSavedObjectMeta: { - searchSourceJSON: - objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON, - }, - optionsJSON: objects[0].attributes.optionsJSON, - panelsJSON: objects[0].attributes.panelsJSON, - refreshInterval: { - display: 'Off', - pause: false, - value: 0, - }, - timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700', - timeRestore: true, - timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700', - title: 'Requests', - version: 1, + + // Sort values aren't deterministic so we need to exclude them + const { sort, ...obj } = objects[0]; + expect(obj).to.eql({ + attributes: { + description: '', + hits: 0, + kibanaSavedObjectMeta: { + searchSourceJSON: objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON, }, - id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', - migrationVersion: objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - references: [ - { - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - name: 'panel_0', - type: 'visualization', - }, - ], - type: 'dashboard', - updated_at: '2017-09-21T18:57:40.826Z', - version: objects[0].version, + optionsJSON: objects[0].attributes.optionsJSON, + panelsJSON: objects[0].attributes.panelsJSON, + refreshInterval: { + display: 'Off', + pause: false, + value: 0, + }, + timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700', + timeRestore: true, + timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700', + title: 'Requests', + version: 1, }, - ]); + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + migrationVersion: objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, + references: [ + { + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + name: 'panel_0', + type: 'visualization', + }, + ], + type: 'dashboard', + updated_at: '2017-09-21T18:57:40.826Z', + version: objects[0].version, + }); expect(objects[0].migrationVersion).to.be.ok(); expect(() => JSON.parse(objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON) @@ -420,43 +420,43 @@ export default function ({ getService }: FtrProviderContext) { ); expect(resp.header['content-type']).to.eql('application/ndjson'); const objects = ndjsonToObject(resp.text); - expect(objects).to.eql([ - { - attributes: { - description: '', - hits: 0, - kibanaSavedObjectMeta: { - searchSourceJSON: - objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON, - }, - optionsJSON: objects[0].attributes.optionsJSON, - panelsJSON: objects[0].attributes.panelsJSON, - refreshInterval: { - display: 'Off', - pause: false, - value: 0, - }, - timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700', - timeRestore: true, - timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700', - title: 'Requests', - version: 1, + + // Sort values aren't deterministic so we need to exclude them + const { sort, ...obj } = objects[0]; + expect(obj).to.eql({ + attributes: { + description: '', + hits: 0, + kibanaSavedObjectMeta: { + searchSourceJSON: objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON, }, - id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', - migrationVersion: objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - references: [ - { - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - name: 'panel_0', - type: 'visualization', - }, - ], - type: 'dashboard', - updated_at: '2017-09-21T18:57:40.826Z', - version: objects[0].version, + optionsJSON: objects[0].attributes.optionsJSON, + panelsJSON: objects[0].attributes.panelsJSON, + refreshInterval: { + display: 'Off', + pause: false, + value: 0, + }, + timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700', + timeRestore: true, + timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700', + title: 'Requests', + version: 1, }, - ]); + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + migrationVersion: objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, + references: [ + { + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + name: 'panel_0', + type: 'visualization', + }, + ], + type: 'dashboard', + updated_at: '2017-09-21T18:57:40.826Z', + version: objects[0].version, + }); expect(objects[0].migrationVersion).to.be.ok(); expect(() => JSON.parse(objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON) @@ -511,7 +511,37 @@ export default function ({ getService }: FtrProviderContext) { await esArchiver.unload('saved_objects/10k'); }); - it('should return 400 when exporting more than 10,000', async () => { + it('should allow exporting more than 10,000 objects if permitted by maxImportExportSize', async () => { + await supertest + .post('/api/saved_objects/_export') + .send({ + type: ['dashboard', 'visualization', 'search', 'index-pattern'], + excludeExportDetails: true, + }) + .expect(200) + .then((resp) => { + expect(resp.header['content-disposition']).to.eql( + 'attachment; filename="export.ndjson"' + ); + expect(resp.header['content-type']).to.eql('application/ndjson'); + const objects = ndjsonToObject(resp.text); + expect(objects.length).to.eql(10001); + }); + }); + + it('should return 400 when exporting more than allowed by maxImportExportSize', async () => { + let anotherCustomVisId: string; + await supertest + .post('/api/saved_objects/visualization') + .send({ + attributes: { + title: 'My other favorite vis', + }, + }) + .expect(200) + .then((resp) => { + anotherCustomVisId = resp.body.id; + }); await supertest .post('/api/saved_objects/_export') .send({ @@ -523,9 +553,13 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body).to.eql({ statusCode: 400, error: 'Bad Request', - message: `Can't export more than 10000 objects`, + message: `Can't export more than 10001 objects`, }); }); + await supertest + // @ts-expect-error TS complains about using `anotherCustomVisId` before it is assigned + .delete(`/api/saved_objects/visualization/${anotherCustomVisId}`) + .expect(200); }); }); }); diff --git a/test/api_integration/apis/saved_objects/import.ts b/test/api_integration/apis/saved_objects/import.ts index b0aa9b0eef8fc..d463b9498a52a 100644 --- a/test/api_integration/apis/saved_objects/import.ts +++ b/test/api_integration/apis/saved_objects/import.ts @@ -166,7 +166,7 @@ export default function ({ getService }: FtrProviderContext) { it('should return 400 when trying to import more than 10,000 objects', async () => { const fileChunks = []; - for (let i = 0; i < 10001; i++) { + for (let i = 0; i <= 10001; i++) { fileChunks.push(`{"type":"visualization","id":"${i}","attributes":{},"references":[]}`); } await supertest @@ -177,7 +177,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body).to.eql({ statusCode: 400, error: 'Bad Request', - message: "Can't import more than 10000 objects", + message: "Can't import more than 10001 objects", }); }); }); diff --git a/test/api_integration/apis/saved_objects/resolve_import_errors.ts b/test/api_integration/apis/saved_objects/resolve_import_errors.ts index 4fcce29905beb..8412d3cce5c3e 100644 --- a/test/api_integration/apis/saved_objects/resolve_import_errors.ts +++ b/test/api_integration/apis/saved_objects/resolve_import_errors.ts @@ -132,9 +132,9 @@ export default function ({ getService }: FtrProviderContext) { }); }); - it('should return 400 when resolving conflicts with a file containing more than 10,000 objects', async () => { + it('should return 400 when resolving conflicts with a file containing more than 10,001 objects', async () => { const fileChunks = []; - for (let i = 0; i < 10001; i++) { + for (let i = 0; i <= 10001; i++) { fileChunks.push(`{"type":"visualization","id":"${i}","attributes":{},"references":[]}`); } await supertest @@ -146,7 +146,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body).to.eql({ statusCode: 400, error: 'Bad Request', - message: "Can't import more than 10000 objects", + message: "Can't import more than 10001 objects", }); }); }); diff --git a/test/api_integration/config.js b/test/api_integration/config.js index bd8f10606a45a..1c19dd24fa96b 100644 --- a/test/api_integration/config.js +++ b/test/api_integration/config.js @@ -30,6 +30,7 @@ export default async function ({ readConfigFile }) { '--elasticsearch.healthCheck.delay=3600000', '--server.xsrf.disableProtection=true', '--server.compression.referrerWhitelist=["some-host.com"]', + `--savedObjects.maxImportExportSize=10001`, ], }, }; diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index 3405f196960cd..474a283b5e3cb 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -1757,3 +1757,65 @@ describe('#removeReferencesTo', () => { expect(mockBaseClient.removeReferencesTo).toHaveBeenCalledTimes(1); }); }); + +describe('#openPointInTimeForType', () => { + it('redirects request to underlying base client', async () => { + const options = { keepAlive: '1m' }; + + await wrapper.openPointInTimeForType('some-type', options); + + expect(mockBaseClient.openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(mockBaseClient.openPointInTimeForType).toHaveBeenCalledWith('some-type', options); + }); + + it('returns response from underlying client', async () => { + const returnValue = { + id: 'abc123', + }; + mockBaseClient.openPointInTimeForType.mockResolvedValue(returnValue); + + const result = await wrapper.openPointInTimeForType('known-type'); + + expect(result).toBe(returnValue); + }); + + it('fails if base client fails', async () => { + const failureReason = new Error('Something bad happened...'); + mockBaseClient.openPointInTimeForType.mockRejectedValue(failureReason); + + await expect(wrapper.openPointInTimeForType('known-type')).rejects.toThrowError(failureReason); + + expect(mockBaseClient.openPointInTimeForType).toHaveBeenCalledTimes(1); + }); +}); + +describe('#closePointInTime', () => { + it('redirects request to underlying base client', async () => { + const id = 'abc123'; + await wrapper.closePointInTime(id); + + expect(mockBaseClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(mockBaseClient.closePointInTime).toHaveBeenCalledWith(id, undefined); + }); + + it('returns response from underlying client', async () => { + const returnValue = { + succeeded: true, + num_freed: 1, + }; + mockBaseClient.closePointInTime.mockResolvedValue(returnValue); + + const result = await wrapper.closePointInTime('abc123'); + + expect(result).toBe(returnValue); + }); + + it('fails if base client fails', async () => { + const failureReason = new Error('Something bad happened...'); + mockBaseClient.closePointInTime.mockRejectedValue(failureReason); + + await expect(wrapper.closePointInTime('abc123')).rejects.toThrowError(failureReason); + + expect(mockBaseClient.closePointInTime).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index 73414e8559192..a602f3606e0a9 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -15,9 +15,11 @@ import { SavedObjectsBulkUpdateResponse, SavedObjectsCheckConflictsObject, SavedObjectsClientContract, + SavedObjectsClosePointInTimeOptions, SavedObjectsCreateOptions, SavedObjectsFindOptions, SavedObjectsFindResponse, + SavedObjectsOpenPointInTimeOptions, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, SavedObjectsAddToNamespacesOptions, @@ -249,6 +251,17 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.options.baseClient.removeReferencesTo(type, id, options); } + public async openPointInTimeForType( + type: string | string[], + options: SavedObjectsOpenPointInTimeOptions = {} + ) { + return await this.options.baseClient.openPointInTimeForType(type, options); + } + + public async closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions) { + return await this.options.baseClient.closePointInTime(id, options); + } + /** * Strips encrypted attributes from any non-bulk Saved Objects API response. If type isn't * registered, response is returned as is. diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts index 25bcfd683b0dc..f353362e33513 100644 --- a/x-pack/plugins/security/server/audit/audit_events.ts +++ b/x-pack/plugins/security/server/audit/audit_events.ts @@ -190,6 +190,8 @@ export enum SavedObjectAction { ADD_TO_SPACES = 'saved_object_add_to_spaces', DELETE_FROM_SPACES = 'saved_object_delete_from_spaces', REMOVE_REFERENCES = 'saved_object_remove_references', + OPEN_POINT_IN_TIME = 'saved_object_open_point_in_time', + CLOSE_POINT_IN_TIME = 'saved_object_close_point_in_time', } type VerbsTuple = [string, string, string]; @@ -203,6 +205,16 @@ const savedObjectAuditVerbs: Record = { saved_object_find: ['access', 'accessing', 'accessed'], saved_object_add_to_spaces: ['update', 'updating', 'updated'], saved_object_delete_from_spaces: ['update', 'updating', 'updated'], + saved_object_open_point_in_time: [ + 'open point-in-time', + 'opening point-in-time', + 'opened point-in-time', + ], + saved_object_close_point_in_time: [ + 'close point-in-time', + 'closing point-in-time', + 'closed point-in-time', + ], saved_object_remove_references: [ 'remove references to', 'removing references to', @@ -219,6 +231,8 @@ const savedObjectAuditTypes: Record = { saved_object_find: EventType.ACCESS, saved_object_add_to_spaces: EventType.CHANGE, saved_object_delete_from_spaces: EventType.CHANGE, + saved_object_open_point_in_time: EventType.CREATION, + saved_object_close_point_in_time: EventType.DELETION, saved_object_remove_references: EventType.CHANGE, }; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts index 571d588037f36..3a0d9f4a5a100 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts @@ -9,7 +9,13 @@ import { flatten, uniq } from 'lodash'; import { FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; -const readOperations: string[] = ['bulk_get', 'get', 'find']; +const readOperations: string[] = [ + 'bulk_get', + 'get', + 'find', + 'open_point_in_time', + 'close_point_in_time', +]; const writeOperations: string[] = [ 'create', 'bulk_create', diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 6c2a57e57dcd8..da2639aba1c6b 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -101,6 +101,8 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-1', 'bulk_get'), actions.savedObject.get('all-savedObject-all-1', 'get'), actions.savedObject.get('all-savedObject-all-1', 'find'), + actions.savedObject.get('all-savedObject-all-1', 'open_point_in_time'), + actions.savedObject.get('all-savedObject-all-1', 'close_point_in_time'), actions.savedObject.get('all-savedObject-all-1', 'create'), actions.savedObject.get('all-savedObject-all-1', 'bulk_create'), actions.savedObject.get('all-savedObject-all-1', 'update'), @@ -110,6 +112,8 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-2', 'bulk_get'), actions.savedObject.get('all-savedObject-all-2', 'get'), actions.savedObject.get('all-savedObject-all-2', 'find'), + actions.savedObject.get('all-savedObject-all-2', 'open_point_in_time'), + actions.savedObject.get('all-savedObject-all-2', 'close_point_in_time'), actions.savedObject.get('all-savedObject-all-2', 'create'), actions.savedObject.get('all-savedObject-all-2', 'bulk_create'), actions.savedObject.get('all-savedObject-all-2', 'update'), @@ -119,9 +123,13 @@ describe('features', () => { actions.savedObject.get('all-savedObject-read-1', 'bulk_get'), actions.savedObject.get('all-savedObject-read-1', 'get'), actions.savedObject.get('all-savedObject-read-1', 'find'), + actions.savedObject.get('all-savedObject-read-1', 'open_point_in_time'), + actions.savedObject.get('all-savedObject-read-1', 'close_point_in_time'), actions.savedObject.get('all-savedObject-read-2', 'bulk_get'), actions.savedObject.get('all-savedObject-read-2', 'get'), actions.savedObject.get('all-savedObject-read-2', 'find'), + actions.savedObject.get('all-savedObject-read-2', 'open_point_in_time'), + actions.savedObject.get('all-savedObject-read-2', 'close_point_in_time'), actions.ui.get('foo', 'all-ui-1'), actions.ui.get('foo', 'all-ui-2'), ]; @@ -132,6 +140,8 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-1', 'bulk_get'), actions.savedObject.get('read-savedObject-all-1', 'get'), actions.savedObject.get('read-savedObject-all-1', 'find'), + actions.savedObject.get('read-savedObject-all-1', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-all-1', 'close_point_in_time'), actions.savedObject.get('read-savedObject-all-1', 'create'), actions.savedObject.get('read-savedObject-all-1', 'bulk_create'), actions.savedObject.get('read-savedObject-all-1', 'update'), @@ -141,6 +151,8 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), actions.savedObject.get('read-savedObject-all-2', 'get'), actions.savedObject.get('read-savedObject-all-2', 'find'), + actions.savedObject.get('read-savedObject-all-2', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-all-2', 'close_point_in_time'), actions.savedObject.get('read-savedObject-all-2', 'create'), actions.savedObject.get('read-savedObject-all-2', 'bulk_create'), actions.savedObject.get('read-savedObject-all-2', 'update'), @@ -150,9 +162,13 @@ describe('features', () => { actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), actions.savedObject.get('read-savedObject-read-1', 'get'), actions.savedObject.get('read-savedObject-read-1', 'find'), + actions.savedObject.get('read-savedObject-read-1', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-read-1', 'close_point_in_time'), actions.savedObject.get('read-savedObject-read-2', 'bulk_get'), actions.savedObject.get('read-savedObject-read-2', 'get'), actions.savedObject.get('read-savedObject-read-2', 'find'), + actions.savedObject.get('read-savedObject-read-2', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-read-2', 'close_point_in_time'), actions.ui.get('foo', 'read-ui-1'), actions.ui.get('foo', 'read-ui-2'), ]; @@ -274,6 +290,8 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-1', 'bulk_get'), actions.savedObject.get('all-savedObject-all-1', 'get'), actions.savedObject.get('all-savedObject-all-1', 'find'), + actions.savedObject.get('all-savedObject-all-1', 'open_point_in_time'), + actions.savedObject.get('all-savedObject-all-1', 'close_point_in_time'), actions.savedObject.get('all-savedObject-all-1', 'create'), actions.savedObject.get('all-savedObject-all-1', 'bulk_create'), actions.savedObject.get('all-savedObject-all-1', 'update'), @@ -283,6 +301,8 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-2', 'bulk_get'), actions.savedObject.get('all-savedObject-all-2', 'get'), actions.savedObject.get('all-savedObject-all-2', 'find'), + actions.savedObject.get('all-savedObject-all-2', 'open_point_in_time'), + actions.savedObject.get('all-savedObject-all-2', 'close_point_in_time'), actions.savedObject.get('all-savedObject-all-2', 'create'), actions.savedObject.get('all-savedObject-all-2', 'bulk_create'), actions.savedObject.get('all-savedObject-all-2', 'update'), @@ -292,9 +312,13 @@ describe('features', () => { actions.savedObject.get('all-savedObject-read-1', 'bulk_get'), actions.savedObject.get('all-savedObject-read-1', 'get'), actions.savedObject.get('all-savedObject-read-1', 'find'), + actions.savedObject.get('all-savedObject-read-1', 'open_point_in_time'), + actions.savedObject.get('all-savedObject-read-1', 'close_point_in_time'), actions.savedObject.get('all-savedObject-read-2', 'bulk_get'), actions.savedObject.get('all-savedObject-read-2', 'get'), actions.savedObject.get('all-savedObject-read-2', 'find'), + actions.savedObject.get('all-savedObject-read-2', 'open_point_in_time'), + actions.savedObject.get('all-savedObject-read-2', 'close_point_in_time'), actions.ui.get('foo', 'all-ui-1'), actions.ui.get('foo', 'all-ui-2'), actions.ui.get('catalogue', 'read-catalogue-1'), @@ -304,6 +328,8 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-1', 'bulk_get'), actions.savedObject.get('read-savedObject-all-1', 'get'), actions.savedObject.get('read-savedObject-all-1', 'find'), + actions.savedObject.get('read-savedObject-all-1', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-all-1', 'close_point_in_time'), actions.savedObject.get('read-savedObject-all-1', 'create'), actions.savedObject.get('read-savedObject-all-1', 'bulk_create'), actions.savedObject.get('read-savedObject-all-1', 'update'), @@ -313,6 +339,8 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), actions.savedObject.get('read-savedObject-all-2', 'get'), actions.savedObject.get('read-savedObject-all-2', 'find'), + actions.savedObject.get('read-savedObject-all-2', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-all-2', 'close_point_in_time'), actions.savedObject.get('read-savedObject-all-2', 'create'), actions.savedObject.get('read-savedObject-all-2', 'bulk_create'), actions.savedObject.get('read-savedObject-all-2', 'update'), @@ -322,9 +350,13 @@ describe('features', () => { actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), actions.savedObject.get('read-savedObject-read-1', 'get'), actions.savedObject.get('read-savedObject-read-1', 'find'), + actions.savedObject.get('read-savedObject-read-1', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-read-1', 'close_point_in_time'), actions.savedObject.get('read-savedObject-read-2', 'bulk_get'), actions.savedObject.get('read-savedObject-read-2', 'get'), actions.savedObject.get('read-savedObject-read-2', 'find'), + actions.savedObject.get('read-savedObject-read-2', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-read-2', 'close_point_in_time'), actions.ui.get('foo', 'read-ui-1'), actions.ui.get('foo', 'read-ui-2'), ]); @@ -388,6 +420,8 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-1', 'bulk_get'), actions.savedObject.get('read-savedObject-all-1', 'get'), actions.savedObject.get('read-savedObject-all-1', 'find'), + actions.savedObject.get('read-savedObject-all-1', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-all-1', 'close_point_in_time'), actions.savedObject.get('read-savedObject-all-1', 'create'), actions.savedObject.get('read-savedObject-all-1', 'bulk_create'), actions.savedObject.get('read-savedObject-all-1', 'update'), @@ -397,6 +431,8 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), actions.savedObject.get('read-savedObject-all-2', 'get'), actions.savedObject.get('read-savedObject-all-2', 'find'), + actions.savedObject.get('read-savedObject-all-2', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-all-2', 'close_point_in_time'), actions.savedObject.get('read-savedObject-all-2', 'create'), actions.savedObject.get('read-savedObject-all-2', 'bulk_create'), actions.savedObject.get('read-savedObject-all-2', 'update'), @@ -406,9 +442,13 @@ describe('features', () => { actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), actions.savedObject.get('read-savedObject-read-1', 'get'), actions.savedObject.get('read-savedObject-read-1', 'find'), + actions.savedObject.get('read-savedObject-read-1', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-read-1', 'close_point_in_time'), actions.savedObject.get('read-savedObject-read-2', 'bulk_get'), actions.savedObject.get('read-savedObject-read-2', 'get'), actions.savedObject.get('read-savedObject-read-2', 'find'), + actions.savedObject.get('read-savedObject-read-2', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-read-2', 'close_point_in_time'), actions.ui.get('foo', 'read-ui-1'), actions.ui.get('foo', 'read-ui-2'), ]); @@ -691,6 +731,8 @@ describe('reserved', () => { actions.savedObject.get('savedObject-all-1', 'bulk_get'), actions.savedObject.get('savedObject-all-1', 'get'), actions.savedObject.get('savedObject-all-1', 'find'), + actions.savedObject.get('savedObject-all-1', 'open_point_in_time'), + actions.savedObject.get('savedObject-all-1', 'close_point_in_time'), actions.savedObject.get('savedObject-all-1', 'create'), actions.savedObject.get('savedObject-all-1', 'bulk_create'), actions.savedObject.get('savedObject-all-1', 'update'), @@ -700,6 +742,8 @@ describe('reserved', () => { actions.savedObject.get('savedObject-all-2', 'bulk_get'), actions.savedObject.get('savedObject-all-2', 'get'), actions.savedObject.get('savedObject-all-2', 'find'), + actions.savedObject.get('savedObject-all-2', 'open_point_in_time'), + actions.savedObject.get('savedObject-all-2', 'close_point_in_time'), actions.savedObject.get('savedObject-all-2', 'create'), actions.savedObject.get('savedObject-all-2', 'bulk_create'), actions.savedObject.get('savedObject-all-2', 'update'), @@ -709,9 +753,13 @@ describe('reserved', () => { actions.savedObject.get('savedObject-read-1', 'bulk_get'), actions.savedObject.get('savedObject-read-1', 'get'), actions.savedObject.get('savedObject-read-1', 'find'), + actions.savedObject.get('savedObject-read-1', 'open_point_in_time'), + actions.savedObject.get('savedObject-read-1', 'close_point_in_time'), actions.savedObject.get('savedObject-read-2', 'bulk_get'), actions.savedObject.get('savedObject-read-2', 'get'), actions.savedObject.get('savedObject-read-2', 'find'), + actions.savedObject.get('savedObject-read-2', 'open_point_in_time'), + actions.savedObject.get('savedObject-read-2', 'close_point_in_time'), actions.ui.get('foo', 'ui-1'), actions.ui.get('foo', 'ui-2'), ]); @@ -823,6 +871,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -832,6 +882,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -952,6 +1004,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -961,6 +1015,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -970,6 +1026,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -979,6 +1037,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -995,6 +1055,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1004,6 +1066,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1026,6 +1090,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1035,6 +1101,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1044,6 +1112,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1053,6 +1123,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1063,6 +1135,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1072,6 +1146,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1081,6 +1157,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1090,6 +1168,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1160,6 +1240,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1169,6 +1251,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1178,6 +1262,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1187,6 +1273,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1203,6 +1291,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1212,6 +1302,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1304,6 +1396,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1313,6 +1407,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1322,6 +1418,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1331,6 +1429,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1365,6 +1465,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1374,6 +1476,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1389,6 +1493,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1398,6 +1504,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1473,6 +1581,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1482,6 +1592,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1491,6 +1603,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1500,6 +1614,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1606,6 +1722,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1615,6 +1733,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1627,6 +1747,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1636,6 +1758,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1654,6 +1778,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1663,6 +1789,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1672,6 +1800,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1681,6 +1811,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1691,6 +1823,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1700,6 +1834,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1709,6 +1845,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1718,6 +1856,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1808,6 +1948,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1817,6 +1959,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1833,6 +1977,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1842,6 +1988,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1864,6 +2012,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1873,6 +2023,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1882,6 +2034,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1891,6 +2045,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1901,6 +2057,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1910,6 +2068,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1919,6 +2079,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1928,6 +2090,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -2018,6 +2182,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -2027,6 +2193,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-licensed-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-licensed-sub-feature-type', 'create'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-licensed-sub-feature-type', 'update'), @@ -2036,9 +2204,13 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-licensed-sub-feature-type', 'get'), actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-licensed-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), actions.ui.get('foo', 'licensed-sub-feature-ui'), @@ -2056,6 +2228,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -2065,6 +2239,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-licensed-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-licensed-sub-feature-type', 'create'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-licensed-sub-feature-type', 'update'), @@ -2074,9 +2250,13 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-licensed-sub-feature-type', 'get'), actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-licensed-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), actions.ui.get('foo', 'licensed-sub-feature-ui'), @@ -2100,6 +2280,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -2109,6 +2291,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-licensed-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-licensed-sub-feature-type', 'create'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-licensed-sub-feature-type', 'update'), @@ -2118,9 +2302,13 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-licensed-sub-feature-type', 'get'), actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-licensed-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), actions.ui.get('foo', 'licensed-sub-feature-ui'), @@ -2131,6 +2319,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -2140,6 +2330,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-licensed-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-licensed-sub-feature-type', 'create'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-licensed-sub-feature-type', 'update'), @@ -2149,9 +2341,13 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-licensed-sub-feature-type', 'get'), actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-licensed-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), actions.ui.get('foo', 'licensed-sub-feature-ui'), @@ -2163,6 +2359,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -2172,6 +2370,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-licensed-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-licensed-sub-feature-type', 'create'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-licensed-sub-feature-type', 'update'), @@ -2181,9 +2381,13 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-licensed-sub-feature-type', 'get'), actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-licensed-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), actions.ui.get('foo', 'licensed-sub-feature-ui'), @@ -2194,6 +2398,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -2203,6 +2409,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-licensed-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-licensed-sub-feature-type', 'create'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-licensed-sub-feature-type', 'update'), @@ -2212,9 +2420,13 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-licensed-sub-feature-type', 'get'), actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-licensed-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), actions.ui.get('foo', 'licensed-sub-feature-ui'), diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index aeddba051a186..1293d3f2c84a3 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -905,6 +905,17 @@ describe('#find', () => { ); }); + test(`throws BadRequestError when searching across namespaces when pit is provided`, async () => { + const options = { + type: [type1, type2], + pit: { id: 'abc123' }, + namespaces: ['some-ns', 'another-ns'], + }; + await expect(client.find(options)).rejects.toThrowErrorMatchingInlineSnapshot( + `"_find across namespaces is not permitted when using the \`pit\` option."` + ); + }); + test(`checks privileges for user, actions, and namespaces`, async () => { const options = { type: [type1, type2], namespaces }; await expectPrivilegeCheck(client.find, { options }, namespaces); @@ -987,6 +998,64 @@ describe('#get', () => { }); }); +describe('#openPointInTimeForType', () => { + const type = 'foo'; + const namespace = 'some-ns'; + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.openPointInTimeForType, { type }); + }); + + test(`returns result of baseClient.openPointInTimeForType when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.openPointInTimeForType.mockReturnValue(apiCallReturnValue as any); + + const options = { namespace }; + const result = await expectSuccess(client.openPointInTimeForType, { type, options }); + expect(result).toBe(apiCallReturnValue); + }); + + test(`adds audit event when successful`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.openPointInTimeForType.mockReturnValue(apiCallReturnValue as any); + const options = { namespace }; + await expectSuccess(client.openPointInTimeForType, { type, options }); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_open_point_in_time', EventOutcome.UNKNOWN); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); + await expect(() => client.openPointInTimeForType(type, { namespace })).rejects.toThrow(); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_open_point_in_time', EventOutcome.FAILURE); + }); +}); + +describe('#closePointInTime', () => { + const id = 'abc123'; + const namespace = 'some-ns'; + + test(`returns result of baseClient.closePointInTime`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.closePointInTime.mockReturnValue(apiCallReturnValue as any); + + const options = { namespace }; + const result = await client.closePointInTime(id, options); + expect(result).toBe(apiCallReturnValue); + }); + + test(`adds audit event`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.closePointInTime.mockReturnValue(apiCallReturnValue as any); + + const options = { namespace }; + await client.closePointInTime(id, options); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_close_point_in_time', EventOutcome.UNKNOWN); + }); +}); + describe('#resolve', () => { const type = 'foo'; const id = `${type}-id`; diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index 4a886e5addb46..73bee302363ab 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -17,6 +17,8 @@ import { SavedObjectsCreateOptions, SavedObjectsDeleteFromNamespacesOptions, SavedObjectsFindOptions, + SavedObjectsOpenPointInTimeOptions, + SavedObjectsClosePointInTimeOptions, SavedObjectsRemoveReferencesToOptions, SavedObjectsUpdateOptions, SavedObjectsUtils, @@ -223,6 +225,11 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra `_find across namespaces is not permitted when the Spaces plugin is disabled.` ); } + if (options.pit && Array.isArray(options.namespaces) && options.namespaces.length > 1) { + throw this.errors.createBadRequestError( + '_find across namespaces is not permitted when using the `pit` option.' + ); + } const args = { options }; const { status, typeMap } = await this.ensureAuthorized( @@ -562,6 +569,57 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return await this.baseClient.removeReferencesTo(type, id, options); } + public async openPointInTimeForType( + type: string | string[], + options: SavedObjectsOpenPointInTimeOptions + ) { + try { + const args = { type, options }; + await this.ensureAuthorized(type, 'open_point_in_time', options?.namespace, { + args, + // Partial authorization is acceptable in this case because this method is only designed + // to be used with `find`, which already allows for partial authorization. + requireFullAuthorization: false, + }); + } catch (error) { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.OPEN_POINT_IN_TIME, + error, + }) + ); + throw error; + } + + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.OPEN_POINT_IN_TIME, + outcome: EventOutcome.UNKNOWN, + }) + ); + + return await this.baseClient.openPointInTimeForType(type, options); + } + + public async closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions) { + // We are intentionally omitting a call to `ensureAuthorized` here, because `closePointInTime` + // doesn't take in `types`, which are required to perform authorization. As there is no way + // to know what index/indices a PIT was created against, we have no practical means of + // authorizing users. We've decided we are okay with this because: + // (a) Elasticsearch only requires `read` privileges on an index in order to open/close + // a PIT against it, and; + // (b) By the time a user is accessing this service, they are already authenticated + // to Kibana, which is our closest equivalent to Elasticsearch's `read`. + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.CLOSE_POINT_IN_TIME, + outcome: EventOutcome.UNKNOWN, + }) + ); + + return await this.baseClient.closePointInTime(id, options); + } + private async checkPrivileges( actions: string | string[], namespaceOrNamespaces?: string | Array diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts index 8a749b5009334..f5917e78135ec 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts @@ -589,5 +589,57 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); }); }); + + describe('#openPointInTimeForType', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = createSpacesSavedObjectsClient(); + + await expect(client.openPointInTimeForType('foo', { namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = createSpacesSavedObjectsClient(); + const expectedReturnValue = { id: 'abc123' }; + baseClient.openPointInTimeForType.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const options = Object.freeze({ foo: 'bar' }); + // @ts-expect-error + const actualReturnValue = await client.openPointInTimeForType('foo', options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.openPointInTimeForType).toHaveBeenCalledWith('foo', { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + + describe('#closePointInTime', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = createSpacesSavedObjectsClient(); + + await expect(client.closePointInTime('foo', { namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = createSpacesSavedObjectsClient(); + const expectedReturnValue = { succeeded: true, num_freed: 1 }; + baseClient.closePointInTime.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const options = Object.freeze({ foo: 'bar' }); + // @ts-expect-error + const actualReturnValue = await client.closePointInTime('foo', options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.closePointInTime).toHaveBeenCalledWith('foo', { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); }); }); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index 9316d86b19bdd..433f95d2b5cf6 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -15,6 +15,8 @@ import { SavedObjectsClientContract, SavedObjectsCreateOptions, SavedObjectsFindOptions, + SavedObjectsClosePointInTimeOptions, + SavedObjectsOpenPointInTimeOptions, SavedObjectsUpdateOptions, SavedObjectsAddToNamespacesOptions, SavedObjectsDeleteFromNamespacesOptions, @@ -378,4 +380,42 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { namespace: spaceIdToNamespace(this.spaceId), }); } + + /** + * Opens a Point In Time (PIT) against the indices for the specified Saved Object types. + * The returned `id` can then be passed to `SavedObjects.find` to search against that PIT. + * + * @param {string|Array} type + * @param {object} [options] - {@link SavedObjectsOpenPointInTimeOptions} + * @property {string} [options.keepAlive] + * @property {string} [options.preference] + * @returns {promise} - { id: string } + */ + async openPointInTimeForType( + type: string | string[], + options: SavedObjectsOpenPointInTimeOptions = {} + ) { + throwErrorIfNamespaceSpecified(options); + return await this.client.openPointInTimeForType(type, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } + + /** + * Closes a Point In Time (PIT) by ID. This simply proxies the request to ES + * via the Elasticsearch client, and is included in the Saved Objects Client + * as a convenience for consumers who are using `openPointInTimeForType`. + * + * @param {string} id - ID returned from `openPointInTimeForType` + * @param {object} [options] - {@link SavedObjectsClosePointInTimeOptions} + * @returns {promise} - { succeeded: boolean; num_freed: number } + */ + async closePointInTime(id: string, options: SavedObjectsClosePointInTimeOptions = {}) { + throwErrorIfNamespaceSpecified(options); + return await this.client.closePointInTime(id, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } } From f5f6bc17c19069121cef50c2b7ea53c86db8d71b Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Thu, 11 Feb 2021 15:02:11 -0800 Subject: [PATCH 37/53] =?UTF-8?q?[7.x]=20[Dashboard]=20Adds=C2=A0Save=20as?= =?UTF-8?q?=20button=20to=20top=20menu=20(#90320)=20(#91208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../application/top_nav/dashboard_top_nav.tsx | 43 ++++++-- .../application/top_nav/get_top_nav_config.ts | 104 +++++++++--------- .../panel_toolbar.stories.storyshot | 3 +- .../top_nav/panel_toolbar/panel_toolbar.tsx | 3 +- .../public/application/top_nav/top_nav_ids.ts | 3 +- .../apps/dashboard/dashboard_save.ts | 20 ++++ .../apps/dashboard/empty_dashboard.ts | 4 +- .../functional/page_objects/dashboard_page.ts | 12 ++ .../services/dashboard/visualizations.ts | 2 +- .../new_visualize_flow/dashboard_embedding.ts | 4 +- .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - .../dashboard_mode/dashboard_empty_screen.js | 4 +- 13 files changed, 129 insertions(+), 81 deletions(-) diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index 0caaac6764bbe..786afc81c400c 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -321,6 +321,33 @@ export function DashboardTopNav({ dashboardStateManager, ]); + const runQuickSave = useCallback(async () => { + const currentTitle = dashboardStateManager.getTitle(); + const currentDescription = dashboardStateManager.getDescription(); + const currentTimeRestore = dashboardStateManager.getTimeRestore(); + + let currentTags: string[] = []; + if (savedObjectsTagging) { + const dashboard = dashboardStateManager.savedDashboard; + if (savedObjectsTagging.ui.hasTagDecoration(dashboard)) { + currentTags = dashboard.getTags(); + } + } + + save({}).then((response: SaveResult) => { + // If the save wasn't successful, put the original values back. + if (!(response as { id: string }).id) { + dashboardStateManager.setTitle(currentTitle); + dashboardStateManager.setDescription(currentDescription); + dashboardStateManager.setTimeRestore(currentTimeRestore); + if (savedObjectsTagging) { + dashboardStateManager.setTags(currentTags); + } + } + return response; + }); + }, [save, savedObjectsTagging, dashboardStateManager]); + const runClone = useCallback(() => { const currentTitle = dashboardStateManager.getTitle(); const onClone = async ( @@ -356,9 +383,8 @@ export function DashboardTopNav({ [TopNavIds.ENTER_EDIT_MODE]: () => onChangeViewMode(ViewMode.EDIT), [TopNavIds.DISCARD_CHANGES]: onDiscardChanges, [TopNavIds.SAVE]: runSave, + [TopNavIds.QUICK_SAVE]: runQuickSave, [TopNavIds.CLONE]: runClone, - [TopNavIds.ADD_EXISTING]: addFromLibrary, - [TopNavIds.VISUALIZE]: createNew, [TopNavIds.OPTIONS]: (anchorElement) => { showOptionsPopover({ anchorElement, @@ -394,10 +420,9 @@ export function DashboardTopNav({ onDiscardChanges, onChangeViewMode, savedDashboard, - addFromLibrary, - createNew, runClone, runSave, + runQuickSave, share, ]); @@ -419,11 +444,11 @@ export function DashboardTopNav({ const showFilterBar = shouldShowFilterBar(Boolean(embedSettings?.forceHideFilterBar)); const showSearchBar = showQueryBar || showFilterBar; - const topNav = getTopNavConfig( - viewMode, - dashboardTopNavActions, - dashboardCapabilities.hideWriteControls - ); + const topNav = getTopNavConfig(viewMode, dashboardTopNavActions, { + hideWriteControls: dashboardCapabilities.hideWriteControls, + isNewDashboard: !savedDashboard.id, + isDirty: dashboardStateManager.isDirty, + }); return { appName: 'dashboard', diff --git a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts index 37414cb948d5a..abc128369017c 100644 --- a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts +++ b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts @@ -20,11 +20,11 @@ import { NavAction } from '../../types'; export function getTopNavConfig( dashboardMode: ViewMode, actions: { [key: string]: NavAction }, - hideWriteControls: boolean + options: { hideWriteControls: boolean; isNewDashboard: boolean; isDirty: boolean } ) { switch (dashboardMode) { case ViewMode.VIEW: - return hideWriteControls + return options.hideWriteControls ? [ getFullScreenConfig(actions[TopNavIds.FULL_SCREEN]), getShareConfig(actions[TopNavIds.SHARE]), @@ -36,20 +36,39 @@ export function getTopNavConfig( getEditConfig(actions[TopNavIds.ENTER_EDIT_MODE]), ]; case ViewMode.EDIT: - return [ - getOptionsConfig(actions[TopNavIds.OPTIONS]), - getShareConfig(actions[TopNavIds.SHARE]), - getAddConfig(actions[TopNavIds.ADD_EXISTING]), - getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), - getDiscardConfig(actions[TopNavIds.DISCARD_CHANGES]), - getSaveConfig(actions[TopNavIds.SAVE]), - getCreateNewConfig(actions[TopNavIds.VISUALIZE]), - ]; + return options.isNewDashboard + ? [ + getOptionsConfig(actions[TopNavIds.OPTIONS]), + getShareConfig(actions[TopNavIds.SHARE]), + getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), + getDiscardConfig(actions[TopNavIds.DISCARD_CHANGES]), + getSaveConfig(actions[TopNavIds.SAVE], options.isNewDashboard), + ] + : [ + getOptionsConfig(actions[TopNavIds.OPTIONS]), + getShareConfig(actions[TopNavIds.SHARE]), + getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), + getDiscardConfig(actions[TopNavIds.DISCARD_CHANGES]), + getSaveConfig(actions[TopNavIds.SAVE]), + getQuickSave(actions[TopNavIds.QUICK_SAVE]), + ]; default: return []; } } +function getSaveButtonLabel() { + return i18n.translate('dashboard.topNave.saveButtonAriaLabel', { + defaultMessage: 'save', + }); +} + +function getSaveAsButtonLabel() { + return i18n.translate('dashboard.topNave.saveAsButtonAriaLabel', { + defaultMessage: 'save as', + }); +} + function getFullScreenConfig(action: NavAction) { return { id: 'full-screen', @@ -89,17 +108,32 @@ function getEditConfig(action: NavAction) { /** * @returns {kbnTopNavConfig} */ -function getSaveConfig(action: NavAction) { +function getQuickSave(action: NavAction) { return { - id: 'save', - label: i18n.translate('dashboard.topNave.saveButtonAriaLabel', { - defaultMessage: 'save', - }), + id: 'quick-save', + emphasize: true, + label: getSaveButtonLabel(), description: i18n.translate('dashboard.topNave.saveConfigDescription', { - defaultMessage: 'Save your dashboard', + defaultMessage: 'Quick save your dashboard without any prompts', + }), + testId: 'dashboardQuickSaveMenuItem', + run: action, + }; +} + +/** + * @returns {kbnTopNavConfig} + */ +function getSaveConfig(action: NavAction, isNewDashboard = false) { + return { + id: 'save', + label: isNewDashboard ? getSaveButtonLabel() : getSaveAsButtonLabel(), + description: i18n.translate('dashboard.topNave.saveAsConfigDescription', { + defaultMessage: 'Save as a new dashboard', }), testId: 'dashboardSaveMenuItem', run: action, + emphasize: isNewDashboard, }; } @@ -157,42 +191,6 @@ function getCloneConfig(action: NavAction) { /** * @returns {kbnTopNavConfig} */ -function getAddConfig(action: NavAction) { - return { - id: 'add', - label: i18n.translate('dashboard.topNave.addButtonAriaLabel', { - defaultMessage: 'Library', - }), - description: i18n.translate('dashboard.topNave.addConfigDescription', { - defaultMessage: 'Add an existing visualization to the dashboard', - }), - testId: 'dashboardAddPanelButton', - run: action, - }; -} - -/** - * @returns {kbnTopNavConfig} - */ -function getCreateNewConfig(action: NavAction) { - return { - emphasize: true, - iconType: 'plusInCircleFilled', - id: 'addNew', - label: i18n.translate('dashboard.topNave.addNewButtonAriaLabel', { - defaultMessage: 'Create panel', - }), - description: i18n.translate('dashboard.topNave.addNewConfigDescription', { - defaultMessage: 'Create a new panel on this dashboard', - }), - testId: 'dashboardAddNewPanelButton', - run: action, - }; -} - -// /** -// * @returns {kbnTopNavConfig} -// */ function getShareConfig(action: NavAction | undefined) { return { id: 'share', diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot b/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot index f822a7e70d523..afbbecb3935e0 100644 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot +++ b/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot @@ -10,7 +10,7 @@ exports[`Storyshots components/PanelToolbar default 1`] = ` >