From 5ffb48d15a4a0ad62df9470ea543d3e8eec368ec Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 15 Feb 2021 13:36:26 +0000 Subject: [PATCH 01/23] skip flaky suite (#91191) --- test/functional/apps/dashboard/dashboard_unsaved_state.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/dashboard/dashboard_unsaved_state.ts b/test/functional/apps/dashboard/dashboard_unsaved_state.ts index bf0791d93fb2c..851d7ab7461ed 100644 --- a/test/functional/apps/dashboard/dashboard_unsaved_state.ts +++ b/test/functional/apps/dashboard/dashboard_unsaved_state.ts @@ -19,7 +19,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { let originalPanelCount = 0; let unsavedPanelCount = 0; - describe('dashboard unsaved panels', () => { + // FLAKY: https://github.com/elastic/kibana/issues/91191 + describe.skip('dashboard unsaved panels', () => { before(async () => { await esArchiver.load('dashboard/current/kibana'); await kibanaServer.uiSettings.replace({ From 4de5a7d5eefc2a4479d505745b1ad11c0ca96df0 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 15 Feb 2021 13:40:03 +0000 Subject: [PATCH 02/23] skip flaky suite (#86948) --- test/functional/apps/dashboard/dashboard_listing.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/dashboard/dashboard_listing.ts b/test/functional/apps/dashboard/dashboard_listing.ts index f89161ce8c499..86a3aac1f32c2 100644 --- a/test/functional/apps/dashboard/dashboard_listing.ts +++ b/test/functional/apps/dashboard/dashboard_listing.ts @@ -15,7 +15,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); const listingTable = getService('listingTable'); - describe('dashboard listing page', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/86948 + describe.skip('dashboard listing page', function describeIndexTests() { const dashboardName = 'Dashboard Listing Test'; before(async function () { From 2f845dd9f935ab72564365e2f18b81b776fb0e56 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Mon, 15 Feb 2021 14:49:20 +0100 Subject: [PATCH 03/23] [ML] Data Frame Analytics: ROC Curve Chart (#89991) Adds the ROC curve chart to the results page for classification jobs in the evaluate section. --- .../components/scatterplot_matrix/index.ts | 4 +- ...trix_view.scss => scatterplot_matrix.scss} | 0 .../scatterplot_matrix/scatterplot_matrix.tsx | 308 +++++++++++++- .../scatterplot_matrix_vega_lite_spec.test.ts | 3 +- .../scatterplot_matrix_vega_lite_spec.ts | 6 +- .../scatterplot_matrix_view.tsx | 324 --------------- .../components/vega_chart/common.ts | 12 + .../components/vega_chart/index.ts | 11 + .../components/vega_chart/vega_chart.tsx | 19 + .../vega_chart_loading.tsx} | 2 +- .../components/vega_chart/vega_chart_view.tsx | 46 +++ .../data_frame_analytics/common/analytics.ts | 27 +- .../get_scatterplot_matrix_legend_type.ts | 2 +- .../_classification_exploration.scss | 11 +- .../evaluate_panel.tsx | 378 ++++++++---------- .../get_roc_curve_chart_vega_lite_spec.tsx | 131 ++++++ .../is_training_filter.ts | 49 +++ .../use_confusion_matrix.ts | 98 +++++ .../use_roc_curve.ts | 107 +++++ .../error_callout/error_callout.tsx | 8 +- .../exploration_page_wrapper.tsx | 10 +- .../classification_creation.ts | 17 +- .../outlier_detection_creation.ts | 4 +- .../regression_creation.ts | 4 +- ...=> data_frame_analytics_canvas_element.ts} | 11 +- .../ml/data_frame_analytics_results.ts | 1 + x-pack/test/functional/services/ml/index.ts | 6 +- 27 files changed, 1023 insertions(+), 576 deletions(-) rename x-pack/plugins/ml/public/application/components/scatterplot_matrix/{scatterplot_matrix_view.scss => scatterplot_matrix.scss} (100%) delete mode 100644 x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.tsx create mode 100644 x-pack/plugins/ml/public/application/components/vega_chart/common.ts create mode 100644 x-pack/plugins/ml/public/application/components/vega_chart/index.ts create mode 100644 x-pack/plugins/ml/public/application/components/vega_chart/vega_chart.tsx rename x-pack/plugins/ml/public/application/components/{scatterplot_matrix/scatterplot_matrix_loading.tsx => vega_chart/vega_chart_loading.tsx} (91%) create mode 100644 x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_view.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/is_training_filter.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts rename x-pack/test/functional/services/ml/{data_frame_analytics_scatterplot.ts => data_frame_analytics_canvas_element.ts} (72%) diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts index c72b0eb5fd66e..216b0d8d5e992 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts @@ -6,6 +6,4 @@ */ export { useScatterplotFieldOptions } from './use_scatterplot_field_options'; -export { LEGEND_TYPES } from './scatterplot_matrix_vega_lite_spec'; -export { ScatterplotMatrix } from './scatterplot_matrix'; -export type { ScatterplotMatrixViewProps as ScatterplotMatrixProps } from './scatterplot_matrix_view'; +export { ScatterplotMatrix, ScatterplotMatrixProps } from './scatterplot_matrix'; diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.scss b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.scss similarity index 100% rename from x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.scss rename to x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.scss diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx index 8a10fd5574ba5..a4f68c84ba81f 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx @@ -5,15 +5,305 @@ * 2.0. */ -import React, { FC, Suspense } from 'react'; +import React, { useMemo, useEffect, useState, FC } from 'react'; -import type { ScatterplotMatrixViewProps } from './scatterplot_matrix_view'; -import { ScatterplotMatrixLoading } from './scatterplot_matrix_loading'; +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiSwitch, +} from '@elastic/eui'; -const ScatterplotMatrixLazy = React.lazy(() => import('./scatterplot_matrix_view')); +import { i18n } from '@kbn/i18n'; -export const ScatterplotMatrix: FC = (props) => ( - }> - - -); +import type { SearchResponse7 } from '../../../../common/types/es_client'; +import type { ResultsSearchQuery } from '../../data_frame_analytics/common/analytics'; + +import { useMlApiContext } from '../../contexts/kibana'; + +import { getProcessedFields } from '../data_grid'; +import { useCurrentEuiTheme } from '../color_range_legend'; + +// Separate imports for lazy loadable VegaChart and related code +import { VegaChart } from '../vega_chart'; +import type { LegendType } from '../vega_chart/common'; +import { VegaChartLoading } from '../vega_chart/vega_chart_loading'; + +import { + getScatterplotMatrixVegaLiteSpec, + OUTLIER_SCORE_FIELD, +} from './scatterplot_matrix_vega_lite_spec'; + +import './scatterplot_matrix.scss'; + +const SCATTERPLOT_MATRIX_DEFAULT_FIELDS = 4; +const SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE = 1000; +const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE = 1; +const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE = 10000; + +const TOGGLE_ON = i18n.translate('xpack.ml.splom.toggleOn', { + defaultMessage: 'On', +}); +const TOGGLE_OFF = i18n.translate('xpack.ml.splom.toggleOff', { + defaultMessage: 'Off', +}); + +const sampleSizeOptions = [100, 1000, 10000].map((d) => ({ value: d, text: '' + d })); + +export interface ScatterplotMatrixProps { + fields: string[]; + index: string; + resultsField?: string; + color?: string; + legendType?: LegendType; + searchQuery?: ResultsSearchQuery; +} + +export const ScatterplotMatrix: FC = ({ + fields: allFields, + index, + resultsField, + color, + legendType, + searchQuery, +}) => { + const { esSearch } = useMlApiContext(); + + // dynamicSize is optionally used for outlier charts where the scatterplot marks + // are sized according to outlier_score + const [dynamicSize, setDynamicSize] = useState(false); + + // used to give the use the option to customize the fields used for the matrix axes + const [fields, setFields] = useState([]); + + useEffect(() => { + const defaultFields = + allFields.length > SCATTERPLOT_MATRIX_DEFAULT_FIELDS + ? allFields.slice(0, SCATTERPLOT_MATRIX_DEFAULT_FIELDS) + : allFields; + setFields(defaultFields); + }, [allFields]); + + // the amount of documents to be fetched + const [fetchSize, setFetchSize] = useState(SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE); + // flag to add a random score to the ES query to fetch documents + const [randomizeQuery, setRandomizeQuery] = useState(false); + + const [isLoading, setIsLoading] = useState(false); + + // contains the fetched documents and columns to be passed on to the Vega spec. + const [splom, setSplom] = useState<{ items: any[]; columns: string[] } | undefined>(); + + // formats the array of field names for EuiComboBox + const fieldOptions = useMemo( + () => + allFields.map((d) => ({ + label: d, + })), + [allFields] + ); + + const fieldsOnChange = (newFields: EuiComboBoxOptionOption[]) => { + setFields(newFields.map((d) => d.label)); + }; + + const fetchSizeOnChange = (e: React.ChangeEvent) => { + setFetchSize( + Math.min( + Math.max(parseInt(e.target.value, 10), SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE), + SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE + ) + ); + }; + + const randomizeQueryOnChange = () => { + setRandomizeQuery(!randomizeQuery); + }; + + const dynamicSizeOnChange = () => { + setDynamicSize(!dynamicSize); + }; + + const { euiTheme } = useCurrentEuiTheme(); + + useEffect(() => { + if (fields.length === 0) { + setSplom(undefined); + setIsLoading(false); + return; + } + + async function fetchSplom(options: { didCancel: boolean }) { + setIsLoading(true); + try { + const queryFields = [ + ...fields, + ...(color !== undefined ? [color] : []), + ...(legendType !== undefined ? [] : [`${resultsField}.${OUTLIER_SCORE_FIELD}`]), + ]; + + const queryFallback = searchQuery !== undefined ? searchQuery : { match_all: {} }; + const query = randomizeQuery + ? { + function_score: { + query: queryFallback, + random_score: { seed: 10, field: '_seq_no' }, + }, + } + : queryFallback; + + const resp: SearchResponse7 = await esSearch({ + index, + body: { + fields: queryFields, + _source: false, + query, + from: 0, + size: fetchSize, + }, + }); + + if (!options.didCancel) { + const items = resp.hits.hits.map((d) => + getProcessedFields(d.fields, (key: string) => + key.startsWith(`${resultsField}.feature_importance`) + ) + ); + + setSplom({ columns: fields, items }); + setIsLoading(false); + } + } catch (e) { + // TODO error handling + setIsLoading(false); + } + } + + const options = { didCancel: false }; + fetchSplom(options); + return () => { + options.didCancel = true; + }; + // stringify the fields array and search, otherwise the comparator will trigger on new but identical instances. + }, [fetchSize, JSON.stringify({ fields, searchQuery }), index, randomizeQuery, resultsField]); + + const vegaSpec = useMemo(() => { + if (splom === undefined) { + return; + } + + const { items, columns } = splom; + + const values = + resultsField !== undefined + ? items + : items.map((d) => { + d[`${resultsField}.${OUTLIER_SCORE_FIELD}`] = 0; + return d; + }); + + return getScatterplotMatrixVegaLiteSpec( + values, + columns, + euiTheme, + resultsField, + color, + legendType, + dynamicSize + ); + }, [resultsField, splom, color, legendType, dynamicSize]); + + return ( + <> + {splom === undefined || vegaSpec === undefined ? ( + + ) : ( +
+ + + + ({ + label: d, + }))} + onChange={fieldsOnChange} + isClearable={true} + data-test-subj="mlScatterplotMatrixFieldsComboBox" + /> + + + + + + + + + + + + + {resultsField !== undefined && legendType === undefined && ( + + + + + + )} + + + +
+ )} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts index 44fba189e856c..c963b7509139b 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts @@ -10,13 +10,14 @@ import { compile } from 'vega-lite/build-es5/vega-lite'; import euiThemeLight from '@elastic/eui/dist/eui_theme_light.json'; +import { LEGEND_TYPES } from '../vega_chart/common'; + import { getColorSpec, getScatterplotMatrixVegaLiteSpec, COLOR_OUTLIER, COLOR_RANGE_NOMINAL, DEFAULT_COLOR, - LEGEND_TYPES, } from './scatterplot_matrix_vega_lite_spec'; describe('getColorSpec()', () => { diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts index e476123ad0f2a..f99aa7c5c3de8 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts @@ -15,11 +15,7 @@ import { euiPaletteColorBlind, euiPaletteNegative, euiPalettePositive } from '@e import { i18n } from '@kbn/i18n'; -export const LEGEND_TYPES = { - NOMINAL: 'nominal', - QUANTITATIVE: 'quantitative', -} as const; -export type LegendType = typeof LEGEND_TYPES[keyof typeof LEGEND_TYPES]; +import { LegendType, LEGEND_TYPES } from '../vega_chart/common'; export const OUTLIER_SCORE_FIELD = 'outlier_score'; diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.tsx b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.tsx deleted file mode 100644 index 7d32992ace84d..0000000000000 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.tsx +++ /dev/null @@ -1,324 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo, useEffect, useState, FC } from 'react'; - -// There is still an issue with Vega Lite's typings with the strict mode Kibana is using. -// @ts-ignore -import { compile } from 'vega-lite/build-es5/vega-lite'; -import { parse, View, Warn } from 'vega'; -import { Handler } from 'vega-tooltip'; - -import { - htmlIdGenerator, - EuiComboBox, - EuiComboBoxOptionOption, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiSelect, - EuiSwitch, -} from '@elastic/eui'; - -import { i18n } from '@kbn/i18n'; - -import type { SearchResponse7 } from '../../../../common/types/es_client'; -import type { ResultsSearchQuery } from '../../data_frame_analytics/common/analytics'; - -import { useMlApiContext } from '../../contexts/kibana'; - -import { getProcessedFields } from '../data_grid'; -import { useCurrentEuiTheme } from '../color_range_legend'; - -import { ScatterplotMatrixLoading } from './scatterplot_matrix_loading'; - -import { - getScatterplotMatrixVegaLiteSpec, - LegendType, - OUTLIER_SCORE_FIELD, -} from './scatterplot_matrix_vega_lite_spec'; - -import './scatterplot_matrix_view.scss'; - -const SCATTERPLOT_MATRIX_DEFAULT_FIELDS = 4; -const SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE = 1000; -const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE = 1; -const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE = 10000; - -const TOGGLE_ON = i18n.translate('xpack.ml.splom.toggleOn', { - defaultMessage: 'On', -}); -const TOGGLE_OFF = i18n.translate('xpack.ml.splom.toggleOff', { - defaultMessage: 'Off', -}); - -const sampleSizeOptions = [100, 1000, 10000].map((d) => ({ value: d, text: '' + d })); - -export interface ScatterplotMatrixViewProps { - fields: string[]; - index: string; - resultsField?: string; - color?: string; - legendType?: LegendType; - searchQuery?: ResultsSearchQuery; -} - -export const ScatterplotMatrixView: FC = ({ - fields: allFields, - index, - resultsField, - color, - legendType, - searchQuery, -}) => { - const { esSearch } = useMlApiContext(); - - // dynamicSize is optionally used for outlier charts where the scatterplot marks - // are sized according to outlier_score - const [dynamicSize, setDynamicSize] = useState(false); - - // used to give the use the option to customize the fields used for the matrix axes - const [fields, setFields] = useState([]); - - useEffect(() => { - const defaultFields = - allFields.length > SCATTERPLOT_MATRIX_DEFAULT_FIELDS - ? allFields.slice(0, SCATTERPLOT_MATRIX_DEFAULT_FIELDS) - : allFields; - setFields(defaultFields); - }, [allFields]); - - // the amount of documents to be fetched - const [fetchSize, setFetchSize] = useState(SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE); - // flag to add a random score to the ES query to fetch documents - const [randomizeQuery, setRandomizeQuery] = useState(false); - - const [isLoading, setIsLoading] = useState(false); - - // contains the fetched documents and columns to be passed on to the Vega spec. - const [splom, setSplom] = useState<{ items: any[]; columns: string[] } | undefined>(); - - // formats the array of field names for EuiComboBox - const fieldOptions = useMemo( - () => - allFields.map((d) => ({ - label: d, - })), - [allFields] - ); - - const fieldsOnChange = (newFields: EuiComboBoxOptionOption[]) => { - setFields(newFields.map((d) => d.label)); - }; - - const fetchSizeOnChange = (e: React.ChangeEvent) => { - setFetchSize( - Math.min( - Math.max(parseInt(e.target.value, 10), SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE), - SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE - ) - ); - }; - - const randomizeQueryOnChange = () => { - setRandomizeQuery(!randomizeQuery); - }; - - const dynamicSizeOnChange = () => { - setDynamicSize(!dynamicSize); - }; - - const { euiTheme } = useCurrentEuiTheme(); - - useEffect(() => { - async function fetchSplom(options: { didCancel: boolean }) { - setIsLoading(true); - try { - const queryFields = [ - ...fields, - ...(color !== undefined ? [color] : []), - ...(legendType !== undefined ? [] : [`${resultsField}.${OUTLIER_SCORE_FIELD}`]), - ]; - - const queryFallback = searchQuery !== undefined ? searchQuery : { match_all: {} }; - const query = randomizeQuery - ? { - function_score: { - query: queryFallback, - random_score: { seed: 10, field: '_seq_no' }, - }, - } - : queryFallback; - - const resp: SearchResponse7 = await esSearch({ - index, - body: { - fields: queryFields, - _source: false, - query, - from: 0, - size: fetchSize, - }, - }); - - if (!options.didCancel) { - const items = resp.hits.hits.map((d) => - getProcessedFields(d.fields, (key: string) => - key.startsWith(`${resultsField}.feature_importance`) - ) - ); - - setSplom({ columns: fields, items }); - setIsLoading(false); - } - } catch (e) { - // TODO error handling - setIsLoading(false); - } - } - - const options = { didCancel: false }; - fetchSplom(options); - return () => { - options.didCancel = true; - }; - // stringify the fields array and search, otherwise the comparator will trigger on new but identical instances. - }, [fetchSize, JSON.stringify({ fields, searchQuery }), index, randomizeQuery, resultsField]); - - const htmlId = useMemo(() => htmlIdGenerator()(), []); - - useEffect(() => { - if (splom === undefined) { - return; - } - - const { items, columns } = splom; - - const values = - resultsField !== undefined - ? items - : items.map((d) => { - d[`${resultsField}.${OUTLIER_SCORE_FIELD}`] = 0; - return d; - }); - - const vegaSpec = getScatterplotMatrixVegaLiteSpec( - values, - columns, - euiTheme, - resultsField, - color, - legendType, - dynamicSize - ); - - const vgSpec = compile(vegaSpec).spec; - - const view = new View(parse(vgSpec)) - .logLevel(Warn) - .renderer('canvas') - .tooltip(new Handler().call) - .initialize(`#${htmlId}`); - - view.runAsync(); // evaluate and render the view - }, [resultsField, splom, color, legendType, dynamicSize]); - - return ( - <> - {splom === undefined ? ( - - ) : ( - <> - - - - ({ - label: d, - }))} - onChange={fieldsOnChange} - isClearable={true} - data-test-subj="mlScatterplotMatrixFieldsComboBox" - /> - - - - - - - - - - - - - {resultsField !== undefined && legendType === undefined && ( - - - - - - )} - - -
- - )} - - ); -}; - -// required for dynamic import using React.lazy() -// eslint-disable-next-line import/no-default-export -export default ScatterplotMatrixView; diff --git a/x-pack/plugins/ml/public/application/components/vega_chart/common.ts b/x-pack/plugins/ml/public/application/components/vega_chart/common.ts new file mode 100644 index 0000000000000..79254788ce7a6 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/vega_chart/common.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const LEGEND_TYPES = { + NOMINAL: 'nominal', + QUANTITATIVE: 'quantitative', +} as const; +export type LegendType = typeof LEGEND_TYPES[keyof typeof LEGEND_TYPES]; diff --git a/x-pack/plugins/ml/public/application/components/vega_chart/index.ts b/x-pack/plugins/ml/public/application/components/vega_chart/index.ts new file mode 100644 index 0000000000000..f1d5c3ed4523b --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/vega_chart/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// Make sure to only export the component we can lazy load here. +// Code from other files in this directory should be imported directly from the file, +// otherwise we break the bundling approach using lazy loading. +export { VegaChart } from './vega_chart'; diff --git a/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart.tsx b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart.tsx new file mode 100644 index 0000000000000..ab175908d9d79 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, Suspense } from 'react'; + +import { VegaChartLoading } from './vega_chart_loading'; +import type { VegaChartViewProps } from './vega_chart_view'; + +const VegaChartView = React.lazy(() => import('./vega_chart_view')); + +export const VegaChart: FC = (props) => ( + }> + + +); diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_loading.tsx b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_loading.tsx similarity index 91% rename from x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_loading.tsx rename to x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_loading.tsx index cdb4d99b041d5..8a5c1575f94d6 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_loading.tsx +++ b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_loading.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui'; -export const ScatterplotMatrixLoading = () => { +export const VegaChartLoading = () => { return ( diff --git a/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_view.tsx b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_view.tsx new file mode 100644 index 0000000000000..7774def574b69 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_view.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useEffect, FC } from 'react'; + +// There is still an issue with Vega Lite's typings with the strict mode Kibana is using. +// @ts-ignore +import type { TopLevelSpec } from 'vega-lite/build-es5/vega-lite'; + +// There is still an issue with Vega Lite's typings with the strict mode Kibana is using. +// @ts-ignore +import { compile } from 'vega-lite/build-es5/vega-lite'; +import { parse, View, Warn } from 'vega'; +import { Handler } from 'vega-tooltip'; + +import { htmlIdGenerator } from '@elastic/eui'; + +export interface VegaChartViewProps { + vegaSpec: TopLevelSpec; +} + +export const VegaChartView: FC = ({ vegaSpec }) => { + const htmlId = useMemo(() => htmlIdGenerator()(), []); + + useEffect(() => { + const vgSpec = compile(vegaSpec).spec; + + const view = new View(parse(vgSpec)) + .logLevel(Warn) + .renderer('canvas') + .tooltip(new Handler().call) + .initialize(`#${htmlId}`); + + view.runAsync(); // evaluate and render the view + }, [vegaSpec]); + + return
; +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default VegaChartView; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 4f1799ed26f87..1c13177e44e7f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -154,11 +154,21 @@ export interface ConfusionMatrix { other_predicted_class_doc_count: number; } +export interface RocCurveItem { + fpr: number; + threshold: number; + tpr: number; +} + export interface ClassificationEvaluateResponse { classification: { - multiclass_confusion_matrix: { + multiclass_confusion_matrix?: { confusion_matrix: ConfusionMatrix[]; }; + auc_roc?: { + curve?: RocCurveItem[]; + value: number; + }; }; } @@ -244,7 +254,8 @@ export const isClassificationEvaluateResponse = ( return ( keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && - arg?.classification?.multiclass_confusion_matrix !== undefined + (arg?.classification?.multiclass_confusion_matrix !== undefined || + arg?.classification?.auc_roc !== undefined) ); }; @@ -422,7 +433,8 @@ export enum REGRESSION_STATS { interface EvaluateMetrics { classification: { - multiclass_confusion_matrix: object; + multiclass_confusion_matrix?: object; + auc_roc?: { include_curve: boolean; class_name: string }; }; regression: { r_squared: object; @@ -442,6 +454,8 @@ interface LoadEvalDataConfig { ignoreDefaultQuery?: boolean; jobType: DataFrameAnalysisConfigType; requiresKeyword?: boolean; + rocCurveClassName?: string; + includeMulticlassConfusionMatrix?: boolean; } export const loadEvalData = async ({ @@ -454,6 +468,8 @@ export const loadEvalData = async ({ ignoreDefaultQuery, jobType, requiresKeyword, + rocCurveClassName, + includeMulticlassConfusionMatrix = true, }: LoadEvalDataConfig) => { const results: LoadEvaluateResult = { success: false, eval: null, error: null }; const defaultPredictionField = `${dependentVariable}_prediction`; @@ -469,7 +485,10 @@ export const loadEvalData = async ({ const metrics: EvaluateMetrics = { classification: { - multiclass_confusion_matrix: {}, + ...(includeMulticlassConfusionMatrix ? { multiclass_confusion_matrix: {} } : {}), + ...(rocCurveClassName !== undefined + ? { auc_roc: { include_curve: true, class_name: rocCurveClassName } } + : {}), }, regression: { r_squared: {}, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts index a8b95a415ea53..2113f9385c5ef 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts @@ -9,7 +9,7 @@ import { ANALYSIS_CONFIG_TYPE } from './analytics'; import { AnalyticsJobType } from '../pages/analytics_management/hooks/use_create_analytics_form/state'; -import { LEGEND_TYPES } from '../../components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec'; +import { LEGEND_TYPES } from '../../components/vega_chart/common'; export const getScatterplotMatrixLegendType = (jobType: AnalyticsJobType | 'unknown') => { switch (jobType) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss index d1c507c5241d5..73ced778821cf 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss @@ -1,3 +1,6 @@ +/* Fixed width so we can align it with the padding of the AUC ROC chart. */ +$labelColumnWidth: 80px; + /* Workaround for EuiDataGrid within a Flex Layout, this tricks browsers treating the width as a px value instead of % @@ -6,7 +9,7 @@ width: 100%; } -.mlDataFrameAnalyticsClassification__confusionMatrix { +.mlDataFrameAnalyticsClassification__evaluateSectionContent { padding: 0 5%; } @@ -14,7 +17,7 @@ The following two classes are a workaround to avoid having EuiDataGrid in a flex layout and just uses a legacy approach for a two column layout so we don't break IE11. */ -.mlDataFrameAnalyticsClassification__confusionMatrix:after { +.mlDataFrameAnalyticsClassification__evaluateSectionContent:after { content: ''; display: table; clear: both; @@ -22,7 +25,7 @@ .mlDataFrameAnalyticsClassification__actualLabel { float: left; - width: 8%; + width: $labelColumnWidth; padding-top: $euiSize * 4; } @@ -32,7 +35,7 @@ .mlDataFrameAnalyticsClassification__dataGridMinWidth { float: left; min-width: 480px; - width: 92%; + width: calc(100% - #{$labelColumnWidth}); .euiDataGridRowCell--boolean { text-transform: none; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx index b7dec4e5a435e..20866bf43a2f4 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -21,26 +21,20 @@ import { EuiTitle, } from '@elastic/eui'; import { useMlKibana } from '../../../../../contexts/kibana'; + +// Separate imports for lazy loadable VegaChart and related code +import { VegaChart } from '../../../../../components/vega_chart'; +import { VegaChartLoading } from '../../../../../components/vega_chart/vega_chart_loading'; + import { ErrorCallout } from '../error_callout'; -import { - getDependentVar, - getPredictionFieldName, - loadEvalData, - loadDocsCount, - DataFrameAnalyticsConfig, -} from '../../../../common'; -import { isKeywordAndTextType } from '../../../../common/fields'; +import { getDependentVar, DataFrameAnalyticsConfig } from '../../../../common'; import { DataFrameTaskStateType } from '../../../analytics_management/components/analytics_list/common'; -import { - isResultsSearchBoolQuery, - isClassificationEvaluateResponse, - ConfusionMatrix, - ResultsSearchQuery, - ANALYSIS_CONFIG_TYPE, -} from '../../../../common/analytics'; +import { ResultsSearchQuery } from '../../../../common/analytics'; import { ExpandableSection, HEADER_ITEMS_LOADING } from '../expandable_section'; +import { getRocCurveChartVegaLiteSpec } from './get_roc_curve_chart_vega_lite_spec'; + import { getColumnData, ACTUAL_CLASS_ID, @@ -48,6 +42,10 @@ import { getTrailingControlColumns, } from './column_data'; +import { isTrainingFilter } from './is_training_filter'; +import { useRocCurve } from './use_roc_curve'; +import { useConfusionMatrix } from './use_confusion_matrix'; + export interface EvaluatePanelProps { jobConfig: DataFrameAnalyticsConfig; jobStatus?: DataFrameTaskStateType; @@ -81,7 +79,7 @@ const trainingDatasetHelpText = i18n.translate( } ); -function getHelpText(dataSubsetTitle: string) { +function getHelpText(dataSubsetTitle: string): string { let helpText = entireDatasetHelpText; if (dataSubsetTitle === SUBSET_TITLE.TESTING) { helpText = testingDatasetHelpText; @@ -95,77 +93,36 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se const { services: { docLinks }, } = useMlKibana(); - const [isLoading, setIsLoading] = useState(false); - const [confusionMatrixData, setConfusionMatrixData] = useState([]); + const [columns, setColumns] = useState([]); const [columnsData, setColumnsData] = useState([]); const [showFullColumns, setShowFullColumns] = useState(false); const [popoverContents, setPopoverContents] = useState([]); - const [docsCount, setDocsCount] = useState(null); - const [error, setError] = useState(null); const [dataSubsetTitle, setDataSubsetTitle] = useState(SUBSET_TITLE.ENTIRE); // Column visibility - const [visibleColumns, setVisibleColumns] = useState(() => + const [visibleColumns, setVisibleColumns] = useState(() => columns.map(({ id }: { id: string }) => id) ); - const index = jobConfig.dest.index; - const dependentVariable = getDependentVar(jobConfig.analysis); - const predictionFieldName = getPredictionFieldName(jobConfig.analysis); - // default is 'ml' const resultsField = jobConfig.dest.results_field; - let requiresKeyword = false; + const isTraining = isTrainingFilter(searchQuery, resultsField); - const loadData = async ({ isTraining }: { isTraining: boolean | undefined }) => { - setIsLoading(true); - - try { - requiresKeyword = isKeywordAndTextType(dependentVariable); - } catch (e) { - // Additional error handling due to missing field type is handled by loadEvalData - console.error('Unable to load new field types', error); // eslint-disable-line no-console - } - - const evalData = await loadEvalData({ - isTraining, - index, - dependentVariable, - resultsField, - predictionFieldName, - searchQuery, - jobType: ANALYSIS_CONFIG_TYPE.CLASSIFICATION, - requiresKeyword, - }); - - const docsCountResp = await loadDocsCount({ - isTraining, - searchQuery, - resultsField, - destIndex: jobConfig.dest.index, - }); - - if ( - evalData.success === true && - evalData.eval && - isClassificationEvaluateResponse(evalData.eval) - ) { - const confusionMatrix = - evalData.eval?.classification?.multiclass_confusion_matrix?.confusion_matrix; - setError(null); - setConfusionMatrixData(confusionMatrix || []); - setIsLoading(false); - } else { - setIsLoading(false); - setConfusionMatrixData([]); - setError(evalData.error); - } + const { + confusionMatrixData, + docsCount, + error: errorConfusionMatrix, + isLoading: isLoadingConfusionMatrix, + } = useConfusionMatrix(jobConfig, searchQuery); - if (docsCountResp.success === true) { - setDocsCount(docsCountResp.docsCount); + useEffect(() => { + if (isTraining === undefined) { + setDataSubsetTitle(SUBSET_TITLE.ENTIRE); } else { - setDocsCount(null); + setDataSubsetTitle( + isTraining && isTraining === true ? SUBSET_TITLE.TRAINING : SUBSET_TITLE.TESTING + ); } - }; + }, [isTraining]); useEffect(() => { if (confusionMatrixData.length > 0) { @@ -198,48 +155,12 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se } }, [confusionMatrixData]); - useEffect(() => { - let isTraining: boolean | undefined; - const query = - isResultsSearchBoolQuery(searchQuery) && (searchQuery.bool.should || searchQuery.bool.filter); - - if (query !== undefined && query !== false) { - for (let i = 0; i < query.length; i++) { - const clause = query[i]; - - if (clause.match && clause.match[`${resultsField}.is_training`] !== undefined) { - isTraining = clause.match[`${resultsField}.is_training`]; - break; - } else if ( - clause.bool && - (clause.bool.should !== undefined || clause.bool.filter !== undefined) - ) { - const innerQuery = clause.bool.should || clause.bool.filter; - if (innerQuery !== undefined) { - for (let j = 0; j < innerQuery.length; j++) { - const innerClause = innerQuery[j]; - if ( - innerClause.match && - innerClause.match[`${resultsField}.is_training`] !== undefined - ) { - isTraining = innerClause.match[`${resultsField}.is_training`]; - break; - } - } - } - } - } - } - if (isTraining === undefined) { - setDataSubsetTitle(SUBSET_TITLE.ENTIRE); - } else { - setDataSubsetTitle( - isTraining && isTraining === true ? SUBSET_TITLE.TRAINING : SUBSET_TITLE.TESTING - ); - } - - loadData({ isTraining }); - }, [JSON.stringify(searchQuery)]); + const { + rocCurveData, + classificationClasses, + error: errorRocCurve, + isLoading: isLoadingRocCurve, + } = useRocCurve(jobConfig, searchQuery, visibleColumns); const renderCellValue = ({ rowIndex, @@ -312,7 +233,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se } headerItems={ - !isLoading + !isLoadingConfusionMatrix ? [ ...(jobStatus !== undefined ? [ @@ -348,94 +269,149 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se } contentPadding={true} content={ - !isLoading ? ( - <> - {error !== null && } - {error === null && ( - <> - - - {getHelpText(dataSubsetTitle)} - - - - - - {/* BEGIN TABLE ELEMENTS */} - -
-
- - - -
-
- {columns.length > 0 && columnsData.length > 0 && ( - <> -
- - - -
- - + {!isLoadingConfusionMatrix ? ( + <> + {errorConfusionMatrix !== null && } + {errorConfusionMatrix === null && ( + <> + + + {getHelpText(dataSubsetTitle)} + + + + + + {/* BEGIN TABLE ELEMENTS */} + +
+
+ + - - )} + +
+
+ {columns.length > 0 && columnsData.length > 0 && ( + <> +
+ + + +
+ + + + )} +
-
- - )} - {/* END TABLE ELEMENTS */} - - ) : null + {/* END TABLE ELEMENTS */} + + )} + + ) : null} + {/* AUC ROC Chart */} + + + + + + + + + + + + {Array.isArray(errorRocCurve) && ( + + {errorRocCurve.map((e) => ( + <> + {e} +
+ + ))} + + } + /> + )} + {!isLoadingRocCurve && errorRocCurve === null && rocCurveData.length > 0 && ( +
+ +
+ )} + {isLoadingRocCurve && } + } /> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx new file mode 100644 index 0000000000000..b9e9c5720e5aa --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx @@ -0,0 +1,131 @@ +/* + * 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. + */ + +// There is still an issue with Vega Lite's typings with the strict mode Kibana is using. +// @ts-ignore +import type { TopLevelSpec } from 'vega-lite/build-es5/vega-lite'; + +import { euiPaletteColorBlind, euiPaletteGray } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { LEGEND_TYPES } from '../../../../../components/vega_chart/common'; + +import { RocCurveItem } from '../../../../common/analytics'; + +const GRAY = euiPaletteGray(1)[0]; +const BASELINE = 'baseline'; +const SIZE = 300; + +// returns a custom color range that includes gray for the baseline +function getColorRangeNominal(classificationClasses: string[]) { + const legendItems = [...classificationClasses, BASELINE].sort(); + const baselineIndex = legendItems.indexOf(BASELINE); + + const colorRangeNominal = euiPaletteColorBlind({ rotations: 2 }).slice( + 0, + classificationClasses.length + ); + + colorRangeNominal.splice(baselineIndex, 0, GRAY); + + return colorRangeNominal; +} + +export interface RocCurveDataRow extends RocCurveItem { + class_name: string; +} + +export const getRocCurveChartVegaLiteSpec = ( + classificationClasses: string[], + data: RocCurveDataRow[], + legendTitle: string +): TopLevelSpec => { + // we append two rows which make up the data for the diagonal baseline + data.push({ tpr: 0, fpr: 0, threshold: 1, class_name: BASELINE }); + data.push({ tpr: 1, fpr: 1, threshold: 1, class_name: BASELINE }); + + const colorRangeNominal = getColorRangeNominal(classificationClasses); + + return { + $schema: 'https://vega.github.io/schema/vega-lite/v4.8.1.json', + // Left padding of 45px to align the left axis of the chart with the confusion matrix above. + padding: { left: 45, top: 0, right: 0, bottom: 0 }, + config: { + legend: { + orient: 'right', + }, + view: { + continuousHeight: SIZE, + continuousWidth: SIZE, + }, + }, + data: { + name: 'roc-curve-data', + }, + datasets: { + 'roc-curve-data': data, + }, + encoding: { + color: { + field: 'class_name', + type: LEGEND_TYPES.NOMINAL, + scale: { + range: colorRangeNominal, + }, + legend: { + title: legendTitle, + }, + }, + size: { + value: 2, + }, + strokeDash: { + condition: { + test: `(datum.class_name === '${BASELINE}')`, + value: [5, 5], + }, + value: [0], + }, + x: { + field: 'fpr', + sort: null, + title: i18n.translate('xpack.ml.dataframe.analytics.rocChartSpec.xAxisTitle', { + defaultMessage: 'False Positive Rate (FPR)', + }), + type: 'quantitative', + axis: { + tickColor: GRAY, + labelColor: GRAY, + domainColor: GRAY, + titleColor: GRAY, + }, + }, + y: { + field: 'tpr', + title: i18n.translate('xpack.ml.dataframe.analytics.rocChartSpec.yAxisTitle', { + defaultMessage: 'True Positive Rate (TPR) (a.k.a Recall)', + }), + type: 'quantitative', + axis: { + tickColor: GRAY, + labelColor: GRAY, + domainColor: GRAY, + titleColor: GRAY, + }, + }, + tooltip: [ + { type: LEGEND_TYPES.NOMINAL, field: 'class_name' }, + { type: LEGEND_TYPES.QUANTITATIVE, field: 'fpr' }, + { type: LEGEND_TYPES.QUANTITATIVE, field: 'tpr' }, + ], + }, + height: SIZE, + width: SIZE, + mark: 'line', + }; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/is_training_filter.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/is_training_filter.ts new file mode 100644 index 0000000000000..21203f85bbe84 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/is_training_filter.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isResultsSearchBoolQuery, ResultsSearchQuery } from '../../../../common/analytics'; + +export type IsTraining = boolean | undefined; + +export function isTrainingFilter( + searchQuery: ResultsSearchQuery, + resultsField: string +): IsTraining { + let isTraining: IsTraining; + const query = + isResultsSearchBoolQuery(searchQuery) && (searchQuery.bool.should || searchQuery.bool.filter); + + if (query !== undefined && query !== false) { + for (let i = 0; i < query.length; i++) { + const clause = query[i]; + + if (clause.match && clause.match[`${resultsField}.is_training`] !== undefined) { + isTraining = clause.match[`${resultsField}.is_training`]; + break; + } else if ( + clause.bool && + (clause.bool.should !== undefined || clause.bool.filter !== undefined) + ) { + const innerQuery = clause.bool.should || clause.bool.filter; + if (innerQuery !== undefined) { + for (let j = 0; j < innerQuery.length; j++) { + const innerClause = innerQuery[j]; + if ( + innerClause.match && + innerClause.match[`${resultsField}.is_training`] !== undefined + ) { + isTraining = innerClause.match[`${resultsField}.is_training`]; + break; + } + } + } + } + } + } + + return isTraining; +} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts new file mode 100644 index 0000000000000..be44a8e36ed00 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect } from 'react'; + +import { + isClassificationEvaluateResponse, + ConfusionMatrix, + ResultsSearchQuery, + ANALYSIS_CONFIG_TYPE, +} from '../../../../common/analytics'; +import { isKeywordAndTextType } from '../../../../common/fields'; + +import { + getDependentVar, + getPredictionFieldName, + loadEvalData, + loadDocsCount, + DataFrameAnalyticsConfig, +} from '../../../../common'; + +import { isTrainingFilter } from './is_training_filter'; + +export const useConfusionMatrix = ( + jobConfig: DataFrameAnalyticsConfig, + searchQuery: ResultsSearchQuery +) => { + const [confusionMatrixData, setConfusionMatrixData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [docsCount, setDocsCount] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadConfusionMatrixData() { + setIsLoading(true); + + let requiresKeyword = false; + const dependentVariable = getDependentVar(jobConfig.analysis); + const resultsField = jobConfig.dest.results_field; + const isTraining = isTrainingFilter(searchQuery, resultsField); + + try { + requiresKeyword = isKeywordAndTextType(dependentVariable); + } catch (e) { + // Additional error handling due to missing field type is handled by loadEvalData + console.error('Unable to load new field types', e); // eslint-disable-line no-console + } + + const evalData = await loadEvalData({ + isTraining, + index: jobConfig.dest.index, + dependentVariable, + resultsField, + predictionFieldName: getPredictionFieldName(jobConfig.analysis), + searchQuery, + jobType: ANALYSIS_CONFIG_TYPE.CLASSIFICATION, + requiresKeyword, + }); + + const docsCountResp = await loadDocsCount({ + isTraining, + searchQuery, + resultsField, + destIndex: jobConfig.dest.index, + }); + + if ( + evalData.success === true && + evalData.eval && + isClassificationEvaluateResponse(evalData.eval) + ) { + const confusionMatrix = + evalData.eval?.classification?.multiclass_confusion_matrix?.confusion_matrix; + setError(null); + setConfusionMatrixData(confusionMatrix || []); + setIsLoading(false); + } else { + setIsLoading(false); + setConfusionMatrixData([]); + setError(evalData.error); + } + + if (docsCountResp.success === true) { + setDocsCount(docsCountResp.docsCount); + } else { + setDocsCount(null); + } + } + + loadConfusionMatrixData(); + }, [JSON.stringify([jobConfig, searchQuery])]); + + return { confusionMatrixData, docsCount, error, isLoading }; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts new file mode 100644 index 0000000000000..8cdb6f86ebdda --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect } from 'react'; + +import { + isClassificationEvaluateResponse, + ResultsSearchQuery, + RocCurveItem, + ANALYSIS_CONFIG_TYPE, +} from '../../../../common/analytics'; +import { isKeywordAndTextType } from '../../../../common/fields'; + +import { + getDependentVar, + getPredictionFieldName, + loadEvalData, + DataFrameAnalyticsConfig, +} from '../../../../common'; + +import { ACTUAL_CLASS_ID, OTHER_CLASS_ID } from './column_data'; + +import { isTrainingFilter } from './is_training_filter'; + +interface RocCurveDataRow extends RocCurveItem { + class_name: string; +} + +export const useRocCurve = ( + jobConfig: DataFrameAnalyticsConfig, + searchQuery: ResultsSearchQuery, + visibleColumns: string[] +) => { + const classificationClasses = visibleColumns.filter( + (d) => d !== ACTUAL_CLASS_ID && d !== OTHER_CLASS_ID + ); + + const [rocCurveData, setRocCurveData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadRocCurveData() { + setIsLoading(true); + + const dependentVariable = getDependentVar(jobConfig.analysis); + const resultsField = jobConfig.dest.results_field; + + const newRocCurveData: RocCurveDataRow[] = []; + + let requiresKeyword = false; + const errors: string[] = []; + + try { + requiresKeyword = isKeywordAndTextType(dependentVariable); + } catch (e) { + // Additional error handling due to missing field type is handled by loadEvalData + console.error('Unable to load new field types', e); // eslint-disable-line no-console + } + + for (let i = 0; i < classificationClasses.length; i++) { + const rocCurveClassName = classificationClasses[i]; + const evalData = await loadEvalData({ + isTraining: isTrainingFilter(searchQuery, resultsField), + index: jobConfig.dest.index, + dependentVariable, + resultsField, + predictionFieldName: getPredictionFieldName(jobConfig.analysis), + searchQuery, + jobType: ANALYSIS_CONFIG_TYPE.CLASSIFICATION, + requiresKeyword, + rocCurveClassName, + includeMulticlassConfusionMatrix: false, + }); + + if ( + evalData.success === true && + evalData.eval && + isClassificationEvaluateResponse(evalData.eval) + ) { + const auc = evalData.eval?.classification?.auc_roc?.value || 0; + const rocCurveDataForClass = (evalData.eval?.classification?.auc_roc?.curve || []).map( + (d) => ({ + class_name: `${rocCurveClassName} (AUC: ${Math.round(auc * 100000) / 100000})`, + ...d, + }) + ); + newRocCurveData.push(...rocCurveDataForClass); + } else if (evalData.error !== null) { + errors.push(evalData.error); + } + } + + setError(errors.length > 0 ? errors : null); + setRocCurveData(newRocCurveData); + setIsLoading(false); + } + + loadRocCurveData(); + }, [JSON.stringify([jobConfig, searchQuery, visibleColumns])]); + + return { rocCurveData, classificationClasses, error, isLoading }; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/error_callout.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/error_callout.tsx index d18e5b55794b5..81f5e53570809 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/error_callout.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/error_callout.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { EuiCallOut } from '@elastic/eui'; interface Props { - error: string; + error: string | JSX.Element; } export const ErrorCallout: FC = ({ error }) => { @@ -26,7 +26,7 @@ export const ErrorCallout: FC = ({ error }) => { ); // Job was created but not started so the destination index has not been created - if (error.includes('index_not_found')) { + if (typeof error === 'string' && error.includes('index_not_found')) { errorCallout = ( = ({ error }) => {

); - } else if (error.includes('No documents found')) { + } else if (typeof error === 'string' && error.includes('No documents found')) { // Job was started but no results have been written yet errorCallout = ( = ({ error }) => {

); - } else if (error.includes('userProvidedQueryBuilder')) { + } else if (typeof error === 'string' && error.includes('userProvidedQueryBuilder')) { // query bar syntax is incorrect errorCallout = ( = ({ )} + {isLoadingJobConfig === true && jobConfig === undefined && } + {isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && ( + + )} + {isLoadingJobConfig === true && jobConfig !== undefined && totalFeatureImportance === undefined && } @@ -191,10 +196,7 @@ export const ExplorationPageWrapper: FC = ({ )} - {isLoadingJobConfig === true && jobConfig === undefined && } - {isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && ( - - )} + {isLoadingJobConfig === true && jobConfig === undefined && } {isLoadingJobConfig === false && diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts index 59f1775bb2117..1d67408b73360 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts @@ -41,6 +41,15 @@ export default function ({ getService }: FtrProviderContext) { modelMemory: '60mb', createIndexPattern: true, expected: { + rocCurveColorState: [ + // background + { key: '#FFFFFF', value: 93 }, + // tick/grid/axis + { key: '#98A2B3', value: 1 }, + { key: '#DDDDDD', value: 3 }, + // line + { key: '#6092C0', value: 1 }, + ], scatterplotMatrixColorStats: [ // background { key: '#000000', value: 94 }, @@ -102,7 +111,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); await ml.testExecution.logTestStep('displays the scatterplot matrix'); - await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + await ml.dataFrameAnalyticsCanvasElement.assertCanvasElement( 'mlAnalyticsCreateJobWizardScatterplotMatrixFormRow', testData.expected.scatterplotMatrixColorStats ); @@ -221,11 +230,15 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('displays the results view for created job'); await ml.dataFrameAnalyticsTable.openResultsView(testData.jobId); await ml.dataFrameAnalyticsResults.assertClassificationEvaluatePanelElementsExists(); + await ml.dataFrameAnalyticsCanvasElement.assertCanvasElement( + 'mlDFAnalyticsClassificationExplorationRocCurveChart', + testData.expected.rocCurveColorState + ); await ml.dataFrameAnalyticsResults.assertClassificationTablePanelExists(); await ml.dataFrameAnalyticsResults.assertResultsTableExists(); await ml.dataFrameAnalyticsResults.assertResultsTableTrainingFiltersExist(); await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); - await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + await ml.dataFrameAnalyticsCanvasElement.assertCanvasElement( 'mlDFExpandableSection-splom', testData.expected.scatterplotMatrixColorStats ); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts index 02535f158ee63..8b291fa36867a 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts @@ -128,7 +128,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); await ml.testExecution.logTestStep('displays the scatterplot matrix'); - await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + await ml.dataFrameAnalyticsCanvasElement.assertCanvasElement( 'mlAnalyticsCreateJobWizardScatterplotMatrixFormRow', testData.expected.scatterplotMatrixColorStatsWizard ); @@ -249,7 +249,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsResults.assertOutlierTablePanelExists(); await ml.dataFrameAnalyticsResults.assertResultsTableExists(); await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); - await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + await ml.dataFrameAnalyticsCanvasElement.assertCanvasElement( 'mlDFExpandableSection-splom', testData.expected.scatterplotMatrixColorStatsResults ); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts index f41944e3409d7..4ce5d5b352e14 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts @@ -101,7 +101,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); await ml.testExecution.logTestStep('displays the scatterplot matrix'); - await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + await ml.dataFrameAnalyticsCanvasElement.assertCanvasElement( 'mlAnalyticsCreateJobWizardScatterplotMatrixFormRow', testData.expected.scatterplotMatrixColorStats ); @@ -224,7 +224,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsResults.assertResultsTableExists(); await ml.dataFrameAnalyticsResults.assertResultsTableTrainingFiltersExist(); await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); - await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + await ml.dataFrameAnalyticsCanvasElement.assertCanvasElement( 'mlDFExpandableSection-splom', testData.expected.scatterplotMatrixColorStats ); diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_scatterplot.ts b/x-pack/test/functional/services/ml/data_frame_analytics_canvas_element.ts similarity index 72% rename from x-pack/test/functional/services/ml/data_frame_analytics_scatterplot.ts rename to x-pack/test/functional/services/ml/data_frame_analytics_canvas_element.ts index 39b387e2de650..a354e0723d377 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_scatterplot.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_canvas_element.ts @@ -9,14 +9,14 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; -export function MachineLearningDataFrameAnalyticsScatterplotProvider({ +export function MachineLearningDataFrameAnalyticsCanvasElementProvider({ getService, }: FtrProviderContext) { const canvasElement = getService('canvasElement'); const testSubjects = getService('testSubjects'); - return new (class AnalyticsScatterplot { - public async assertScatterplotMatrix( + return new (class AnalyticsCanvasElement { + public async assertCanvasElement( dataTestSubj: string, expectedColorStats: Array<{ key: string; @@ -24,16 +24,15 @@ export function MachineLearningDataFrameAnalyticsScatterplotProvider({ }> ) { await testSubjects.existOrFail(dataTestSubj); - await testSubjects.existOrFail('mlScatterplotMatrix'); const actualColorStats = await canvasElement.getColorStats( - `[data-test-subj="mlScatterplotMatrix"] canvas`, + `[data-test-subj="${dataTestSubj}"] canvas`, expectedColorStats, 1 ); expect(actualColorStats.every((d) => d.withinTolerance)).to.eql( true, - `Color stats for scatterplot matrix should be within tolerance. Expected: '${JSON.stringify( + `Color stats for canvas element should be within tolerance. Expected: '${JSON.stringify( expectedColorStats )}' (got '${JSON.stringify(actualColorStats)}')` ); diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_results.ts b/x-pack/test/functional/services/ml/data_frame_analytics_results.ts index b6aba13054f75..c08e13cedaaa5 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_results.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_results.ts @@ -32,6 +32,7 @@ export function MachineLearningDataFrameAnalyticsResultsProvider({ async assertClassificationEvaluatePanelElementsExists() { await testSubjects.existOrFail('mlDFExpandableSection-ClassificationEvaluation'); await testSubjects.existOrFail('mlDFAnalyticsClassificationExplorationConfusionMatrix'); + await testSubjects.existOrFail('mlDFAnalyticsClassificationExplorationRocCurveChart'); }, async assertClassificationTablePanelExists() { diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts index 91d009316cf9e..ceee1ba7dc1ac 100644 --- a/x-pack/test/functional/services/ml/index.ts +++ b/x-pack/test/functional/services/ml/index.ts @@ -18,7 +18,7 @@ import { MachineLearningDataFrameAnalyticsProvider } from './data_frame_analytic import { MachineLearningDataFrameAnalyticsCreationProvider } from './data_frame_analytics_creation'; import { MachineLearningDataFrameAnalyticsEditProvider } from './data_frame_analytics_edit'; import { MachineLearningDataFrameAnalyticsResultsProvider } from './data_frame_analytics_results'; -import { MachineLearningDataFrameAnalyticsScatterplotProvider } from './data_frame_analytics_scatterplot'; +import { MachineLearningDataFrameAnalyticsCanvasElementProvider } from './data_frame_analytics_canvas_element'; import { MachineLearningDataFrameAnalyticsMapProvider } from './data_frame_analytics_map'; import { MachineLearningDataFrameAnalyticsTableProvider } from './data_frame_analytics_table'; import { MachineLearningDataVisualizerProvider } from './data_visualizer'; @@ -66,7 +66,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { const dataFrameAnalyticsResults = MachineLearningDataFrameAnalyticsResultsProvider(context); const dataFrameAnalyticsMap = MachineLearningDataFrameAnalyticsMapProvider(context); const dataFrameAnalyticsTable = MachineLearningDataFrameAnalyticsTableProvider(context); - const dataFrameAnalyticsScatterplot = MachineLearningDataFrameAnalyticsScatterplotProvider( + const dataFrameAnalyticsCanvasElement = MachineLearningDataFrameAnalyticsCanvasElementProvider( context ); @@ -113,7 +113,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { dataFrameAnalyticsResults, dataFrameAnalyticsMap, dataFrameAnalyticsTable, - dataFrameAnalyticsScatterplot, + dataFrameAnalyticsCanvasElement, dataVisualizer, dataVisualizerFileBased, dataVisualizerIndexBased, From 2db193b4edde9c0baabe602305ceb6f7db7f3a67 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Mon, 15 Feb 2021 15:12:24 +0100 Subject: [PATCH 04/23] [Discover] Fix toggling multi fields from doc view table (#91121) --- .../application/components/table/table.tsx | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/plugins/discover/public/application/components/table/table.tsx b/src/plugins/discover/public/application/components/table/table.tsx index 684a7d4fd467c..093b445267241 100644 --- a/src/plugins/discover/public/application/components/table/table.tsx +++ b/src/plugins/discover/public/application/components/table/table.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { DocViewTableRow } from './table_row'; import { trimAngularSpan } from './table_helper'; @@ -54,6 +54,20 @@ export function DocViewTable({ setFieldsWithParents(arr); }, [indexPattern, hit]); + const toggleColumn = useCallback( + (field: string) => { + if (!onRemoveColumn || !onAddColumn || !columns) { + return; + } + if (columns.includes(field)) { + onRemoveColumn(field); + } else { + onAddColumn(field); + } + }, + [onRemoveColumn, onAddColumn, columns] + ); + if (!indexPattern) { return null; } @@ -65,6 +79,7 @@ export function DocViewTable({ fieldRowOpen[field] = !fieldRowOpen[field]; setFieldRowOpen({ ...fieldRowOpen }); } + return ( @@ -85,16 +100,6 @@ export function DocViewTable({ const isCollapsible = value.length > COLLAPSE_LINE_LENGTH; const isCollapsed = isCollapsible && !fieldRowOpen[field]; - const toggleColumn = - onRemoveColumn && onAddColumn && Array.isArray(columns) - ? () => { - if (columns.includes(field)) { - onRemoveColumn(field); - } else { - onAddColumn(field); - } - } - : undefined; const displayUnderscoreWarning = !mapping(field) && field.indexOf('_') === 0; const fieldType = isNestedFieldParent(field, indexPattern) @@ -109,10 +114,10 @@ export function DocViewTable({ displayUnderscoreWarning={displayUnderscoreWarning} isCollapsed={isCollapsed} isCollapsible={isCollapsible} - isColumnActive={Array.isArray(columns) && columns.includes(field)} + isColumnActive={!!columns?.includes(field)} onFilter={filter} onToggleCollapse={() => toggleValueCollapse(field)} - onToggleColumn={toggleColumn} + onToggleColumn={() => toggleColumn(field)} value={value} valueRaw={valueRaw} /> @@ -123,7 +128,7 @@ export function DocViewTable({ data-test-subj={`tableDocViewRow-multifieldsTitle-${field}`} > - - + {columns} ); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js b/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js index cb9baaa4112bb..fbec412c30f48 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js @@ -7,6 +7,7 @@ */ import React from 'react'; +import { i18n } from '@kbn/i18n'; import { getDisplayName } from './lib/get_display_name'; import { labelDateFormatter } from './lib/label_date_formatter'; import { findIndex, first } from 'lodash'; @@ -22,7 +23,7 @@ export function visWithSplits(WrappedComponent) { const splitsVisData = visData[model.id].series.reduce((acc, series) => { const [seriesId, splitId] = series.id.split(':'); const seriesModel = model.series.find((s) => s.id === seriesId); - if (!seriesModel || !splitId) return acc; + if (!seriesModel) return acc; const label = series.splitByLabel; @@ -80,7 +81,12 @@ export function visWithSplits(WrappedComponent) { model={model} visData={newVisData} onBrush={props.onBrush} - additionalLabel={additionalLabel} + additionalLabel={ + additionalLabel || + i18n.translate('visTypeTimeseries.emptyTextValue', { + defaultMessage: '(empty)', + }) + } backgroundColor={props.backgroundColor} getConfig={props.getConfig} /> diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js index 9ca53edc50ce9..4ec60661ffed2 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js @@ -9,6 +9,7 @@ import React, { useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import { i18n } from '@kbn/i18n'; import { labelDateFormatter } from '../../../components/lib/label_date_formatter'; import { @@ -188,7 +189,12 @@ export const TimeSeries = ({ key={key} seriesId={id} seriesGroupId={groupId} - name={seriesName} + name={ + seriesName || + i18n.translate('visTypeTimeseries.emptyTextValue', { + defaultMessage: '(empty)', + }) + } data={data} hideInLegend={hideInLegend} bars={bars} @@ -213,7 +219,12 @@ export const TimeSeries = ({ key={key} seriesId={id} seriesGroupId={groupId} - name={seriesName} + name={ + seriesName || + i18n.translate('visTypeTimeseries.emptyTextValue', { + defaultMessage: '(empty)', + }) + } data={data} hideInLegend={hideInLegend} lines={lines} diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js index 42d6d095d0648..542ee0871fdcb 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js @@ -8,6 +8,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import { i18n } from '@kbn/i18n'; import { getLastValue } from '../../../../common/get_last_value'; import { labelDateFormatter } from '../../components/lib/label_date_formatter'; import reactcss from 'reactcss'; @@ -103,6 +104,7 @@ export class TopN extends Component { // if both are 0, the division returns NaN causing unexpected behavior. // For this it defaults to 0 const width = 100 * (Math.abs(lastValue) / intervalLength) || 0; + const label = item.labelFormatted ? labelDateFormatter(item.labelFormatted) : item.label; const styles = reactcss( { @@ -128,7 +130,10 @@ export class TopN extends Component { return (
  + {i18n.translate('discover.fieldChooser.discoverField.multiFields', { defaultMessage: 'Multi fields', @@ -142,10 +147,12 @@ export function DocViewTable({ displayUnderscoreWarning={displayUnderscoreWarning} isCollapsed={isCollapsed} isCollapsible={isCollapsible} - isColumnActive={Array.isArray(columns) && columns.includes(field)} + isColumnActive={Array.isArray(columns) && columns.includes(multiField)} onFilter={filter} - onToggleCollapse={() => toggleValueCollapse(field)} - onToggleColumn={toggleColumn} + onToggleCollapse={() => { + toggleValueCollapse(multiField); + }} + onToggleColumn={() => toggleColumn(multiField)} value={value} valueRaw={valueRaw} /> From 42e11e6763472a1e25c0854506a618fe06c33bde Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Mon, 15 Feb 2021 07:48:16 -0700 Subject: [PATCH 05/23] [data.search.session] Server telemetry on search sessions (#91256) * [data.search.session] Server telemetry on search sessions * Update telemetry mappings * Added tests and logger Co-authored-by: Liza K --- .../server/collectors/fetch.test.ts | 94 +++++++++++++++++++ .../data_enhanced/server/collectors/fetch.ts | 59 ++++++++++++ .../data_enhanced/server/collectors/index.ts | 8 ++ .../server/collectors/register.ts | 38 ++++++++ x-pack/plugins/data_enhanced/server/plugin.ts | 5 + .../schema/xpack_plugins.json | 13 +++ 6 files changed, 217 insertions(+) create mode 100644 x-pack/plugins/data_enhanced/server/collectors/fetch.test.ts create mode 100644 x-pack/plugins/data_enhanced/server/collectors/fetch.ts create mode 100644 x-pack/plugins/data_enhanced/server/collectors/index.ts create mode 100644 x-pack/plugins/data_enhanced/server/collectors/register.ts diff --git a/x-pack/plugins/data_enhanced/server/collectors/fetch.test.ts b/x-pack/plugins/data_enhanced/server/collectors/fetch.test.ts new file mode 100644 index 0000000000000..380cc0e354502 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/collectors/fetch.test.ts @@ -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 { + SharedGlobalConfig, + ElasticsearchClient, + SavedObjectsErrorHelpers, + Logger, +} from '../../../../../src/core/server'; +import { BehaviorSubject } from 'rxjs'; +import { fetchProvider } from './fetch'; +import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; + +describe('fetchProvider', () => { + let fetchFn: any; + let esClient: jest.Mocked; + let mockLogger: Logger; + + beforeEach(async () => { + const config$ = new BehaviorSubject({ + kibana: { + index: '123', + }, + } as any); + mockLogger = { + warn: jest.fn(), + debug: jest.fn(), + } as any; + esClient = elasticsearchServiceMock.createElasticsearchClient(); + fetchFn = fetchProvider(config$, mockLogger); + }); + + test('returns when ES returns no results', async () => { + esClient.search.mockResolvedValue({ + statusCode: 200, + body: { + aggregations: { + persisted: { + buckets: [], + }, + }, + }, + } as any); + + const collRes = await fetchFn({ esClient }); + expect(collRes.transientCount).toBe(0); + expect(collRes.persistedCount).toBe(0); + expect(collRes.totalCount).toBe(0); + expect(mockLogger.warn).not.toBeCalled(); + }); + + test('returns when ES throws an error', async () => { + esClient.search.mockRejectedValue( + SavedObjectsErrorHelpers.createTooManyRequestsError('a', 'b') + ); + + const collRes = await fetchFn({ esClient }); + expect(collRes.transientCount).toBe(0); + expect(collRes.persistedCount).toBe(0); + expect(collRes.totalCount).toBe(0); + expect(mockLogger.warn).toBeCalledTimes(1); + }); + + test('returns when ES returns full buckets', async () => { + esClient.search.mockResolvedValue({ + statusCode: 200, + body: { + aggregations: { + persisted: { + buckets: [ + { + key_as_string: 'true', + doc_count: 10, + }, + { + key_as_string: 'false', + doc_count: 7, + }, + ], + }, + }, + }, + } as any); + + const collRes = await fetchFn({ esClient }); + expect(collRes.transientCount).toBe(7); + expect(collRes.persistedCount).toBe(10); + expect(collRes.totalCount).toBe(17); + }); +}); diff --git a/x-pack/plugins/data_enhanced/server/collectors/fetch.ts b/x-pack/plugins/data_enhanced/server/collectors/fetch.ts new file mode 100644 index 0000000000000..428de148fdd4f --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/collectors/fetch.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { SearchResponse } from 'elasticsearch'; +import { SharedGlobalConfig, Logger } from 'kibana/server'; +import { CollectorFetchContext } from '../../../../../src/plugins/usage_collection/server'; +import { SEARCH_SESSION_TYPE } from '../../common'; +import { ReportedUsage } from './register'; + +interface SessionPersistedTermsBucket { + key_as_string: 'false' | 'true'; + doc_count: number; +} + +export function fetchProvider(config$: Observable, logger: Logger) { + return async ({ esClient }: CollectorFetchContext): Promise => { + try { + const config = await config$.pipe(first()).toPromise(); + const { body: esResponse } = await esClient.search>({ + index: config.kibana.index, + body: { + size: 0, + aggs: { + persisted: { + terms: { + field: `${SEARCH_SESSION_TYPE}.persisted`, + }, + }, + }, + }, + }); + + const { buckets } = esResponse.aggregations.persisted; + if (!buckets.length) { + return { transientCount: 0, persistedCount: 0, totalCount: 0 }; + } + + const { transientCount = 0, persistedCount = 0 } = buckets.reduce( + (usage: Partial, bucket: SessionPersistedTermsBucket) => { + const key = bucket.key_as_string === 'false' ? 'transientCount' : 'persistedCount'; + return { ...usage, [key]: bucket.doc_count }; + }, + {} + ); + const totalCount = transientCount + persistedCount; + logger.debug(`fetchProvider | ${persistedCount} persisted | ${transientCount} transient`); + return { transientCount, persistedCount, totalCount }; + } catch (e) { + logger.warn(`fetchProvider | error | ${e.message}`); + return { transientCount: 0, persistedCount: 0, totalCount: 0 }; + } + }; +} diff --git a/x-pack/plugins/data_enhanced/server/collectors/index.ts b/x-pack/plugins/data_enhanced/server/collectors/index.ts new file mode 100644 index 0000000000000..4a82c76e96dee --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/collectors/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 { registerUsageCollector } from './register'; diff --git a/x-pack/plugins/data_enhanced/server/collectors/register.ts b/x-pack/plugins/data_enhanced/server/collectors/register.ts new file mode 100644 index 0000000000000..fe96b7f7ced1b --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/collectors/register.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext, Logger } from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { fetchProvider } from './fetch'; + +export interface ReportedUsage { + transientCount: number; + persistedCount: number; + totalCount: number; +} + +export async function registerUsageCollector( + usageCollection: UsageCollectionSetup, + context: PluginInitializerContext, + logger: Logger +) { + try { + const collector = usageCollection.makeUsageCollector({ + type: 'search-session', + isReady: () => true, + fetch: fetchProvider(context.config.legacy.globalConfig$, logger), + schema: { + transientCount: { type: 'long' }, + persistedCount: { type: 'long' }, + totalCount: { type: 'long' }, + }, + }); + usageCollection.registerCollector(collector); + } catch (err) { + return; // kibana plugin is not enabled (test environment) + } +} diff --git a/x-pack/plugins/data_enhanced/server/plugin.ts b/x-pack/plugins/data_enhanced/server/plugin.ts index c3d342b8159e3..1037de4f79ea7 100644 --- a/x-pack/plugins/data_enhanced/server/plugin.ts +++ b/x-pack/plugins/data_enhanced/server/plugin.ts @@ -24,6 +24,7 @@ import { import { getUiSettings } from './ui_settings'; import type { DataEnhancedRequestHandlerContext } from './type'; import { ConfigSchema } from '../config'; +import { registerUsageCollector } from './collectors'; import { SecurityPluginSetup } from '../../security/server'; interface SetupDependencies { @@ -85,6 +86,10 @@ export class EnhancedDataServerPlugin this.sessionService.setup(core, { taskManager: deps.taskManager, }); + + if (deps.usageCollection) { + registerUsageCollector(deps.usageCollection, this.initializerContext, this.logger); + } } public start(core: CoreStart, { taskManager }: StartDependencies) { diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index d4b07203e8109..d3487078fd114 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -3183,6 +3183,19 @@ } } }, + "search-session": { + "properties": { + "transientCount": { + "type": "long" + }, + "persistedCount": { + "type": "long" + }, + "totalCount": { + "type": "long" + } + } + }, "security_solution": { "properties": { "detections": { From 0817e9803261245e98e10ccae93277c85197875d Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 15 Feb 2021 16:14:50 +0100 Subject: [PATCH 06/23] [Lens] Make sure telemetry shape is always the same (#91129) --- .../plugins/lens/server/usage/collectors.ts | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/lens/server/usage/collectors.ts b/x-pack/plugins/lens/server/usage/collectors.ts index 4c20f946b1a44..2f7a72ba17ea0 100644 --- a/x-pack/plugins/lens/server/usage/collectors.ts +++ b/x-pack/plugins/lens/server/usage/collectors.ts @@ -13,6 +13,19 @@ import { TaskManagerStartContract } from '../../../task_manager/server'; import { LensUsage, LensTelemetryState } from './types'; import { lensUsageSchema } from './schema'; +const emptyUsageCollection = { + saved_overall: {}, + saved_30_days: {}, + saved_90_days: {}, + saved_overall_total: 0, + saved_30_days_total: 0, + saved_90_days_total: 0, + events_30_days: {}, + events_90_days: {}, + suggestion_events_30_days: {}, + suggestion_events_90_days: {}, +}; + export function registerLensUsageCollector( usageCollection: UsageCollectionSetup, taskManager: Promise @@ -29,6 +42,7 @@ export function registerLensUsageCollector( const suggestions = getDataByDate(state.suggestionsByDate); return { + ...emptyUsageCollection, ...state.saved, events_30_days: events.last30, events_90_days: events.last90, @@ -36,19 +50,7 @@ export function registerLensUsageCollector( suggestion_events_90_days: suggestions.last90, }; } catch (err) { - return { - saved_overall_total: 0, - saved_30_days_total: 0, - saved_90_days_total: 0, - saved_overall: {}, - saved_30_days: {}, - saved_90_days: {}, - - events_30_days: {}, - events_90_days: {}, - suggestion_events_30_days: {}, - suggestion_events_90_days: {}, - }; + return emptyUsageCollection; } }, isReady: async () => { From 8127103a710325344964be93a559e6e042073385 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Mon, 15 Feb 2021 16:16:39 +0100 Subject: [PATCH 07/23] [Lens] Fix partial move on datatable (#90630) * [Lens] Fix partial move on datatable * test * fixing test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../config_panel/layer_panel.test.tsx | 72 +++++++++++++++++++ .../editor_frame/config_panel/layer_panel.tsx | 18 ++--- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index aece9188d7cf4..1f97399fdd292 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -697,5 +697,77 @@ describe('LayerPanel', () => { }) ); }); + + it('should call onDrop and update visualization when replacing between compatible groups', () => { + const mockVis = { + ...mockVisualization, + removeDimension: jest.fn(), + setDimension: jest.fn(() => 'modifiedState'), + }; + mockVis.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'a' }, { columnId: 'b' }], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup', + }, + { + groupLabel: 'B', + groupId: 'b', + accessors: [{ columnId: 'c' }], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup2', + }, + ], + }); + + const draggingOperation = { + layerId: 'first', + columnId: 'a', + groupId: 'a', + id: 'a', + humanData: { label: 'Label' }, + }; + + mockDatasource.onDrop.mockReturnValue({ deleted: 'a' }); + const updateVisualization = jest.fn(); + + const component = mountWithIntl( + + + + ); + act(() => { + component.find(DragDrop).at(3).prop('onDrop')!(draggingOperation, 'replace_compatible'); + }); + expect(mockDatasource.onDrop).toHaveBeenCalledWith( + expect.objectContaining({ + dropType: 'replace_compatible', + droppedItem: draggingOperation, + }) + ); + expect(mockVis.setDimension).toHaveBeenCalledWith({ + columnId: 'c', + groupId: 'b', + layerId: 'first', + prevState: 'state', + }); + expect(mockVis.removeDimension).toHaveBeenCalledWith( + expect.objectContaining({ + columnId: 'a', + layerId: 'first', + prevState: 'modifiedState', + }) + ); + expect(updateVisualization).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 5ba73e98b42c1..5d84f826ab988 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -161,14 +161,12 @@ export function LayerPanel( dropType, }); if (dropResult) { - updateVisualization( - setDimension({ - columnId, - groupId, - layerId: targetLayerId, - prevState: props.visualizationState, - }) - ); + const newVisState = setDimension({ + columnId, + groupId, + layerId: targetLayerId, + prevState: props.visualizationState, + }); if (typeof dropResult === 'object') { // When a column is moved, we delete the reference to the old @@ -176,9 +174,11 @@ export function LayerPanel( removeDimension({ columnId: dropResult.deleted, layerId: targetLayerId, - prevState: props.visualizationState, + prevState: newVisState, }) ); + } else { + updateVisualization(newVisState); } } }; From bbc24b375ea0020326a1b983ca3bcfa48c30d6f4 Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Mon, 15 Feb 2021 16:55:36 +0100 Subject: [PATCH 08/23] Debug flaky test (#90762) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../api_integration/tagging_api/apis/delete.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/delete.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/delete.ts index ed4bc8f4f8c7b..c0cf77c7d2b8c 100644 --- a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/delete.ts +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/delete.ts @@ -13,8 +13,7 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); - // FLAKY: https://github.com/elastic/kibana/issues/90552 - describe.skip('DELETE /api/saved_objects_tagging/tags/{id}', () => { + describe('DELETE /api/saved_objects_tagging/tags/{id}', () => { beforeEach(async () => { await esArchiver.load('delete_with_references'); }); @@ -24,9 +23,15 @@ export default function ({ getService }: FtrProviderContext) { }); it('should delete the tag', async () => { - await supertest.get(`/api/saved_objects_tagging/tags/tag-1`).expect(200); - - await supertest.delete(`/api/saved_objects_tagging/tags/tag-1`).expect(200); + const getRes = await supertest.get(`/api/saved_objects_tagging/tags/tag-1`); + // eslint-disable-next-line no-console + console.trace('%O', getRes.body); + expect(getRes.status).to.eql(200); + + const delRes = await supertest.delete(`/api/saved_objects_tagging/tags/tag-1`); + // eslint-disable-next-line no-console + console.trace('%O', delRes.body); + expect(delRes.status).to.eql(200); await supertest.get(`/api/saved_objects_tagging/tags/tag-1`).expect(404); }); From 4d4856c9ceab0241884dd8ed7e574c5e6a33a445 Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Mon, 15 Feb 2021 16:47:28 +0000 Subject: [PATCH 09/23] [Discover] Fix icon for conflicting fields (#90641) * [Discover] Fix icon for conflicting fields * Fix tooltip text a bit * Fix failing snapshot * Minor code fix Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../sidebar/discover_field.test.tsx | 16 ++++++++ .../components/sidebar/discover_field.tsx | 37 ++++++++++++++++++- .../__snapshots__/field_icon.test.tsx.snap | 3 +- .../public/field_icon/field_icon.tsx | 2 +- 4 files changed, 55 insertions(+), 3 deletions(-) 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 c16dab618b284..54a2de14e2e26 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 @@ -114,4 +114,20 @@ describe('discover sidebar field', function () { findTestSubject(comp, 'field-_source-showDetails').simulate('click'); expect(props.getDetails).not.toHaveBeenCalled(); }); + it('displays warning for conflicting fields', function () { + const field = new IndexPatternField({ + name: 'troubled_field', + type: 'conflict', + esTypes: ['integer', 'text'], + searchable: true, + aggregatable: true, + readFromDocValues: false, + }); + const { comp } = getComponent({ + selected: true, + field, + }); + const dscField = findTestSubject(comp, 'field-troubled_field-showDetails'); + expect(dscField.find('.kbnFieldButton__infoIcon').length).toEqual(1); + }); }); diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx index fa857bbfcbbe4..8cd63f09e0d2c 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx @@ -9,7 +9,14 @@ import './discover_field.scss'; import React, { useState } from 'react'; -import { EuiPopover, EuiPopoverTitle, EuiButtonIcon, EuiToolTip, EuiTitle } from '@elastic/eui'; +import { + EuiPopover, + EuiPopoverTitle, + EuiButtonIcon, + EuiToolTip, + EuiTitle, + EuiIcon, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { UiCounterMetricType } from '@kbn/analytics'; import classNames from 'classnames'; @@ -217,6 +224,33 @@ export function DiscoverField({ ); } + const getFieldInfoIcon = () => { + if (field.type !== 'conflict') { + return null; + } + return ( + + + + ); + }; + + const fieldInfoIcon = getFieldInfoIcon(); + const shouldRenderMultiFields = !!multiFields; const renderMultiFields = () => { if (!multiFields) { @@ -263,6 +297,7 @@ export function DiscoverField({ fieldIcon={dscFieldIcon} fieldAction={actionButton} fieldName={fieldName} + fieldInfoIcon={fieldInfoIcon} /> } isOpen={infoIsOpen} diff --git a/src/plugins/kibana_react/public/field_icon/__snapshots__/field_icon.test.tsx.snap b/src/plugins/kibana_react/public/field_icon/__snapshots__/field_icon.test.tsx.snap index cde6a625ac8e8..07697fc036d1f 100644 --- a/src/plugins/kibana_react/public/field_icon/__snapshots__/field_icon.test.tsx.snap +++ b/src/plugins/kibana_react/public/field_icon/__snapshots__/field_icon.test.tsx.snap @@ -47,8 +47,9 @@ exports[`FieldIcon renders known field types conflict is rendered 1`] = ` diff --git a/src/plugins/kibana_react/public/field_icon/field_icon.tsx b/src/plugins/kibana_react/public/field_icon/field_icon.tsx index b466568037d94..9e3178c6b5b05 100644 --- a/src/plugins/kibana_react/public/field_icon/field_icon.tsx +++ b/src/plugins/kibana_react/public/field_icon/field_icon.tsx @@ -34,7 +34,7 @@ const defaultIcon = { iconType: 'questionInCircle', color: 'gray' }; export const typeToEuiIconMap: Partial> = { boolean: { iconType: 'tokenBoolean' }, // icon for an index pattern mapping conflict in discover - conflict: { iconType: 'alert', color: 'euiVisColor9' }, + conflict: { iconType: 'alert', color: 'euiColorVis9', shape: 'square' }, date: { iconType: 'tokenDate' }, geo_point: { iconType: 'tokenGeo' }, geo_shape: { iconType: 'tokenGeo' }, From 91d3a6ac94d841e8f959bdf085948b1747a3579d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Mon, 15 Feb 2021 18:00:47 +0100 Subject: [PATCH 10/23] [ILM] Copy update (#91100) * Added copy adjustments, fixed wrong capitalization of titles and swap primary buttons back to correct order * Apply suggestions from code review Co-authored-by: James Rodewig <40268737+jrodewig@users.noreply.github.com> Co-authored-by: debadair * Fixed eslint issues and added labels for buttons Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: James Rodewig <40268737+jrodewig@users.noreply.github.com> Co-authored-by: debadair --- .../components/phase_footer/phase_footer.tsx | 39 +++++++-------- .../sections/edit_policy/edit_policy.tsx | 50 +++++++++---------- .../sections/edit_policy/i18n_texts.ts | 11 ++-- 3 files changed, 47 insertions(+), 53 deletions(-) diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/phase_footer.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/phase_footer.tsx index 82f0725bfe7d0..22422ceab8a04 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/phase_footer.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/phase_footer.tsx @@ -17,6 +17,20 @@ import { usePhaseTimings } from '../../form'; import { InfinityIconSvg } from '../infinity_icon/infinity_icon.svg'; +const deleteDataLabel = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.phaseTiming.beforeDeleteDescription', + { + defaultMessage: 'Delete data after this phase', + } +); + +const keepDataLabel = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.phaseTiming.foreverTimingDescription', + { + defaultMessage: 'Keep data in this phase forever', + } +); + interface Props { phase: PhasesExceptDelete; } @@ -31,15 +45,6 @@ export const PhaseFooter: FunctionComponent = ({ phase }) => { if (!phaseConfiguration.isFinalDataPhase) { return null; } - - const phaseDescription = isDeletePhaseEnabled - ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseTiming.beforeDeleteDescription', { - defaultMessage: 'Data will be deleted after this phase', - }) - : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseTiming.foreverTimingDescription', { - defaultMessage: 'Data will remain in this phase forever', - }); - const selectedButton = isDeletePhaseEnabled ? 'ilmEnableDeletePhaseButton' : 'ilmDisableDeletePhaseButton'; @@ -47,22 +52,12 @@ export const PhaseFooter: FunctionComponent = ({ phase }) => { const buttons = [ { id: `ilmDisableDeletePhaseButton`, - label: i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.deletePhase.disablePhaseButtonLabel', - { - defaultMessage: 'Keep data in this phase forever', - } - ), + label: keepDataLabel, iconType: InfinityIconSvg, }, { id: `ilmEnableDeletePhaseButton`, - label: i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.deletePhase.enablePhaseButtonLabel', - { - defaultMessage: 'Delete data after this phase', - } - ), + label: deleteDataLabel, iconType: 'trash', 'data-test-subj': 'enableDeletePhaseButton', }, @@ -72,7 +67,7 @@ export const PhaseFooter: FunctionComponent = ({ phase }) => { - {phaseDescription} + {isDeletePhaseEnabled ? deleteDataLabel : keepDataLabel} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index 637fbd893aaa0..befb8faf51aa1 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -258,33 +258,8 @@ export const EditPolicy: React.FunctionComponent = ({ history }) => { - - - {isShowingPolicyJsonFlyout ? ( - - ) : ( - - )} - - - - - - - - - = ({ history }) => { )} + + + + + + + + + + {isShowingPolicyJsonFlyout ? ( + + ) : ( + + )} + + {isShowingPolicyJsonFlyout ? ( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts index 3923cf93cd0d3..1d75fb5031216 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts @@ -189,27 +189,26 @@ export const i18nTexts = { defaultMessage: 'Cold phase', }), delete: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.deletePhase.deletePhaseTitle', { - defaultMessage: 'Delete Data', + defaultMessage: 'Delete phase', }), }, descriptions: { hot: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.hotPhase.hotPhaseDescription', { defaultMessage: - 'You actively store and query data in the hot phase. All policies have a hot phase.', + 'Store your most-recent, most frequently-searched data in the hot tier, which provides the best indexing and search performance at the highest cost.', }), warm: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.warmPhase.warmPhaseDescription', { defaultMessage: - 'You are still querying your index, but it is read-only. You can allocate shards to less performant hardware. For faster searches, you can reduce the number of shards and force merge segments.', + 'Move data to the warm tier, which is optimized for search performance over indexing performance. Data is infrequently added or updated in the warm phase.', }), cold: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseDescription', { defaultMessage: - 'You are querying your index less frequently, so you can allocate shards on significantly less performant hardware. Because your queries are slower, you can reduce the number of replicas.', + 'Move data to the cold tier, which is optimized for cost savings over search performance. Data is normally read-only in the cold phase.', }), delete: i18n.translate( 'xpack.indexLifecycleMgmt.editPolicy.deletePhase.deletePhaseDescription', { - defaultMessage: - 'You no longer need your index. You can define when it is safe to delete it.', + defaultMessage: 'Delete data you no longer need.', } ), }, From 643794eb9f2b1e14fb5c4ef4f357743c8c8bea50 Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Mon, 15 Feb 2021 19:06:40 +0200 Subject: [PATCH 11/23] fix ui counters flaky test (#91372) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apis/ui_counters/ui_counters.ts | 71 ++++++++++++------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/test/api_integration/apis/ui_counters/ui_counters.ts b/test/api_integration/apis/ui_counters/ui_counters.ts index c287e73e3ace9..2d55e224f31ce 100644 --- a/test/api_integration/apis/ui_counters/ui_counters.ts +++ b/test/api_integration/apis/ui_counters/ui_counters.ts @@ -10,11 +10,12 @@ import expect from '@kbn/expect'; import { ReportManager, METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; import moment from 'moment'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { SavedObject } from '../../../../src/core/server'; +import { UICounterSavedObjectAttributes } from '../../../../src/plugins/kibana_usage_collection/server/collectors/ui_counters/ui_counter_saved_object_type'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const es = getService('es'); const createUiCounterEvent = (eventName: string, type: UiCounterMetricType, count = 1) => ({ eventName, @@ -23,12 +24,21 @@ export default function ({ getService }: FtrProviderContext) { count, }); - // FLAKY: https://github.com/elastic/kibana/issues/85086 - describe.skip('UI Counters API', () => { - before(async () => { - await esArchiver.emptyKibanaIndex(); - }); + const getCounterById = ( + savedObjects: Array>, + targetId: string + ): SavedObject => { + const savedObject = savedObjects.find(({ id }: { id: string }) => id === targetId); + if (!savedObject) { + throw new Error(`Unable to find savedObject id ${targetId}`); + } + + return savedObject; + }; + + describe('UI Counters API', () => { const dayDate = moment().format('DDMMYYYY'); + before(async () => await esArchiver.emptyKibanaIndex()); it('stores ui counter events in savedObjects', async () => { const reportManager = new ReportManager(); @@ -44,12 +54,18 @@ export default function ({ getService }: FtrProviderContext) { .send({ report }) .expect(200); - const { body: response } = await es.search({ index: '.kibana', q: 'type:ui-counter' }); + const { + body: { saved_objects: savedObjects }, + } = await supertest + .get('/api/saved_objects/_find?type=ui-counter') + .set('kbn-xsrf', 'kibana') + .expect(200); - const ids = response.hits.hits.map(({ _id }: { _id: string }) => _id); - expect(ids.includes(`ui-counter:myApp:${dayDate}:${METRIC_TYPE.COUNT}:my_event`)).to.eql( - true + const countTypeEvent = getCounterById( + savedObjects, + `myApp:${dayDate}:${METRIC_TYPE.COUNT}:my_event` ); + expect(countTypeEvent.attributes.count).to.eql(1); }); it('supports multiple events', async () => { @@ -70,28 +86,29 @@ export default function ({ getService }: FtrProviderContext) { .expect(200); const { - body: { - hits: { hits }, - }, - } = await es.search({ index: '.kibana', q: 'type:ui-counter' }); - - const countTypeEvent = hits.find( - (hit: { _id: string }) => - hit._id === `ui-counter:myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}` + body: { saved_objects: savedObjects }, + } = await supertest + .get('/api/saved_objects/_find?type=ui-counter&fields=count') + .set('kbn-xsrf', 'kibana') + .expect(200); + + const countTypeEvent = getCounterById( + savedObjects, + `myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}` ); - expect(countTypeEvent._source['ui-counter'].count).to.eql(1); + expect(countTypeEvent.attributes.count).to.eql(1); - const clickTypeEvent = hits.find( - (hit: { _id: string }) => - hit._id === `ui-counter:myApp:${dayDate}:${METRIC_TYPE.CLICK}:${uniqueEventName}` + const clickTypeEvent = getCounterById( + savedObjects, + `myApp:${dayDate}:${METRIC_TYPE.CLICK}:${uniqueEventName}` ); - expect(clickTypeEvent._source['ui-counter'].count).to.eql(2); + expect(clickTypeEvent.attributes.count).to.eql(2); - const secondEvent = hits.find( - (hit: { _id: string }) => - hit._id === `ui-counter:myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}_2` + const secondEvent = getCounterById( + savedObjects, + `myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}_2` ); - expect(secondEvent._source['ui-counter'].count).to.eql(1); + expect(secondEvent.attributes.count).to.eql(1); }); }); } From 4d0144001214d15fe4ffc24f63a9a197e87a53b2 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Mon, 15 Feb 2021 20:17:21 +0300 Subject: [PATCH 12/23] [Discover] Could not expand cell content in DiscoverGrid (#91289) Closes: #90796 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/discover_grid/constants.ts | 1 - .../discover_grid_columns.test.tsx | 14 ++++---- .../discover_grid/discover_grid_schema.tsx | 33 ++++--------------- .../get_render_cell_value.test.tsx | 8 ++++- .../discover_grid/get_render_cell_value.tsx | 2 +- 5 files changed, 22 insertions(+), 36 deletions(-) diff --git a/src/plugins/discover/public/application/components/discover_grid/constants.ts b/src/plugins/discover/public/application/components/discover_grid/constants.ts index 795cdbc9c48f7..03e5740793396 100644 --- a/src/plugins/discover/public/application/components/discover_grid/constants.ts +++ b/src/plugins/discover/public/application/components/discover_grid/constants.ts @@ -9,7 +9,6 @@ // data types export const kibanaJSON = 'kibana-json'; export const geoPoint = 'geo-point'; -export const unknownType = 'unknown'; export const gridStyle = { border: 'all', fontSize: 's', diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx index 2857001b2443e..2317b8841a37a 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx @@ -28,7 +28,7 @@ describe('Discover grid columns ', function () { "display": undefined, "id": "extension", "isSortable": undefined, - "schema": "unknown", + "schema": "kibana-json", }, Object { "actions": Object { @@ -43,7 +43,7 @@ describe('Discover grid columns ', function () { "display": undefined, "id": "message", "isSortable": undefined, - "schema": "unknown", + "schema": "kibana-json", }, ] `); @@ -68,7 +68,7 @@ describe('Discover grid columns ', function () { "display": undefined, "id": "extension", "isSortable": undefined, - "schema": "unknown", + "schema": "kibana-json", }, Object { "actions": Object { @@ -80,7 +80,7 @@ describe('Discover grid columns ', function () { "display": undefined, "id": "message", "isSortable": undefined, - "schema": "unknown", + "schema": "kibana-json", }, ] `); @@ -106,7 +106,7 @@ describe('Discover grid columns ', function () { "id": "timestamp", "initialWidth": 180, "isSortable": undefined, - "schema": "unknown", + "schema": "kibana-json", }, Object { "actions": Object { @@ -121,7 +121,7 @@ describe('Discover grid columns ', function () { "display": undefined, "id": "extension", "isSortable": undefined, - "schema": "unknown", + "schema": "kibana-json", }, Object { "actions": Object { @@ -136,7 +136,7 @@ describe('Discover grid columns ', function () { "display": undefined, "id": "message", "isSortable": undefined, - "schema": "unknown", + "schema": "kibana-json", }, ] `); diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.tsx index 945cbbcb8a832..83ade88386dbc 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.tsx @@ -6,9 +6,9 @@ * Side Public License, v 1. */ -import React, { ReactNode } from 'react'; -import { EuiCodeBlock } from '@elastic/eui'; -import { geoPoint, kibanaJSON, unknownType } from './constants'; +import React from 'react'; +import { EuiCodeBlock, EuiDataGridPopoverContents } from '@elastic/eui'; +import { geoPoint, kibanaJSON } from './constants'; import { KBN_FIELD_TYPES } from '../../../../../data/common'; export function getSchemaByKbnType(kbnType: string | undefined) { @@ -24,12 +24,10 @@ export function getSchemaByKbnType(kbnType: string | undefined) { return 'string'; case KBN_FIELD_TYPES.DATE: return 'datetime'; - case KBN_FIELD_TYPES._SOURCE: - return kibanaJSON; case KBN_FIELD_TYPES.GEO_POINT: return geoPoint; default: - return unknownType; + return kibanaJSON; } } @@ -45,16 +43,6 @@ export function getSchemaDetectors() { icon: '', color: '', }, - { - type: unknownType, - detector() { - return 0; // this schema is always explicitly defined - }, - sortTextAsc: '', - sortTextDesc: '', - icon: '', - color: '', - }, { type: geoPoint, detector() { @@ -70,19 +58,12 @@ export function getSchemaDetectors() { /** * Returns custom popover content for certain schemas */ -export function getPopoverContents() { +export function getPopoverContents(): EuiDataGridPopoverContents { return { - [geoPoint]: ({ children }: { children: ReactNode }) => { + [geoPoint]: ({ children }) => { return {children}; }, - [unknownType]: ({ children }: { children: ReactNode }) => { - return ( - - {children} - - ); - }, - [kibanaJSON]: ({ children }: { children: ReactNode }) => { + [kibanaJSON]: ({ children }) => { return ( {children} diff --git a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx index 594aaac2168d4..786d7bc74bf6b 100644 --- a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx @@ -78,7 +78,13 @@ describe('Discover grid cell rendering', function () { ); expect(component.html()).toMatchInlineSnapshot(` "{ - "bytes": 100 + "_id": "1", + "_index": "test", + "_type": "test", + "_score": 1, + "_source": { + "bytes": 100 + } }" `); }); diff --git a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx index 6ed19813830c8..45f30a9d26f93 100644 --- a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx @@ -55,7 +55,7 @@ export const getRenderCellValueFn = ( if (field && field.type === '_source') { if (isDetails) { // nicely formatted JSON for the expanded view - return {JSON.stringify(row[columnId], null, 2)}; + return {JSON.stringify(row, null, 2)}; } const formatted = indexPattern.formatHit(row); From 8ed1c3ca3e9b2758b0db6903587075ebfcfb7e84 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 15 Feb 2021 18:24:58 +0100 Subject: [PATCH 13/23] Url template editor (#88577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 set up Storybook for URL template editor * feat: 🎸 add basic syntax highlighting * feat: 🎸 add autocompletion example * feat: 🎸 add Handlebars language * fix: 🐛 first register language * feat: 🎸 add url and handlebars language parsing * feat: 🎸 use simple Handlebars language * refactor: 💡 move to a separate file * feat: 🎸 add Monaco editor to URL drilldown * feat: 🎸 remove editor line numbers * feat: 🎸 allow user to provide Handlebars variables * feat: 🎸 wire in URL drilldown variables into Monaco editor * feat: 🎸 add metadata to event level variables * feat: 🎸 allow to specify Handlebars variable kind * feat: 🎸 add global variables to autocompletion * refactor: 💡 restructure event and context variable code * feat: 🎸 sort variables by scope group * feat: 🎸 add meta information to context variables * docs: ✏️ use correct variable labels * feat: 🎸 fix component demo props * feat: 🎸 improve highlighting of URL parts * feat: 🎸 improve syntax highlighting colors * feat: 🎸 improve highlighting colors * feat: 🎸 add color to url query parameter key * feat: 🎸 improve visual layout url editor * feat: 🎸 highlight URL slashes with light color * feat: 🎸 connect URL editor to state * feat: 🎸 tweak URL parameter colors * feat: 🎸 improve URL schema color * feat: 🎸 insert variables on click in variable dropdown * fix: 🐛 fix unit tests and translation * test: 💍 fix drilldown tests after refactor * feat: 🎸 add dark mode support to URL template editor * test: 💍 fix URL drilldown test after adding dark mode support * fix: 🐛 use text color which can be converted to dark mode * test: 💍 fill in URL template in monaco editor * fix: 🐛 fix translation key * chore: 🤖 update license headers * chore: 🤖 update license headers * feat: 🎸 preview values of global variables * feat: 🎸 preview values of context variables * chore: 🤖 fix url editor Storybook config * fix: 🐛 make translation key unique * feat: 🎸 stop Esc key propagation in URL editor * feat: 🎸 reduce editor height * feat: 🎸 set example URL once URL drilldown is created * feat: 🎸 add word wrapping to URL editor * feat: 🎸 use EUI variable in SCSS * feat: 🎸 add "Example: " prefix to default template * feat: 🎸 do not insert extra brackets * feat: 🎸 make URL param values same color as text * perf: ⚡️ make URL drilldown config component lazy loaded * test: 💍 remove default URL drilldown template * fix: 🐛 disable autocompletion popup while typing * style: 💄 don't use "Example: " prefix in default URL --- docs/user/dashboard/url-drilldown.asciidoc | 6 +- src/dev/storybook/aliases.ts | 1 + .../public/code_editor/editor_theme.ts | 5 +- src/plugins/kibana_react/public/index.ts | 1 + .../url_template_editor/.storybook/main.js | 10 + .../public/url_template_editor/constants.ts | 9 + .../public/url_template_editor/index.ts | 9 + .../public/url_template_editor/language.ts | 198 ++++++++++++ .../public/url_template_editor/styles.scss | 5 + .../url_template_editor.stories.tsx | 45 +++ .../url_template_editor.tsx | 163 ++++++++++ .../public/lib/url_drilldown.test.ts | 6 +- .../public/lib/url_drilldown.tsx | 58 ++-- .../public/lib/url_drilldown_scope.test.ts | 294 ----------------- .../public/lib/url_drilldown_scope.ts | 266 ---------------- .../lib/variables/context_variables.test.ts | 216 +++++++++++++ .../public/lib/variables/context_variables.ts | 249 +++++++++++++++ .../lib/variables/event_variables.test.ts | 109 +++++++ .../public/lib/variables/event_variables.ts | 298 ++++++++++++++++++ .../public/lib/variables/global_variables.ts | 39 +++ .../public/lib/variables/i18n.ts | 16 + .../public/lib/variables/util.ts | 25 ++ .../drilldowns/url_drilldown/public/plugin.ts | 1 + .../plugins/ui_actions_enhanced/kibana.json | 4 +- .../url_drilldown/components/index.ts | 2 +- .../url_drilldown_collect_config/i18n.ts | 21 -- .../url_drilldown_collect_config/index.ts | 8 + .../url_drilldown_collect_config/lazy.tsx | 25 ++ .../test_samples/demo.tsx | 9 +- .../url_drilldown_collect_config.tsx | 129 ++------ .../components/variable_popover/i18n.ts | 29 ++ .../components/variable_popover/index.tsx | 90 ++++++ .../services/dashboard/drilldowns_manage.ts | 20 +- 33 files changed, 1646 insertions(+), 720 deletions(-) create mode 100644 src/plugins/kibana_react/public/url_template_editor/.storybook/main.js create mode 100644 src/plugins/kibana_react/public/url_template_editor/constants.ts create mode 100644 src/plugins/kibana_react/public/url_template_editor/index.ts create mode 100644 src/plugins/kibana_react/public/url_template_editor/language.ts create mode 100644 src/plugins/kibana_react/public/url_template_editor/styles.scss create mode 100644 src/plugins/kibana_react/public/url_template_editor/url_template_editor.stories.tsx create mode 100644 src/plugins/kibana_react/public/url_template_editor/url_template_editor.tsx delete mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.test.ts delete mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts create mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.test.ts create mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.ts create mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.test.ts create mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.ts create mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/global_variables.ts create mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/i18n.ts create mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/util.ts create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.ts create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/lazy.tsx create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/variable_popover/i18n.ts create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/variable_popover/index.tsx diff --git a/docs/user/dashboard/url-drilldown.asciidoc b/docs/user/dashboard/url-drilldown.asciidoc index b292c1ae5e03f..ad3dd17fcfa11 100644 --- a/docs/user/dashboard/url-drilldown.asciidoc +++ b/docs/user/dashboard/url-drilldown.asciidoc @@ -190,7 +190,7 @@ internal {kib} navigations with carrying over current filters. | Current query string. | -| context.panel.query.lang +| context.panel.query.language | Current query language. | @@ -200,8 +200,8 @@ context.panel.timeRange.to Tip: Use in combination with <> helper to format date. | -| context.panel.timeRange.indexPatternId + -context.panel.timeRange.indexPatternIds +| context.panel.indexPatternId + +context.panel.indexPatternIds |Index pattern ids used by a panel. | diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 910f2bad15dde..c72c81f489fb9 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -10,6 +10,7 @@ export const storybookAliases = { apm: 'x-pack/plugins/apm/.storybook', canvas: 'x-pack/plugins/canvas/storybook', codeeditor: 'src/plugins/kibana_react/public/code_editor/.storybook', + url_template_editor: 'src/plugins/kibana_react/public/url_template_editor/.storybook', dashboard: 'src/plugins/dashboard/.storybook', dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/.storybook', data_enhanced: 'x-pack/plugins/data_enhanced/.storybook', diff --git a/src/plugins/kibana_react/public/code_editor/editor_theme.ts b/src/plugins/kibana_react/public/code_editor/editor_theme.ts index e3ea509c4349a..b5d4627a5d89a 100644 --- a/src/plugins/kibana_react/public/code_editor/editor_theme.ts +++ b/src/plugins/kibana_react/public/code_editor/editor_theme.ts @@ -41,7 +41,7 @@ export function createTheme( { token: 'annotation', foreground: euiTheme.euiColorMediumShade }, { token: 'type', foreground: euiTheme.euiColorVis0 }, - { token: 'delimiter', foreground: euiTheme.euiColorDarkestShade }, + { token: 'delimiter', foreground: euiTheme.euiTextSubduedColor }, { token: 'delimiter.html', foreground: euiTheme.euiColorDarkShade }, { token: 'delimiter.xml', foreground: euiTheme.euiColorPrimary }, @@ -81,6 +81,9 @@ export function createTheme( { token: 'operator.sql', foreground: euiTheme.euiColorMediumShade }, { token: 'operator.swift', foreground: euiTheme.euiColorMediumShade }, { token: 'predefined.sql', foreground: euiTheme.euiColorMediumShade }, + + { token: 'text', foreground: euiTheme.euiTitleColor }, + { token: 'label', foreground: euiTheme.euiColorVis9 }, ], colors: { 'editor.foreground': euiTheme.euiColorDarkestShade, diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index c453a2cbe11f4..f2c2c263da5cd 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -7,6 +7,7 @@ */ export * from './code_editor'; +export * from './url_template_editor'; export * from './exit_full_screen_button'; export * from './context'; export * from './overview_page'; diff --git a/src/plugins/kibana_react/public/url_template_editor/.storybook/main.js b/src/plugins/kibana_react/public/url_template_editor/.storybook/main.js new file mode 100644 index 0000000000000..742239e638b8a --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/.storybook/main.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// eslint-disable-next-line import/no-commonjs +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/src/plugins/kibana_react/public/url_template_editor/constants.ts b/src/plugins/kibana_react/public/url_template_editor/constants.ts new file mode 100644 index 0000000000000..6c1a1dbce5d67 --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/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 LANG = 'handlebars_url'; diff --git a/src/plugins/kibana_react/public/url_template_editor/index.ts b/src/plugins/kibana_react/public/url_template_editor/index.ts new file mode 100644 index 0000000000000..0b0ef85ad427b --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './url_template_editor'; diff --git a/src/plugins/kibana_react/public/url_template_editor/language.ts b/src/plugins/kibana_react/public/url_template_editor/language.ts new file mode 100644 index 0000000000000..278a7130ad1fa --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/language.ts @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * This file is adapted from: https://github.com/microsoft/monaco-languages/blob/master/src/handlebars/handlebars.ts + * License: https://github.com/microsoft/monaco-languages/blob/master/LICENSE.md + */ + +import { monaco } from '@kbn/monaco'; + +export const conf: monaco.languages.LanguageConfiguration = { + wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\$\^\&\*\(\)\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\s]+)/g, + + comments: { + blockComment: ['{{!--', '--}}'], + }, + + brackets: [ + ['<', '>'], + ['{{', '}}'], + ['{', '}'], + ['(', ')'], + ], + + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], + + surroundingPairs: [ + { open: '<', close: '>' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], +}; + +export const language: monaco.languages.IMonarchLanguage = { + // Set defaultToken to invalid to see what you do not tokenize yet. + defaultToken: 'invalid', + tokenPostfix: '', + brackets: [ + { + token: 'constant.delimiter.double', + open: '{{', + close: '}}', + }, + { + token: 'constant.delimiter.triple', + open: '{{{', + close: '}}}', + }, + ], + + tokenizer: { + root: [ + { include: '@maybeHandlebars' }, + { include: '@whitespace' }, + { include: '@urlScheme' }, + { include: '@urlAuthority' }, + { include: '@urlSlash' }, + { include: '@urlParamKey' }, + { include: '@urlParamValue' }, + { include: '@text' }, + ], + + maybeHandlebars: [ + [ + /\{\{/, + { + token: '@rematch', + switchTo: '@handlebars.root', + }, + ], + ], + + whitespace: [[/[ \t\r\n]+/, '']], + + text: [ + [ + /[^<{\?\&\/]+/, + { + token: 'text', + next: '@popall', + }, + ], + ], + + rematchAsRoot: [ + [ + /.+/, + { + token: '@rematch', + switchTo: '@root', + }, + ], + ], + + urlScheme: [ + [ + /([a-zA-Z0-9\+\.\-]{1,10})(:)/, + [ + { + token: 'text.keyword.scheme.url', + }, + { + token: 'delimiter', + }, + ], + ], + ], + + urlAuthority: [ + [ + /(\/\/)([a-zA-Z0-9\.\-_]+)/, + [ + { + token: 'delimiter', + }, + { + token: 'metatag.keyword.authority.url', + }, + ], + ], + ], + + urlSlash: [ + [ + /\/+/, + { + token: 'delimiter', + }, + ], + ], + + urlParamKey: [ + [ + /([\?\&\#])([a-zA-Z0-9_\-]+)/, + [ + { + token: 'delimiter.key.query.url', + }, + { + token: 'label.label.key.query.url', + }, + ], + ], + ], + + urlParamValue: [ + [ + /(\=)([^\?\&\{}]+)/, + [ + { + token: 'text.separator.value.query.url', + }, + { + token: 'text.value.query.url', + }, + ], + ], + ], + + handlebars: [ + [ + /\{\{\{?/, + { + token: '@brackets', + bracket: '@open', + }, + ], + [ + /\}\}\}?/, + { + token: '@brackets', + bracket: '@close', + switchTo: '@$S2.$S3', + }, + ], + { include: 'handlebarsExpression' }, + ], + + handlebarsExpression: [ + [/"[^"]*"/, 'string.handlebars'], + [/[#/][^\s}]+/, 'keyword.helper.handlebars'], + [/else\b/, 'keyword.helper.handlebars'], + [/[\s]+/], + [/[^}]/, 'variable.parameter.handlebars'], + ], + }, +} as monaco.languages.IMonarchLanguage; diff --git a/src/plugins/kibana_react/public/url_template_editor/styles.scss b/src/plugins/kibana_react/public/url_template_editor/styles.scss new file mode 100644 index 0000000000000..99379b21454ec --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/styles.scss @@ -0,0 +1,5 @@ +.urlTemplateEditor__container { + .monaco-editor .lines-content.monaco-editor-background { + margin: $euiSizeS; + } +} diff --git a/src/plugins/kibana_react/public/url_template_editor/url_template_editor.stories.tsx b/src/plugins/kibana_react/public/url_template_editor/url_template_editor.stories.tsx new file mode 100644 index 0000000000000..67f34e6eeb14f --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/url_template_editor.stories.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 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 { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import React from 'react'; +import { UrlTemplateEditor } from './url_template_editor'; +import { CodeEditor } from '../code_editor/code_editor'; + +storiesOf('UrlTemplateEditor', module) + .add('default', () => ( + + )) + .add('with variables', () => ( + + )); diff --git a/src/plugins/kibana_react/public/url_template_editor/url_template_editor.tsx b/src/plugins/kibana_react/public/url_template_editor/url_template_editor.tsx new file mode 100644 index 0000000000000..f830b4012976a --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/url_template_editor.tsx @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as React from 'react'; +import { monaco } from '@kbn/monaco'; +import { Props as CodeEditorProps } from '../code_editor/code_editor'; +import { CodeEditor } from '../code_editor'; +import { LANG } from './constants'; +import { language, conf } from './language'; + +import './styles.scss'; + +monaco.languages.register({ + id: LANG, +}); +monaco.languages.setMonarchTokensProvider(LANG, language); +monaco.languages.setLanguageConfiguration(LANG, conf); + +export interface UrlTemplateEditorVariable { + label: string; + title?: string; + documentation?: string; + kind?: monaco.languages.CompletionItemKind; + sortText?: string; +} +export interface UrlTemplateEditorProps { + value: string; + height?: CodeEditorProps['height']; + variables?: UrlTemplateEditorVariable[]; + onChange: CodeEditorProps['onChange']; + onEditor?: (editor: monaco.editor.IStandaloneCodeEditor) => void; + Editor?: React.ComponentType; +} + +export const UrlTemplateEditor: React.FC = ({ + height = 105, + value, + variables, + onChange, + onEditor, + Editor = CodeEditor, +}) => { + const refEditor = React.useRef(null); + const handleEditor = React.useCallback((editor: monaco.editor.IStandaloneCodeEditor) => { + refEditor.current = editor; + + if (onEditor) { + onEditor(editor); + } + }, []); + + const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { + const editor = refEditor.current; + if (!editor) return; + + if (event.key === 'Escape') { + if (editor.hasWidgetFocus()) { + // Don't propagate Escape click if Monaco editor is focused, this allows + // user to close the autocomplete widget with Escape button without + // closing the EUI flyout. + event.stopPropagation(); + editor.trigger('editor', 'hideSuggestWidget', []); + } + } + }, []); + + React.useEffect(() => { + if (!variables) { + return; + } + + const { dispose } = monaco.languages.registerCompletionItemProvider(LANG, { + triggerCharacters: ['{', '/', '?', '&', '='], + provideCompletionItems(model, position, context, token) { + const { lineNumber } = position; + const line = model.getLineContent(lineNumber); + const wordUntil = model.getWordUntilPosition(position); + const word = model.getWordAtPosition(position) || wordUntil; + const { startColumn, endColumn } = word; + const range = { + startLineNumber: lineNumber, + endLineNumber: lineNumber, + startColumn, + endColumn, + }; + + const leadingMustacheCount = + 0 + + (line[range.startColumn - 2] === '{' ? 1 : 0) + + (line[range.startColumn - 3] === '{' ? 1 : 0); + + const trailingMustacheCount = + 0 + + (line[range.endColumn - 1] === '}' ? 1 : 0) + + (line[range.endColumn + 0] === '}' ? 1 : 0); + + return { + suggestions: variables.map( + ({ + label, + title = '', + documentation = '', + kind = monaco.languages.CompletionItemKind.Variable, + sortText, + }) => ({ + kind, + label, + insertText: + (leadingMustacheCount === 2 ? '' : leadingMustacheCount === 1 ? '{' : '{{') + + label + + (trailingMustacheCount === 2 ? '' : trailingMustacheCount === 1 ? '}' : '}}'), + detail: title, + documentation, + range, + sortText, + }) + ), + }; + }, + }); + + return () => { + dispose(); + }; + }, [variables]); + + return ( +
+ +
+ ); +}; diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts index 580b4120ae46d..07c6addda2767 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts @@ -6,6 +6,7 @@ */ import { IExternalUrl } from 'src/core/public'; +import { uiSettingsServiceMock } from 'src/core/public/mocks'; import { UrlDrilldown, ActionContext, Config } from './url_drilldown'; import { IEmbeddable, VALUE_CLICK_TRIGGER } from '../../../../../../src/plugins/embeddable/public'; import { DatatableColumnType } from '../../../../../../src/plugins/expressions/common'; @@ -74,6 +75,7 @@ const createDrilldown = (isExternalUrlValid: boolean = true) => { getSyntaxHelpDocsLink: () => 'http://localhost:5601/docs', getVariablesHelpDocsLink: () => 'http://localhost:5601/docs', navigateToUrl: mockNavigateToUrl, + uiSettings: uiSettingsServiceMock.createSetupContract(), }); return drilldown; }; @@ -408,7 +410,7 @@ describe('UrlDrilldown', () => { ]; for (const expectedItem of expectedList) { - expect(list.includes(expectedItem)).toBe(true); + expect(!!list.find(({ label }) => label === expectedItem)).toBe(true); } }); @@ -438,7 +440,7 @@ describe('UrlDrilldown', () => { ]; for (const expectedItem of expectedList) { - expect(list.includes(expectedItem)).toBe(true); + expect(!!list.find(({ label }) => label === expectedItem)).toBe(true); } }); }); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx index a8c34a871310b..bf2ed8c2a45d1 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { getFlattenedObject } from '@kbn/std'; -import { IExternalUrl } from 'src/core/public'; -import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; +import { IExternalUrl, IUiSettingsClient } from 'src/core/public'; import { ChartActionContext, CONTEXT_MENU_TRIGGER, @@ -20,6 +18,11 @@ import { import { ROW_CLICK_TRIGGER } from '../../../../../../src/plugins/ui_actions/public'; import { Query, Filter, TimeRange } from '../../../../../../src/plugins/data/public'; import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../../src/plugins/kibana_utils/public'; +import { + reactToUiComponent, + UrlTemplateEditorVariable, + KibanaContextProvider, +} from '../../../../../../src/plugins/kibana_react/public'; import { UiActionsEnhancedDrilldownDefinition as Drilldown, UrlDrilldownGlobalScope, @@ -29,8 +32,10 @@ import { urlDrilldownCompileUrl, UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, } from '../../../../ui_actions_enhanced/public'; -import { getPanelVariables, getEventScope, getEventVariableList } from './url_drilldown_scope'; import { txtUrlDrilldownDisplayName } from './i18n'; +import { getEventVariableList, getEventScopeValues } from './variables/event_variables'; +import { getContextVariableList, getContextScopeValues } from './variables/context_variables'; +import { getGlobalVariableList } from './variables/global_variables'; interface EmbeddableQueryInput extends EmbeddableInput { query?: Query; @@ -47,6 +52,7 @@ interface UrlDrilldownDeps { navigateToUrl: (url: string) => Promise; getSyntaxHelpDocsLink: () => string; getVariablesHelpDocsLink: () => string; + uiSettings: IUiSettingsClient; } export type ActionContext = ChartActionContext; @@ -90,21 +96,30 @@ export class UrlDrilldown implements Drilldown { // eslint-disable-next-line react-hooks/rules-of-hooks const variables = React.useMemo(() => this.getVariableList(context), [context]); + return ( - + + + ); }; public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); public readonly createConfig = () => ({ - url: { template: '' }, + url: { + template: 'https://example.com/?{{event.key}}={{event.value}}', + }, openInNewTab: true, encodeUrl: true, }); @@ -167,21 +182,20 @@ export class UrlDrilldown implements Drilldown { return { + event: getEventScopeValues(context), + context: getContextScopeValues(context), ...this.deps.getGlobalScope(), - context: { - panel: getPanelVariables(context), - }, - event: getEventScope(context), }; }; - public readonly getVariableList = (context: ActionFactoryContext): string[] => { + public readonly getVariableList = ( + context: ActionFactoryContext + ): UrlTemplateEditorVariable[] => { + const globalScopeValues = this.deps.getGlobalScope(); const eventVariables = getEventVariableList(context); - const contextVariables = Object.keys(getFlattenedObject(getPanelVariables(context))).map( - (key) => 'context.panel.' + key - ); - const globalVariables = Object.keys(getFlattenedObject(this.deps.getGlobalScope())); + const contextVariables = getContextVariableList(context); + const globalVariables = getGlobalVariableList(globalScopeValues); - return [...eventVariables.sort(), ...contextVariables.sort(), ...globalVariables.sort()]; + return [...eventVariables, ...contextVariables, ...globalVariables]; }; } diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.test.ts deleted file mode 100644 index 5c639a61ba4c2..0000000000000 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.test.ts +++ /dev/null @@ -1,294 +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 { - getEventScope, - ValueClickTriggerEventScope, - getEventVariableList, - getPanelVariables, -} from './url_drilldown_scope'; -import { - RowClickContext, - ROW_CLICK_TRIGGER, -} from '../../../../../../src/plugins/ui_actions/public'; -import { createPoint, rowClickData, TestEmbeddable } from './test/data'; - -describe('VALUE_CLICK_TRIGGER', () => { - describe('supports `points[]`', () => { - test('getEventScope()', () => { - const mockDataPoints = [ - createPoint({ field: 'field0', value: 'value0' }), - createPoint({ field: 'field1', value: 'value1' }), - createPoint({ field: 'field2', value: 'value2' }), - ]; - - const eventScope = getEventScope({ - data: { data: mockDataPoints }, - }) as ValueClickTriggerEventScope; - - expect(eventScope.key).toBe('field0'); - expect(eventScope.value).toBe('value0'); - expect(eventScope.points).toHaveLength(mockDataPoints.length); - expect(eventScope.points).toMatchInlineSnapshot(` - Array [ - Object { - "key": "field0", - "value": "value0", - }, - Object { - "key": "field1", - "value": "value1", - }, - Object { - "key": "field2", - "value": "value2", - }, - ] - `); - }); - }); - - describe('handles undefined, null or missing values', () => { - test('undefined or missing values are removed from the result scope', () => { - const point = createPoint({ field: undefined } as any); - const eventScope = getEventScope({ - data: { data: [point] }, - }) as ValueClickTriggerEventScope; - - expect('key' in eventScope).toBeFalsy(); - expect('value' in eventScope).toBeFalsy(); - }); - - test('null value stays in the result scope', () => { - const point = createPoint({ field: 'field', value: null }); - const eventScope = getEventScope({ - data: { data: [point] }, - }) as ValueClickTriggerEventScope; - - expect(eventScope.value).toBeNull(); - }); - }); -}); - -describe('ROW_CLICK_TRIGGER', () => { - test('getEventVariableList() returns correct list of runtime variables', () => { - const vars = getEventVariableList({ - triggers: [ROW_CLICK_TRIGGER], - }); - expect(vars).toEqual(['event.rowIndex', 'event.values', 'event.keys', 'event.columnNames']); - }); - - test('getEventScope() returns correct variables for row click trigger', () => { - const context = ({ - embeddable: {}, - data: rowClickData as any, - } as unknown) as RowClickContext; - const res = getEventScope(context); - - expect(res).toEqual({ - rowIndex: 1, - values: ['IT', '2.25', 3, 0, 2], - keys: ['DestCountry', 'FlightTimeHour', '', 'DistanceMiles', 'OriginAirportID'], - columnNames: [ - 'Top values of DestCountry', - 'Top values of FlightTimeHour', - 'Count of records', - 'Average of DistanceMiles', - 'Unique count of OriginAirportID', - ], - }); - }); -}); - -describe('getPanelVariables()', () => { - test('returns only ID for empty embeddable', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - }, - {} - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - }); - }); - - test('returns title as specified in input', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - title: 'title1', - }, - {} - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - title: 'title1', - }); - }); - - test('returns output title if input and output titles are specified', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - title: 'title1', - }, - { - title: 'title2', - } - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - title: 'title2', - }); - }); - - test('returns title from output if title in input is missing', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - }, - { - title: 'title2', - } - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - title: 'title2', - }); - }); - - test('returns saved object ID from output', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - savedObjectId: '5678', - }, - { - savedObjectId: '1234', - } - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - savedObjectId: '1234', - }); - }); - - test('returns saved object ID from input if it is not set on output', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - savedObjectId: '5678', - }, - {} - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - savedObjectId: '5678', - }); - }); - - test('returns query, timeRange and filters from input', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - query: { - language: 'C++', - query: 'std::cout << 123;', - }, - timeRange: { - from: 'FROM', - to: 'TO', - }, - filters: [ - { - meta: { - alias: 'asdf', - disabled: false, - negate: false, - }, - }, - ], - }, - {} - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - query: { - language: 'C++', - query: 'std::cout << 123;', - }, - timeRange: { - from: 'FROM', - to: 'TO', - }, - filters: [ - { - meta: { - alias: 'asdf', - disabled: false, - negate: false, - }, - }, - ], - }); - }); - - test('returns a single index pattern from output', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - }, - { - indexPatterns: [{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }], - } - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - indexPatternId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', - }); - }); - - test('returns multiple index patterns from output', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - }, - { - indexPatterns: [ - { id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }, - { id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' }, - ], - } - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - indexPatternIds: [ - 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', - 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', - ], - }); - }); -}); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts deleted file mode 100644 index 0ee388c321feb..0000000000000 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts +++ /dev/null @@ -1,266 +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. - */ - -/** - * This file contains all the logic for mapping from trigger's context and action factory context to variables for URL drilldown scope, - * Please refer to ./README.md for explanation of different scope sources - */ - -import type { Filter, Query, TimeRange } from '../../../../../../src/plugins/data/public'; -import { - isRangeSelectTriggerContext, - isValueClickTriggerContext, - isRowClickTriggerContext, - isContextMenuTriggerContext, - RangeSelectContext, - SELECT_RANGE_TRIGGER, - ValueClickContext, - VALUE_CLICK_TRIGGER, - EmbeddableInput, - EmbeddableOutput, -} from '../../../../../../src/plugins/embeddable/public'; -import type { - ActionContext, - ActionFactoryContext, - EmbeddableWithQueryInput, -} from './url_drilldown'; -import { - RowClickContext, - ROW_CLICK_TRIGGER, -} from '../../../../../../src/plugins/ui_actions/public'; - -/** - * Part of context scope extracted from an embeddable - * Expose on the scope as: `{{context.panel.id}}`, `{{context.panel.filters.[0]}}` - */ -interface EmbeddableUrlDrilldownContextScope extends EmbeddableInput { - /** - * ID of the embeddable panel. - */ - id: string; - - /** - * Title of the embeddable panel. - */ - title?: string; - - /** - * In case panel supports only 1 index pattern. - */ - indexPatternId?: string; - - /** - * In case panel supports more then 1 index pattern. - */ - indexPatternIds?: string[]; - - query?: Query; - filters?: Filter[]; - timeRange?: TimeRange; - savedObjectId?: string; -} - -export function getPanelVariables(contextScopeInput: unknown): EmbeddableUrlDrilldownContextScope { - function hasEmbeddable(val: unknown): val is { embeddable: EmbeddableWithQueryInput } { - if (val && typeof val === 'object' && 'embeddable' in val) return true; - return false; - } - if (!hasEmbeddable(contextScopeInput)) - throw new Error( - "UrlDrilldown [getContextScope] can't build scope because embeddable object is missing in context" - ); - const embeddable = contextScopeInput.embeddable; - - return getEmbeddableVariables(embeddable); -} - -function hasSavedObjectId(obj: Record): obj is { savedObjectId: string } { - return 'savedObjectId' in obj && typeof obj.savedObjectId === 'string'; -} - -/** - * @todo Same functionality is implemented in x-pack/plugins/discover_enhanced/public/actions/explore_data/shared.ts, - * combine both implementations into a common approach. - */ -function getIndexPatternIds(output: EmbeddableOutput): string[] { - function hasIndexPatterns( - _output: Record - ): _output is { indexPatterns: Array<{ id?: string }> } { - return ( - 'indexPatterns' in _output && - Array.isArray(_output.indexPatterns) && - _output.indexPatterns.length > 0 - ); - } - return hasIndexPatterns(output) - ? (output.indexPatterns.map((ip) => ip.id).filter(Boolean) as string[]) - : []; -} - -export function getEmbeddableVariables( - embeddable: EmbeddableWithQueryInput -): EmbeddableUrlDrilldownContextScope { - const input = embeddable.getInput(); - const output = embeddable.getOutput(); - const indexPatternsIds = getIndexPatternIds(output); - - return deleteUndefinedKeys({ - id: input.id, - title: output.title ?? input.title, - savedObjectId: - output.savedObjectId ?? (hasSavedObjectId(input) ? input.savedObjectId : undefined), - query: input.query, - timeRange: input.timeRange, - filters: input.filters, - indexPatternIds: indexPatternsIds.length > 1 ? indexPatternsIds : undefined, - indexPatternId: indexPatternsIds.length === 1 ? indexPatternsIds[0] : undefined, - }); -} - -/** - * URL drilldown event scope, - * available as {{event.$}} - */ -export type UrlDrilldownEventScope = - | ValueClickTriggerEventScope - | RangeSelectTriggerEventScope - | RowClickTriggerEventScope - | ContextMenuTriggerEventScope; - -export type EventScopeInput = ActionContext; -export interface ValueClickTriggerEventScope { - key?: string; - value: Primitive; - negate: boolean; - points: Array<{ key?: string; value: Primitive }>; -} -export interface RangeSelectTriggerEventScope { - key: string; - from?: string | number; - to?: string | number; -} - -export interface RowClickTriggerEventScope { - rowIndex: number; - values: Primitive[]; - keys: string[]; - columnNames: string[]; -} -export type ContextMenuTriggerEventScope = object; - -export function getEventScope(eventScopeInput: EventScopeInput): UrlDrilldownEventScope { - if (isRangeSelectTriggerContext(eventScopeInput)) { - return getEventScopeFromRangeSelectTriggerContext(eventScopeInput); - } else if (isValueClickTriggerContext(eventScopeInput)) { - return getEventScopeFromValueClickTriggerContext(eventScopeInput); - } else if (isRowClickTriggerContext(eventScopeInput)) { - return getEventScopeFromRowClickTriggerContext(eventScopeInput); - } else if (isContextMenuTriggerContext(eventScopeInput)) { - return {}; - } else { - throw new Error("UrlDrilldown [getEventScope] can't build scope from not supported trigger"); - } -} - -function getEventScopeFromRangeSelectTriggerContext( - eventScopeInput: RangeSelectContext -): RangeSelectTriggerEventScope { - const { table, column: columnIndex, range } = eventScopeInput.data; - const column = table.columns[columnIndex]; - return deleteUndefinedKeys({ - key: toPrimitiveOrUndefined(column?.meta.field) as string, - from: toPrimitiveOrUndefined(range[0]) as string | number | undefined, - to: toPrimitiveOrUndefined(range[range.length - 1]) as string | number | undefined, - }); -} - -function getEventScopeFromValueClickTriggerContext( - eventScopeInput: ValueClickContext -): ValueClickTriggerEventScope { - const negate = eventScopeInput.data.negate ?? false; - const points = eventScopeInput.data.data.map(({ table, value, column: columnIndex }) => { - const column = table.columns[columnIndex]; - return { - value: toPrimitiveOrUndefined(value) as Primitive, - key: column?.meta?.field, - }; - }); - - return deleteUndefinedKeys({ - key: points[0]?.key, - value: points[0]?.value, - negate, - points, - }); -} - -function getEventScopeFromRowClickTriggerContext(ctx: RowClickContext): RowClickTriggerEventScope { - const { data } = ctx; - const embeddable = ctx.embeddable as EmbeddableWithQueryInput; - - const { rowIndex } = data; - const columns = data.columns || data.table.columns.map(({ id }) => id); - const values: Primitive[] = []; - const keys: string[] = []; - const columnNames: string[] = []; - const row = data.table.rows[rowIndex]; - - for (const columnId of columns) { - const column = data.table.columns.find(({ id }) => id === columnId); - if (!column) { - // This should never happe, but in case it does we log data necessary for debugging. - // eslint-disable-next-line no-console - console.error(data, embeddable ? `Embeddable [${embeddable.getTitle()}]` : null); - throw new Error('Could not find a datatable column.'); - } - values.push(row[columnId]); - keys.push(column.meta.field || ''); - columnNames.push(column.name || column.meta.field || ''); - } - - const scope: RowClickTriggerEventScope = { - rowIndex, - values, - keys, - columnNames, - }; - - return scope; -} - -export function getEventVariableList(context: ActionFactoryContext): string[] { - const [trigger] = context.triggers; - - switch (trigger) { - case SELECT_RANGE_TRIGGER: - return ['event.key', 'event.from', 'event.to']; - case VALUE_CLICK_TRIGGER: - return ['event.key', 'event.value', 'event.negate', 'event.points']; - case ROW_CLICK_TRIGGER: - return ['event.rowIndex', 'event.values', 'event.keys', 'event.columnNames']; - } - - return []; -} - -type Primitive = string | number | boolean | null; -function toPrimitiveOrUndefined(v: unknown): Primitive | undefined { - if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string' || v === null) - return v; - if (typeof v === 'object' && v instanceof Date) return v.toISOString(); - if (typeof v === 'undefined') return undefined; - return String(v); -} - -function deleteUndefinedKeys>(obj: T): T { - Object.keys(obj).forEach((key) => { - if (obj[key] === undefined) { - delete obj[key]; - } - }); - return obj; -} diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.test.ts new file mode 100644 index 0000000000000..c3c41ef082ffc --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.test.ts @@ -0,0 +1,216 @@ +/* + * 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 { getContextScopeValues } from './context_variables'; +import { TestEmbeddable } from '../test/data'; + +describe('getContextScopeValues()', () => { + test('returns only ID for empty embeddable', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + }, + {} + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + }, + }); + }); + + test('returns title as specified in input', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + title: 'title1', + }, + {} + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + title: 'title1', + }, + }); + }); + + test('returns output title if input and output titles are specified', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + title: 'title1', + }, + { + title: 'title2', + } + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + title: 'title2', + }, + }); + }); + + test('returns title from output if title in input is missing', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + }, + { + title: 'title2', + } + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + title: 'title2', + }, + }); + }); + + test('returns saved object ID from output', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + savedObjectId: '5678', + }, + { + savedObjectId: '1234', + } + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + savedObjectId: '1234', + }, + }); + }); + + test('returns saved object ID from input if it is not set on output', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + savedObjectId: '5678', + }, + {} + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + savedObjectId: '5678', + }, + }); + }); + + test('returns query, timeRange and filters from input', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + query: { + language: 'C++', + query: 'std::cout << 123;', + }, + timeRange: { + from: 'FROM', + to: 'TO', + }, + filters: [ + { + meta: { + alias: 'asdf', + disabled: false, + negate: false, + }, + }, + ], + }, + {} + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + query: { + language: 'C++', + query: 'std::cout << 123;', + }, + timeRange: { + from: 'FROM', + to: 'TO', + }, + filters: [ + { + meta: { + alias: 'asdf', + disabled: false, + negate: false, + }, + }, + ], + }, + }); + }); + + test('returns a single index pattern from output', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + }, + { + indexPatterns: [{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }], + } + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + indexPatternId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + }, + }); + }); + + test('returns multiple index patterns from output', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + }, + { + indexPatterns: [ + { id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }, + { id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' }, + ], + } + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + indexPatternIds: [ + 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', + ], + }, + }); + }); +}); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.ts new file mode 100644 index 0000000000000..c0ff776c94ca1 --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.ts @@ -0,0 +1,249 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { monaco } from '@kbn/monaco'; +import { getFlattenedObject } from '@kbn/std'; +import { txtValue } from './i18n'; +import type { Filter, Query, TimeRange } from '../../../../../../../src/plugins/data/public'; +import { + EmbeddableInput, + EmbeddableOutput, +} from '../../../../../../../src/plugins/embeddable/public'; +import type { EmbeddableWithQueryInput } from '../url_drilldown'; +import { deleteUndefinedKeys } from './util'; +import type { ActionFactoryContext } from '../url_drilldown'; +import type { UrlTemplateEditorVariable } from '../../../../../../../src/plugins/kibana_react/public'; + +/** + * Part of context scope extracted from an embeddable + * Expose on the scope as: `{{context.panel.id}}`, `{{context.panel.filters.[0]}}` + */ +interface PanelValues extends EmbeddableInput { + /** + * ID of the embeddable panel. + */ + id: string; + + /** + * Title of the embeddable panel. + */ + title?: string; + + /** + * In case panel supports only 1 index pattern. + */ + indexPatternId?: string; + + /** + * In case panel supports more then 1 index pattern. + */ + indexPatternIds?: string[]; + + query?: Query; + filters?: Filter[]; + timeRange?: TimeRange; + savedObjectId?: string; +} + +interface ContextValues { + panel: PanelValues; +} + +function hasSavedObjectId(obj: Record): obj is { savedObjectId: string } { + return 'savedObjectId' in obj && typeof obj.savedObjectId === 'string'; +} + +/** + * @todo Same functionality is implemented in x-pack/plugins/discover_enhanced/public/actions/explore_data/shared.ts, + * combine both implementations into a common approach. + */ +function getIndexPatternIds(output: EmbeddableOutput): string[] { + function hasIndexPatterns( + _output: Record + ): _output is { indexPatterns: Array<{ id?: string }> } { + return ( + 'indexPatterns' in _output && + Array.isArray(_output.indexPatterns) && + _output.indexPatterns.length > 0 + ); + } + return hasIndexPatterns(output) + ? (output.indexPatterns.map((ip) => ip.id).filter(Boolean) as string[]) + : []; +} + +export function getEmbeddableVariables(embeddable: EmbeddableWithQueryInput): PanelValues { + const input = embeddable.getInput(); + const output = embeddable.getOutput(); + const indexPatternsIds = getIndexPatternIds(output); + + return deleteUndefinedKeys({ + id: input.id, + title: output.title ?? input.title, + savedObjectId: + output.savedObjectId ?? (hasSavedObjectId(input) ? input.savedObjectId : undefined), + query: input.query, + timeRange: input.timeRange, + filters: input.filters, + indexPatternIds: indexPatternsIds.length > 1 ? indexPatternsIds : undefined, + indexPatternId: indexPatternsIds.length === 1 ? indexPatternsIds[0] : undefined, + }); +} + +const getContextPanelScopeValues = (contextScopeInput: unknown): PanelValues => { + function hasEmbeddable(val: unknown): val is { embeddable: EmbeddableWithQueryInput } { + if (val && typeof val === 'object' && 'embeddable' in val) return true; + return false; + } + if (!hasEmbeddable(contextScopeInput)) + throw new Error( + "UrlDrilldown [getContextScope] can't build scope because embeddable object is missing in context" + ); + const embeddable = contextScopeInput.embeddable; + + return getEmbeddableVariables(embeddable); +}; + +export const getContextScopeValues = (contextScopeInput: unknown): ContextValues => { + return { + panel: getContextPanelScopeValues(contextScopeInput), + }; +}; + +type VariableDescription = Pick; + +const variableDescriptions: Record = { + id: { + title: i18n.translate('xpack.urlDrilldown.context.panel.id.title', { + defaultMessage: 'Panel ID.', + }), + documentation: i18n.translate('xpack.urlDrilldown.context.panel.id.documentation', { + defaultMessage: 'ID of the panel where drilldown is executed.', + }), + }, + title: { + title: i18n.translate('xpack.urlDrilldown.context.panel.title.title', { + defaultMessage: 'Panel title.', + }), + documentation: i18n.translate('xpack.urlDrilldown.context.panel.title.documentation', { + defaultMessage: 'Title of the panel where drilldown is executed.', + }), + }, + filters: { + title: i18n.translate('xpack.urlDrilldown.context.panel.filters.title', { + defaultMessage: 'Panel filters.', + }), + documentation: i18n.translate('xpack.urlDrilldown.context.panel.filters.documentation', { + defaultMessage: 'List of Kibana filters applied to a panel.', + }), + }, + 'query.query': { + title: i18n.translate('xpack.urlDrilldown.context.panel.query.query.title', { + defaultMessage: 'Query string.', + }), + }, + 'query.language': { + title: i18n.translate('xpack.urlDrilldown.context.panel.query.language.title', { + defaultMessage: 'Query language.', + }), + }, + 'timeRange.from': { + title: i18n.translate('xpack.urlDrilldown.context.panel.timeRange.from.title', { + defaultMessage: 'Time picker "from" value.', + }), + }, + 'timeRange.to': { + title: i18n.translate('xpack.urlDrilldown.context.panel.timeRange.to.title', { + defaultMessage: 'Time picker "to" value.', + }), + }, + indexPatternId: { + title: i18n.translate('xpack.urlDrilldown.context.panel.timeRange.indexPatternId.title', { + defaultMessage: 'Index pattern ID.', + }), + documentation: i18n.translate( + 'xpack.urlDrilldown.context.panel.timeRange.indexPatternId.documentation', + { + defaultMessage: 'First index pattern ID used by the panel.', + } + ), + }, + indexPatternIds: { + title: i18n.translate('xpack.urlDrilldown.context.panel.timeRange.indexPatternIds.title', { + defaultMessage: 'Index pattern IDs.', + }), + documentation: i18n.translate( + 'xpack.urlDrilldown.context.panel.timeRange.indexPatternIds.documentation', + { + defaultMessage: 'List of all index pattern IDs used by the panel.', + } + ), + }, + savedObjectId: { + title: i18n.translate('xpack.urlDrilldown.context.panel.savedObjectId.title', { + defaultMessage: 'Saved object ID.', + }), + documentation: i18n.translate('xpack.urlDrilldown.context.panel.savedObjectId.documentation', { + defaultMessage: 'ID of the saved object behind the panel.', + }), + }, +}; + +const kind = monaco.languages.CompletionItemKind.Variable; +const sortPrefix = '2.'; + +const formatValue = (value: unknown) => { + if (typeof value === 'object') { + return '\n' + JSON.stringify(value, null, 4); + } + + return String(value); +}; + +const getPanelVariableList = (values: PanelValues): UrlTemplateEditorVariable[] => { + const variables: UrlTemplateEditorVariable[] = []; + const flattenedValues = getFlattenedObject(values); + const keys = Object.keys(flattenedValues).sort(); + + for (const key of keys) { + const description = variableDescriptions[key]; + const label = 'context.panel.' + key; + + if (!description) { + variables.push({ + label, + sortText: sortPrefix + label, + documentation: !!flattenedValues[key] ? txtValue(formatValue(flattenedValues[key])) : '', + kind, + }); + continue; + } + + variables.push({ + label, + sortText: sortPrefix + label, + title: description.title, + documentation: + (description.documentation || '') + + (!!description.documentation && !!flattenedValues[key] ? '\n\n' : '') + + (!!flattenedValues[key] ? txtValue(formatValue(flattenedValues[key])) : ''), + kind, + }); + } + + return variables; +}; + +export const getContextVariableList = ( + context: ActionFactoryContext +): UrlTemplateEditorVariable[] => { + const values = getContextScopeValues(context); + const variables: UrlTemplateEditorVariable[] = getPanelVariableList(values.panel); + + return variables; +}; diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.test.ts new file mode 100644 index 0000000000000..3d0c55a08d1bf --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + getEventScopeValues, + getEventVariableList, + ValueClickTriggerEventScope, +} from './event_variables'; +import { + RowClickContext, + ROW_CLICK_TRIGGER, +} from '../../../../../../../src/plugins/ui_actions/public'; +import { createPoint, rowClickData } from '../test/data'; + +describe('VALUE_CLICK_TRIGGER', () => { + describe('supports `points[]`', () => { + test('getEventScopeValues()', () => { + const mockDataPoints = [ + createPoint({ field: 'field0', value: 'value0' }), + createPoint({ field: 'field1', value: 'value1' }), + createPoint({ field: 'field2', value: 'value2' }), + ]; + + const eventScope = getEventScopeValues({ + data: { data: mockDataPoints }, + }) as ValueClickTriggerEventScope; + + expect(eventScope.key).toBe('field0'); + expect(eventScope.value).toBe('value0'); + expect(eventScope.points).toHaveLength(mockDataPoints.length); + expect(eventScope.points).toMatchInlineSnapshot(` + Array [ + Object { + "key": "field0", + "value": "value0", + }, + Object { + "key": "field1", + "value": "value1", + }, + Object { + "key": "field2", + "value": "value2", + }, + ] + `); + }); + }); + + describe('handles undefined, null or missing values', () => { + test('undefined or missing values are removed from the result scope', () => { + const point = createPoint({ field: undefined } as any); + const eventScope = getEventScopeValues({ + data: { data: [point] }, + }) as ValueClickTriggerEventScope; + + expect('key' in eventScope).toBeFalsy(); + expect('value' in eventScope).toBeFalsy(); + }); + + test('null value stays in the result scope', () => { + const point = createPoint({ field: 'field', value: null }); + const eventScope = getEventScopeValues({ + data: { data: [point] }, + }) as ValueClickTriggerEventScope; + + expect(eventScope.value).toBeNull(); + }); + }); +}); + +describe('ROW_CLICK_TRIGGER', () => { + test('getEventVariableList() returns correct list of runtime variables', () => { + const vars = getEventVariableList({ + triggers: [ROW_CLICK_TRIGGER], + }); + expect(vars.map(({ label }) => label)).toEqual([ + 'event.values', + 'event.keys', + 'event.columnNames', + 'event.rowIndex', + ]); + }); + + test('getEventScopeValues() returns correct variables for row click trigger', () => { + const context = ({ + embeddable: {}, + data: rowClickData as any, + } as unknown) as RowClickContext; + const res = getEventScopeValues(context); + + expect(res).toEqual({ + rowIndex: 1, + values: ['IT', '2.25', 3, 0, 2], + keys: ['DestCountry', 'FlightTimeHour', '', 'DistanceMiles', 'OriginAirportID'], + columnNames: [ + 'Top values of DestCountry', + 'Top values of FlightTimeHour', + 'Count of records', + 'Average of DistanceMiles', + 'Unique count of OriginAirportID', + ], + }); + }); +}); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.ts new file mode 100644 index 0000000000000..8eb798eea74cb --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.ts @@ -0,0 +1,298 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { monaco } from '@kbn/monaco'; +import { + isRangeSelectTriggerContext, + isValueClickTriggerContext, + isRowClickTriggerContext, + isContextMenuTriggerContext, + RangeSelectContext, + SELECT_RANGE_TRIGGER, + ValueClickContext, + VALUE_CLICK_TRIGGER, +} from '../../../../../../../src/plugins/embeddable/public'; +import type { + ActionContext, + ActionFactoryContext, + EmbeddableWithQueryInput, +} from '../url_drilldown'; +import { + RowClickContext, + ROW_CLICK_TRIGGER, +} from '../../../../../../../src/plugins/ui_actions/public'; +import type { UrlTemplateEditorVariable } from '../../../../../../../src/plugins/kibana_react/public'; +import { deleteUndefinedKeys, toPrimitiveOrUndefined, Primitive } from './util'; + +/** + * URL drilldown event scope, available as `{{event.*}}` Handlebars variables. + */ +export type UrlDrilldownEventScope = + | ValueClickTriggerEventScope + | RangeSelectTriggerEventScope + | RowClickTriggerEventScope + | ContextMenuTriggerEventScope; + +export type EventScopeInput = ActionContext; + +export interface ValueClickTriggerEventScope { + key?: string; + value: Primitive; + negate: boolean; + points: Array<{ key?: string; value: Primitive }>; +} + +export interface RangeSelectTriggerEventScope { + key: string; + from?: string | number; + to?: string | number; +} + +export interface RowClickTriggerEventScope { + rowIndex: number; + values: Primitive[]; + keys: string[]; + columnNames: string[]; +} + +export type ContextMenuTriggerEventScope = object; + +const getEventScopeFromRangeSelectTriggerContext = ( + eventScopeInput: RangeSelectContext +): RangeSelectTriggerEventScope => { + const { table, column: columnIndex, range } = eventScopeInput.data; + const column = table.columns[columnIndex]; + return deleteUndefinedKeys({ + key: toPrimitiveOrUndefined(column?.meta.field) as string, + from: toPrimitiveOrUndefined(range[0]) as string | number | undefined, + to: toPrimitiveOrUndefined(range[range.length - 1]) as string | number | undefined, + }); +}; + +const getEventScopeFromValueClickTriggerContext = ( + eventScopeInput: ValueClickContext +): ValueClickTriggerEventScope => { + const negate = eventScopeInput.data.negate ?? false; + const points = eventScopeInput.data.data.map(({ table, value, column: columnIndex }) => { + const column = table.columns[columnIndex]; + return { + value: toPrimitiveOrUndefined(value) as Primitive, + key: column?.meta?.field, + }; + }); + + return deleteUndefinedKeys({ + key: points[0]?.key, + value: points[0]?.value, + negate, + points, + }); +}; + +const getEventScopeFromRowClickTriggerContext = ( + ctx: RowClickContext +): RowClickTriggerEventScope => { + const { data } = ctx; + const embeddable = ctx.embeddable as EmbeddableWithQueryInput; + + const { rowIndex } = data; + const columns = data.columns || data.table.columns.map(({ id }) => id); + const values: Primitive[] = []; + const keys: string[] = []; + const columnNames: string[] = []; + const row = data.table.rows[rowIndex]; + + for (const columnId of columns) { + const column = data.table.columns.find(({ id }) => id === columnId); + if (!column) { + // This should never happe, but in case it does we log data necessary for debugging. + // eslint-disable-next-line no-console + console.error(data, embeddable ? `Embeddable [${embeddable.getTitle()}]` : null); + throw new Error('Could not find a datatable column.'); + } + values.push(row[columnId]); + keys.push(column.meta.field || ''); + columnNames.push(column.name || column.meta.field || ''); + } + + const scope: RowClickTriggerEventScope = { + rowIndex, + values, + keys, + columnNames, + }; + + return scope; +}; + +export const getEventScopeValues = (eventScopeInput: EventScopeInput): UrlDrilldownEventScope => { + if (isRangeSelectTriggerContext(eventScopeInput)) { + return getEventScopeFromRangeSelectTriggerContext(eventScopeInput); + } else if (isValueClickTriggerContext(eventScopeInput)) { + return getEventScopeFromValueClickTriggerContext(eventScopeInput); + } else if (isRowClickTriggerContext(eventScopeInput)) { + return getEventScopeFromRowClickTriggerContext(eventScopeInput); + } else if (isContextMenuTriggerContext(eventScopeInput)) { + return {}; + } else { + throw new Error("UrlDrilldown [getEventScope] can't build scope from not supported trigger"); + } +}; + +const kind = monaco.languages.CompletionItemKind.Event; +const sortPrefix = '1.'; + +const valueClickVariables: readonly UrlTemplateEditorVariable[] = [ + { + label: 'event.value', + sortText: sortPrefix + 'event.value', + title: i18n.translate('xpack.urlDrilldown.click.event.value.title', { + defaultMessage: 'Click value.', + }), + documentation: i18n.translate('xpack.urlDrilldown.click.event.value.documentation', { + defaultMessage: 'Value behind clicked data point.', + }), + kind, + }, + { + label: 'event.key', + sortText: sortPrefix + 'event.key', + title: i18n.translate('xpack.urlDrilldown.click.event.key.title', { + defaultMessage: 'Name of clicked field.', + }), + documentation: i18n.translate('xpack.urlDrilldown.click.event.key.documentation', { + defaultMessage: 'Field name behind clicked data point.', + }), + kind, + }, + { + label: 'event.negate', + sortText: sortPrefix + 'event.negate', + title: i18n.translate('xpack.urlDrilldown.click.event.negate.title', { + defaultMessage: 'Whether the filter is negated.', + }), + documentation: i18n.translate('xpack.urlDrilldown.click.event.negate.documentation', { + defaultMessage: 'Boolean, indicating whether clicked data point resulted in negative filter.', + }), + kind, + }, + { + label: 'event.points', + sortText: sortPrefix + 'event.points', + title: i18n.translate('xpack.urlDrilldown.click.event.points.title', { + defaultMessage: 'List of all clicked points.', + }), + documentation: i18n.translate('xpack.urlDrilldown.click.event.points.documentation', { + defaultMessage: + 'Some visualizations have clickable points that emit more than one data point. Use list of data points in case a single value is insufficient.', + }), + kind, + }, +]; + +const rowClickVariables: readonly UrlTemplateEditorVariable[] = [ + { + label: 'event.values', + sortText: sortPrefix + 'event.values', + title: i18n.translate('xpack.urlDrilldown.row.event.values.title', { + defaultMessage: 'List of row cell values.', + }), + documentation: i18n.translate('xpack.urlDrilldown.row.event.values.documentation', { + defaultMessage: 'An array of all cell values for the raw on which the action will execute.', + }), + kind, + }, + { + label: 'event.keys', + sortText: sortPrefix + 'event.keys', + title: i18n.translate('xpack.urlDrilldown.row.event.keys.title', { + defaultMessage: 'List of row cell fields.', + }), + documentation: i18n.translate('xpack.urlDrilldown.row.event.keys.documentation', { + defaultMessage: 'An array of field names for each column.', + }), + kind, + }, + { + label: 'event.columnNames', + sortText: sortPrefix + 'event.columnNames', + title: i18n.translate('xpack.urlDrilldown.row.event.columnNames.title', { + defaultMessage: 'List of table column names.', + }), + documentation: i18n.translate('xpack.urlDrilldown.row.event.columnNames.documentation', { + defaultMessage: 'An array of column names.', + }), + kind, + }, + { + label: 'event.rowIndex', + sortText: sortPrefix + 'event.rowIndex', + title: i18n.translate('xpack.urlDrilldown.row.event.rowIndex.title', { + defaultMessage: 'Clicked row index.', + }), + documentation: i18n.translate('xpack.urlDrilldown.row.event.rowIndex.documentation', { + defaultMessage: 'Number, representing the row that was clicked, starting from 0.', + }), + kind, + }, +]; + +const selectRangeVariables: readonly UrlTemplateEditorVariable[] = [ + { + label: 'event.key', + sortText: sortPrefix + 'event.key', + title: i18n.translate('xpack.urlDrilldown.range.event.key.title', { + defaultMessage: 'Name of aggregation field.', + }), + documentation: i18n.translate('xpack.urlDrilldown.range.event.key.documentation', { + defaultMessage: 'Aggregation field behind the selected range, if available.', + }), + kind, + }, + { + label: 'event.from', + sortText: sortPrefix + 'event.from', + title: i18n.translate('xpack.urlDrilldown.range.event.from.title', { + defaultMessage: 'Range start value.', + }), + documentation: i18n.translate('xpack.urlDrilldown.range.event.from.documentation', { + defaultMessage: + '`from` value of the selected range. Depending on your data, could be either a date or number.', + }), + kind, + }, + { + label: 'event.to', + sortText: sortPrefix + 'event.to', + title: i18n.translate('xpack.urlDrilldown.range.event.to.title', { + defaultMessage: 'Range end value.', + }), + documentation: i18n.translate('xpack.urlDrilldown.range.event.to.documentation', { + defaultMessage: + '`to` value of the selected range. Depending on your data, could be either a date or number.', + }), + kind, + }, +]; + +export const getEventVariableList = ( + context: ActionFactoryContext +): UrlTemplateEditorVariable[] => { + const [trigger] = context.triggers; + + switch (trigger) { + case VALUE_CLICK_TRIGGER: + return [...valueClickVariables]; + case ROW_CLICK_TRIGGER: + return [...rowClickVariables]; + case SELECT_RANGE_TRIGGER: + return [...selectRangeVariables]; + } + + return []; +}; diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/global_variables.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/global_variables.ts new file mode 100644 index 0000000000000..7338e6b471211 --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/global_variables.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { monaco } from '@kbn/monaco'; +import { txtValue } from './i18n'; +import { UrlDrilldownGlobalScope } from '../../../../../ui_actions_enhanced/public'; +import type { UrlTemplateEditorVariable } from '../../../../../../../src/plugins/kibana_react/public'; + +const kind = monaco.languages.CompletionItemKind.Constant; +const sortPrefix = '3.'; + +export const getGlobalVariableList = ( + values: UrlDrilldownGlobalScope +): UrlTemplateEditorVariable[] => { + const globalVariables: UrlTemplateEditorVariable[] = [ + { + label: 'kibanaUrl', + sortText: sortPrefix + 'kibanaUrl', + title: i18n.translate('xpack.urlDrilldown.global.kibanaUrl.documentation.title', { + defaultMessage: 'Link to Kibana homepage.', + }), + documentation: + i18n.translate('xpack.urlDrilldown.global.kibanaUrl.documentation', { + defaultMessage: + 'Kibana base URL. Useful for creating URL drilldowns that navigate within Kibana.', + }) + + '\n\n' + + txtValue(values.kibanaUrl), + kind, + }, + ]; + + return globalVariables; +}; diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/i18n.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/i18n.ts new file mode 100644 index 0000000000000..b7b7cab535702 --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/i18n.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtValue = (value: string) => + i18n.translate('xpack.urlDrilldown.valuePreview', { + defaultMessage: 'Value: {value}', + values: { + value, + }, + }); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/util.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/util.ts new file mode 100644 index 0000000000000..ef9045b9ba108 --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/util.ts @@ -0,0 +1,25 @@ +/* + * 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 type Primitive = string | number | boolean | null; + +export const toPrimitiveOrUndefined = (v: unknown): Primitive | undefined => { + if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string' || v === null) + return v; + if (typeof v === 'object' && v instanceof Date) return v.toISOString(); + if (typeof v === 'undefined') return undefined; + return String(v); +}; + +export const deleteUndefinedKeys = >(obj: T): T => { + Object.keys(obj).forEach((key) => { + if (obj[key] === undefined) { + delete obj[key]; + } + }); + return obj; +}; diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts b/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts index 4b8e26c4a866b..b733691c639b6 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts @@ -47,6 +47,7 @@ export class UrlDrilldownPlugin startServices().core.docLinks.links.dashboard.urlDrilldownTemplateSyntax, getVariablesHelpDocsLink: () => startServices().core.docLinks.links.dashboard.urlDrilldownVariables, + uiSettings: core.uiSettings, }) ); diff --git a/x-pack/plugins/ui_actions_enhanced/kibana.json b/x-pack/plugins/ui_actions_enhanced/kibana.json index 5435019f216f2..dbc136a258884 100644 --- a/x-pack/plugins/ui_actions_enhanced/kibana.json +++ b/x-pack/plugins/ui_actions_enhanced/kibana.json @@ -2,13 +2,13 @@ "id": "uiActionsEnhanced", "version": "kibana", "configPath": ["xpack", "ui_actions_enhanced"], + "server": true, + "ui": true, "requiredPlugins": [ "embeddable", "uiActions", "licensing" ], - "server": true, - "ui": true, "requiredBundles": [ "kibanaUtils", "kibanaReact", diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts index c00bf2eed1475..ac2ddd1bfc14d 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { UrlDrilldownCollectConfig } from './url_drilldown_collect_config/url_drilldown_collect_config'; +export { UrlDrilldownCollectConfig } from './url_drilldown_collect_config'; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts index 60f43f86b7645..3df237b03229e 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts @@ -25,13 +25,6 @@ export const txtUrlPreviewHelpText = i18n.translate( } ); -export const txtAddVariableButtonTitle = i18n.translate( - 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.addVariableButtonTitle', - { - defaultMessage: 'Add variable', - } -); - export const txtUrlTemplateLabel = i18n.translate( 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateLabel', { @@ -46,20 +39,6 @@ export const txtUrlTemplateSyntaxHelpLinkText = i18n.translate( } ); -export const txtUrlTemplateVariablesHelpLinkText = i18n.translate( - 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesHelpLinkText', - { - defaultMessage: 'Help', - } -); - -export const txtUrlTemplateVariablesFilterPlaceholderText = i18n.translate( - 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesFilterPlaceholderText', - { - defaultMessage: 'Filter variables', - } -); - export const txtUrlTemplatePreviewLabel = i18n.translate( 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLabel', { diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.ts new file mode 100644 index 0000000000000..7922158c62e7f --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/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 { UrlDrilldownCollectConfig, UrlDrilldownCollectConfigProps } from './lazy'; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/lazy.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/lazy.tsx new file mode 100644 index 0000000000000..d4b04ce489dab --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/lazy.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as React from 'react'; +import type { UrlDrilldownCollectConfigProps } from './url_drilldown_collect_config'; + +const UrlDrilldownCollectConfigLazy = React.lazy(() => + import('./url_drilldown_collect_config').then(({ UrlDrilldownCollectConfig }) => ({ + default: UrlDrilldownCollectConfig, + })) +); + +export type { UrlDrilldownCollectConfigProps }; + +export const UrlDrilldownCollectConfig: React.FC = (props) => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx index 7a8b1b5b967ed..f6a75765cd8ad 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx @@ -20,7 +20,14 @@ export const Demo = () => { {JSON.stringify(config)} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx index 2537695b5edab..c9da71440d236 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx @@ -5,55 +5,49 @@ * 2.0. */ -import React, { useRef, useState } from 'react'; +import React, { useRef } from 'react'; import { EuiFormRow, - EuiIcon, EuiLink, - EuiPopover, - EuiPopoverFooter, - EuiPopoverTitle, - EuiSelectable, - EuiText, - EuiTextArea, - EuiSelectableOption, EuiSwitch, EuiAccordion, EuiSpacer, EuiPanel, EuiTextColor, } from '@elastic/eui'; +import { monaco } from '@kbn/monaco'; import { UrlDrilldownConfig } from '../../types'; import './index.scss'; import { - txtAddVariableButtonTitle, txtUrlTemplateSyntaxHelpLinkText, - txtUrlTemplateVariablesHelpLinkText, - txtUrlTemplateVariablesFilterPlaceholderText, txtUrlTemplateLabel, txtUrlTemplateOpenInNewTab, - txtUrlTemplatePlaceholder, txtUrlTemplateAdditionalOptions, txtUrlTemplateEncodeUrl, txtUrlTemplateEncodeDescription, } from './i18n'; +import { VariablePopover } from '../variable_popover'; +import { + UrlTemplateEditor, + UrlTemplateEditorVariable, +} from '../../../../../../../../src/plugins/kibana_react/public'; -export interface UrlDrilldownCollectConfig { +export interface UrlDrilldownCollectConfigProps { config: UrlDrilldownConfig; - variables: string[]; + variables: UrlTemplateEditorVariable[]; onConfig: (newConfig: UrlDrilldownConfig) => void; syntaxHelpDocsLink?: string; variablesHelpDocsLink?: string; } -export const UrlDrilldownCollectConfig: React.FC = ({ +export const UrlDrilldownCollectConfig: React.FC = ({ config, variables, onConfig, syntaxHelpDocsLink, variablesHelpDocsLink, }) => { - const textAreaRef = useRef(null); + const editorRef = useRef(null); const [showUrlError, setShowUrlError] = React.useState(false); const urlTemplate = config.url.template ?? ''; @@ -72,19 +66,16 @@ export const UrlDrilldownCollectConfig: React.FC = ({ const isEmpty = !urlTemplate; const isInvalid = showUrlError && isEmpty; const variablesDropdown = ( - { - if (textAreaRef.current) { - updateUrlTemplate( - urlTemplate.substr(0, textAreaRef.current!.selectionStart) + - `{{${variable}}}` + - urlTemplate.substr(textAreaRef.current!.selectionEnd) - ); - } else { - updateUrlTemplate(urlTemplate + `{{${variable}}}`); - } + const editor = editorRef.current; + if (!editor) return; + + editor.trigger('keyboard', 'type', { + text: '{{' + variable + '}}', + }); }} /> ); @@ -105,17 +96,13 @@ export const UrlDrilldownCollectConfig: React.FC = ({ } labelAppend={variablesDropdown} > - updateUrlTemplate(event.target.value)} - onBlur={() => setShowUrlError(true)} - rows={3} - inputRef={textAreaRef} + onChange={(newUrlTemplate) => updateUrlTemplate(newUrlTemplate)} + onEditor={(editor) => { + editorRef.current = editor; + }} /> @@ -156,71 +143,3 @@ export const UrlDrilldownCollectConfig: React.FC = ({ ); }; - -function AddVariableButton({ - variables, - onSelect, - variablesHelpLink, -}: { - variables: string[]; - onSelect: (variable: string) => void; - variablesHelpLink?: string; -}) { - const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState(false); - const closePopover = () => setIsVariablesPopoverOpen(false); - - const options: EuiSelectableOption[] = variables.map((variable: string) => ({ - key: variable, - label: variable, - })); - - return ( - - setIsVariablesPopoverOpen(true)}> - {txtAddVariableButtonTitle} - - - } - isOpen={isVariablesPopoverOpen} - closePopover={closePopover} - panelPaddingSize="none" - anchorPosition="downLeft" - > - { - const selected = newOptions.find((o) => o.checked === 'on'); - if (!selected) return; - onSelect(selected.key!); - closePopover(); - }} - listProps={{ - showIcons: false, - }} - > - {(list, search) => ( -
- {search} - {list} - {variablesHelpLink && ( - - - {txtUrlTemplateVariablesHelpLinkText} - - - )} -
- )} -
-
- ); -} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/variable_popover/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/variable_popover/i18n.ts new file mode 100644 index 0000000000000..ece7a71778eb9 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/variable_popover/i18n.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtAddVariableButtonTitle = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.addVariableButtonTitle', + { + defaultMessage: 'Add variable', + } +); + +export const txtUrlTemplateVariablesHelpLinkText = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesHelpLinkText', + { + defaultMessage: 'Help', + } +); + +export const txtUrlTemplateVariablesFilterPlaceholderText = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesFilterPlaceholderText', + { + defaultMessage: 'Filter variables', + } +); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/variable_popover/index.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/variable_popover/index.tsx new file mode 100644 index 0000000000000..a0c1b2ad24b11 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/variable_popover/index.tsx @@ -0,0 +1,90 @@ +/* + * 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, { useState } from 'react'; +import { + EuiIcon, + EuiLink, + EuiPopover, + EuiPopoverFooter, + EuiPopoverTitle, + EuiSelectable, + EuiText, + EuiSelectableOption, +} from '@elastic/eui'; +import { + txtAddVariableButtonTitle, + txtUrlTemplateVariablesHelpLinkText, + txtUrlTemplateVariablesFilterPlaceholderText, +} from './i18n'; +import { UrlTemplateEditorVariable } from '../../../../../../../../src/plugins/kibana_react/public'; + +export interface Props { + variables: UrlTemplateEditorVariable[]; + onSelect: (variable: string) => void; + variablesHelpLink?: string; +} + +export const VariablePopover: React.FC = ({ variables, onSelect, variablesHelpLink }) => { + const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState(false); + const closePopover = () => setIsVariablesPopoverOpen(false); + + const options: EuiSelectableOption[] = variables.map(({ label }) => ({ + key: label, + label, + })); + + return ( + + setIsVariablesPopoverOpen(true)}> + {txtAddVariableButtonTitle} + + + } + isOpen={isVariablesPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="downLeft" + > + { + const selected = newOptions.find((o) => o.checked === 'on'); + if (!selected) return; + onSelect(selected.key!); + closePopover(); + }} + listProps={{ + showIcons: false, + }} + > + {(list, search) => ( +
+ {search} + {list} + {variablesHelpLink && ( + + + {txtUrlTemplateVariablesHelpLinkText} + + + )} +
+ )} +
+
+ ); +}; diff --git a/x-pack/test/functional/services/dashboard/drilldowns_manage.ts b/x-pack/test/functional/services/dashboard/drilldowns_manage.ts index 21fa18272c14b..6f4deb2d32986 100644 --- a/x-pack/test/functional/services/dashboard/drilldowns_manage.ts +++ b/x-pack/test/functional/services/dashboard/drilldowns_manage.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { Key } from 'selenium-webdriver'; import { FtrProviderContext } from '../../ftr_provider_context'; const CREATE_DRILLDOWN_FLYOUT_DATA_TEST_SUBJ = 'createDrilldownFlyout'; @@ -24,7 +25,8 @@ export function DashboardDrilldownsManageProvider({ getService }: FtrProviderCon const flyout = getService('flyout'); const comboBox = getService('comboBox'); const esArchiver = getService('esArchiver'); - + const find = getService('find'); + const browser = getService('browser'); return new (class DashboardDrilldownsManage { readonly DASHBOARD_WITH_PIE_CHART_NAME = 'Dashboard with Pie Chart'; readonly DASHBOARD_WITH_AREA_CHART_NAME = 'Dashboard With Area Chart'; @@ -116,8 +118,22 @@ export function DashboardDrilldownsManageProvider({ getService }: FtrProviderCon } } + async eraseInput(maxChars: number) { + const keys = [ + ...Array(maxChars).fill(Key.ARROW_RIGHT), + ...Array(maxChars).fill(Key.BACK_SPACE), + ]; + await browser + .getActions() + .sendKeys(...keys) + .perform(); + } + async fillInURLTemplate(destinationURLTemplate: string) { - await testSubjects.setValue('urlInput', destinationURLTemplate); + const monaco = await find.byCssSelector('.urlTemplateEditor__container .monaco-editor'); + await monaco.clickMouseButton(); + await this.eraseInput(300); + await browser.pressKeys(destinationURLTemplate); } async saveChanges() { From f1f206b2c825ea92e56d164c288ddac3268037d7 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Mon, 15 Feb 2021 18:34:46 +0100 Subject: [PATCH 14/23] Legacy ES client: use config.httpAuth instead of config.hosts.auth (#91276) * Use httpAuth instead of host.auth * NIT --- .../legacy/elasticsearch_client_config.test.ts | 4 +--- .../legacy/elasticsearch_client_config.ts | 11 +++++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/core/server/elasticsearch/legacy/elasticsearch_client_config.test.ts b/src/core/server/elasticsearch/legacy/elasticsearch_client_config.test.ts index d8b9b79bc381d..6239ad270d5b5 100644 --- a/src/core/server/elasticsearch/legacy/elasticsearch_client_config.test.ts +++ b/src/core/server/elasticsearch/legacy/elasticsearch_client_config.test.ts @@ -99,7 +99,6 @@ test('parses fully specified config', () => { "apiVersion": "v7.0.0", "hosts": Array [ Object { - "auth": "elastic:changeme", "headers": Object { "x-elastic-product-origin": "kibana", "xsrf": "something", @@ -111,7 +110,6 @@ test('parses fully specified config', () => { "query": null, }, Object { - "auth": "elastic:changeme", "headers": Object { "x-elastic-product-origin": "kibana", "xsrf": "something", @@ -123,7 +121,6 @@ test('parses fully specified config', () => { "query": null, }, Object { - "auth": "elastic:changeme", "headers": Object { "x-elastic-product-origin": "kibana", "xsrf": "something", @@ -135,6 +132,7 @@ test('parses fully specified config', () => { "query": null, }, ], + "httpAuth": "elastic:changeme", "keepAlive": true, "log": [Function], "pingTimeout": 12345, diff --git a/src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts b/src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts index 728fda04a8b5e..d68e7635c57cb 100644 --- a/src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts +++ b/src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts @@ -106,11 +106,14 @@ export function parseElasticsearchClientConfig( esClientConfig.sniffInterval = getDurationAsMs(config.sniffInterval); } + const needsAuth = auth !== false && config.username && config.password; + if (needsAuth) { + esClientConfig.httpAuth = `${config.username}:${config.password}`; + } + if (Array.isArray(config.hosts)) { - const needsAuth = auth !== false && config.username && config.password; esClientConfig.hosts = config.hosts.map((nodeUrl: string) => { const uri = url.parse(nodeUrl); - const httpsURI = uri.protocol === 'https:'; const httpURI = uri.protocol === 'http:'; @@ -126,10 +129,6 @@ export function parseElasticsearchClientConfig( }, }; - if (needsAuth) { - host.auth = `${config.username}:${config.password}`; - } - return host; }); } From 0a5e054fdc3ebf9b18653e6661344a580f7bd583 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Mon, 15 Feb 2021 13:36:39 -0500 Subject: [PATCH 15/23] [Fleet] Don't upgrade agent(s) in a managed policy (#91303) ## Summary - Make sure any agents requesting to be upgraded, are not enrolled in a managed policy. - `force: true` will only bypass agent / kibana version checks. It will not bypass managed policy check. To workaround, the enrolled policy should be changed to unmanaged (`is_managed: false`) as we do with enroll, reassign, etc. - Took more efficient approach to bulk actions. One `bulkGet` for N agents/policies vs N `get`s approach used for bulk reassignment of agents. See discussion in https://github.com/elastic/kibana/pull/88688/files#r568941761 - [x] API - [ ] UI - [x] tests ### 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 ### Manual tests #### upgrade one ``` curl --location --request POST 'http://localhost:5601/api/fleet/agents/8d9748e0-6d52-11eb-8cbd-47e38cd1c8de/upgrade' --header 'kbn-xsrf: ' --header 'Content-Type: application/json' --header 'Authorization: Basic ZWxhc3RpYzpjaGFuZ2VtZQ==' --data-raw '{ "version": "8.0.0" }' {"statusCode":400,"error":"Bad Request","message":"Cannot upgrade agent 8d9748e0-6d52-11eb-8cbd-47e38cd1c8de in managed policy bf319100-6d50-11eb-8859-15a87f509a99"} ``` ``` curl --location --request POST 'http://localhost:5601/api/fleet/agents/8d9748e0-6d52-11eb-8cbd-47e38cd1c8de/upgrade' --header 'kbn-xsrf: ' --header 'Content-Type: application/json' --header 'Authorization: Basic ZWxhc3RpYzpjaGFuZ2VtZQ==' --data-raw '{ "version": "8.0.0", "force": true }' {"statusCode":400,"error":"Bad Request","message":"Cannot upgrade agent 8d9748e0-6d52-11eb-8cbd-47e38cd1c8de in managed policy bf319100-6d50-11eb-8859-15a87f509a99"} ``` #### bulk upgrade ``` curl --location --request POST 'http://localhost:5601/api/fleet/agents/bulk_upgrade' --header 'kbn-xsrf: ' --header 'Content-Type: application/json' --header 'Authorization: Basic ZWxhc3RpYzpjaGFuZ2VtZQ==' --data-raw '{ "version": "8.0.0", "agents": [ "8d9748e0-6d52-11eb-8cbd-47e38cd1c8de" ] }' {} ``` ``` curl --location --request POST 'http://localhost:5601/api/fleet/agents/bulk_upgrade' --header 'kbn-xsrf: ' --header 'Content-Type: application/json' --header 'Authorization: Basic ZWxhc3RpYzpjaGFuZ2VtZQ==' --data-raw '{ "version": "8.0.0", "agents": [ "8d9748e0-6d52-11eb-8cbd-47e38cd1c8de" ], "force": true }' {"statusCode":400,"error":"Bad Request","message":"Cannot update agent in managed policy bf319100-6d50-11eb-8859-15a87f509a99"}``` ``` --- .../fleet/server/services/agent_policy.ts | 15 ++++ .../fleet/server/services/agents/upgrade.ts | 47 +++++++++-- .../apis/agents/upgrade.ts | 79 +++++++++++++++++++ 3 files changed, 136 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 31e9a63175d18..f31f38796055c 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -209,6 +209,21 @@ class AgentPolicyService { return agentPolicy; } + public async getByIDs( + soClient: SavedObjectsClientContract, + ids: string[], + options: { fields?: string[] } = {} + ): Promise { + const objects = ids.map((id) => ({ ...options, id, type: SAVED_OBJECT_TYPE })); + const agentPolicySO = await soClient.bulkGet(objects); + + return agentPolicySO.saved_objects.map((so) => ({ + id: so.id, + version: so.version, + ...so.attributes, + })); + } + public async list( soClient: SavedObjectsClientContract, options: ListWithKuery & { diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 5105e14530982..d73cc38e32c39 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -8,8 +8,16 @@ import { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; import { AgentAction, AgentActionSOAttributes } from '../../types'; import { AGENT_ACTION_SAVED_OBJECT_TYPE } from '../../constants'; +import { agentPolicyService } from '../../services'; +import { IngestManagerError } from '../../errors'; import { bulkCreateAgentActions, createAgentAction } from './actions'; -import { getAgents, listAllAgents, updateAgent, bulkUpdateAgents } from './crud'; +import { + getAgents, + listAllAgents, + updateAgent, + bulkUpdateAgents, + getAgentPolicyForAgent, +} from './crud'; import { isAgentUpgradeable } from '../../../common/services'; import { appContextService } from '../app_context'; @@ -31,6 +39,14 @@ export async function sendUpgradeAgentAction({ version, source_uri: sourceUri, }; + + const agentPolicy = await getAgentPolicyForAgent(soClient, esClient, agentId); + if (agentPolicy?.is_managed) { + throw new IngestManagerError( + `Cannot upgrade agent ${agentId} in managed policy ${agentPolicy.id}` + ); + } + await createAgentAction(soClient, esClient, { agent_id: agentId, created_at: now, @@ -89,19 +105,40 @@ export async function sendUpgradeAgentsActions( showInactive: false, }) ).agents; - const agentsToUpdate = options.force + + // upgradeable if they pass the version check + const upgradeableAgents = options.force ? agents : agents.filter((agent) => isAgentUpgradeable(agent, kibanaVersion)); + + // get any policy ids from upgradable agents + const policyIdsToGet = new Set( + upgradeableAgents.filter((agent) => agent.policy_id).map((agent) => agent.policy_id!) + ); + + // get the agent policies for those ids + const agentPolicies = await agentPolicyService.getByIDs(soClient, Array.from(policyIdsToGet), { + fields: ['is_managed'], + }); + + // throw if any of those agent policies are managed + for (const policy of agentPolicies) { + if (policy.is_managed) { + throw new IngestManagerError(`Cannot upgrade agent in managed policy ${policy.id}`); + } + } + + // Create upgrade action for each agent const now = new Date().toISOString(); const data = { version: options.version, source_uri: options.sourceUri, }; - // Create upgrade action for each agent + await bulkCreateAgentActions( soClient, esClient, - agentsToUpdate.map((agent) => ({ + upgradeableAgents.map((agent) => ({ agent_id: agent.id, created_at: now, data, @@ -113,7 +150,7 @@ export async function sendUpgradeAgentsActions( return await bulkUpdateAgents( soClient, esClient, - agentsToUpdate.map((agent) => ({ + upgradeableAgents.map((agent) => ({ agentId: agent.id, data: { upgraded_at: null, diff --git a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts index ce0da9158da2c..1b7afac7e2f6c 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts @@ -427,5 +427,84 @@ export default function (providerContext: FtrProviderContext) { }) .expect(400); }); + + describe('fleet upgrade agent(s) in a managed policy', function () { + it('should respond 400 to bulk upgrade and not update the agent SOs', async () => { + // update enrolled policy to managed + await supertest.put(`/api/fleet/agent_policies/policy1`).set('kbn-xsrf', 'xxxx').send({ + name: 'Test policy', + namespace: 'default', + is_managed: true, + }); + + const kibanaVersion = await kibanaServer.version.get(); + await kibanaServer.savedObjects.update({ + id: 'agent1', + type: AGENT_SAVED_OBJECT_TYPE, + attributes: { + local_metadata: { elastic: { agent: { upgradeable: true, version: '0.0.0' } } }, + }, + }); + await kibanaServer.savedObjects.update({ + id: 'agent2', + type: AGENT_SAVED_OBJECT_TYPE, + attributes: { + local_metadata: { + elastic: { + agent: { upgradeable: true, version: semver.inc(kibanaVersion, 'patch') }, + }, + }, + }, + }); + + // attempt to upgrade agent in managed policy + const { body } = await supertest + .post(`/api/fleet/agents/bulk_upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + version: kibanaVersion, + agents: ['agent1', 'agent2'], + }) + .expect(400); + expect(body.message).to.contain('Cannot upgrade agent in managed policy policy1'); + + const [agent1data, agent2data] = await Promise.all([ + supertest.get(`/api/fleet/agents/agent1`), + supertest.get(`/api/fleet/agents/agent2`), + ]); + + expect(typeof agent1data.body.item.upgrade_started_at).to.be('undefined'); + expect(typeof agent2data.body.item.upgrade_started_at).to.be('undefined'); + }); + + it('should respond 400 to upgrade and not update the agent SOs', async () => { + // update enrolled policy to managed + await supertest.put(`/api/fleet/agent_policies/policy1`).set('kbn-xsrf', 'xxxx').send({ + name: 'Test policy', + namespace: 'default', + is_managed: true, + }); + + const kibanaVersion = await kibanaServer.version.get(); + await kibanaServer.savedObjects.update({ + id: 'agent1', + type: AGENT_SAVED_OBJECT_TYPE, + attributes: { + local_metadata: { elastic: { agent: { upgradeable: true, version: '0.0.0' } } }, + }, + }); + + // attempt to upgrade agent in managed policy + const { body } = await supertest + .post(`/api/fleet/agents/agent1/upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ version: kibanaVersion }) + .expect(400); + expect(body.message).to.contain('Cannot upgrade agent agent1 in managed policy policy1'); + + const agent1data = await supertest.get(`/api/fleet/agents/agent1`); + expect(typeof agent1data.body.item.upgrade_started_at).to.be('undefined'); + }); + }); }); } From a8e1e47de6ee7b385c17928e91589db630835d6c Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Mon, 15 Feb 2021 20:47:00 +0100 Subject: [PATCH 16/23] [ML] Anomaly Detection alert initialisation from the ML app (#91283) --- x-pack/plugins/ml/common/types/alerts.ts | 1 + .../plugins/ml/common/types/capabilities.ts | 2 + .../alerting/interim_results_control.tsx | 34 +++ .../ml/public/alerting/job_selector.tsx | 10 +- .../ml/public/alerting/ml_alerting_flyout.tsx | 105 ++++++++ .../alerting/ml_anomaly_alert_trigger.tsx | 33 ++- .../ml/public/alerting/register_ml_alerts.ts | 8 +- x-pack/plugins/ml/public/application/app.tsx | 1 + .../contexts/kibana/kibana_context.ts | 2 + .../create_watch_flyout.js | 182 -------------- .../create_watch_service.js | 199 --------------- .../create_watch_flyout/create_watch_view.js | 215 ---------------- .../components/create_watch_flyout/email.html | 43 ---- .../email_influencers.html | 9 - .../components/create_watch_flyout/index.js | 8 - .../create_watch_flyout/select_severity.tsx | 134 ---------- .../components/create_watch_flyout/watch.js | 232 ------------------ .../components/job_actions/management.js | 20 +- .../components/jobs_list/jobs_list.js | 8 +- .../jobs_list_view/jobs_list_view.js | 26 +- .../multi_job_actions/actions_menu.js | 22 ++ .../multi_job_actions/multi_job_actions.js | 2 + .../start_datafeed_modal.js | 37 ++- .../post_save_options/post_save_options.tsx | 43 +--- x-pack/plugins/ml/public/plugin.ts | 10 +- .../ml/server/lib/alerts/alerting_service.ts | 7 + .../capabilities/check_capabilities.test.ts | 3 +- x-pack/plugins/ml/server/routes/alerting.ts | 2 + .../server/routes/schemas/alerting_schema.ts | 1 + .../machine_learning/empty_ml_capabilities.ts | 1 + .../translations/translations/ja-JP.json | 24 -- .../translations/translations/zh-CN.json | 24 -- 32 files changed, 289 insertions(+), 1159 deletions(-) create mode 100644 x-pack/plugins/ml/public/alerting/interim_results_control.tsx create mode 100644 x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_flyout.js delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_view.js delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email.html delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email_influencers.html delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/index.js delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/select_severity.tsx delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/watch.js diff --git a/x-pack/plugins/ml/common/types/alerts.ts b/x-pack/plugins/ml/common/types/alerts.ts index d19385a175efd..51d06b9906230 100644 --- a/x-pack/plugins/ml/common/types/alerts.ts +++ b/x-pack/plugins/ml/common/types/alerts.ts @@ -89,4 +89,5 @@ export type MlAnomalyDetectionAlertParams = { }; severity: number; resultType: AnomalyResultType; + includeInterim: boolean; } & AlertTypeParams; diff --git a/x-pack/plugins/ml/common/types/capabilities.ts b/x-pack/plugins/ml/common/types/capabilities.ts index cccf87f0a7950..61a5013642cd7 100644 --- a/x-pack/plugins/ml/common/types/capabilities.ts +++ b/x-pack/plugins/ml/common/types/capabilities.ts @@ -57,6 +57,8 @@ export const adminMlCapabilities = { canCreateDataFrameAnalytics: false, canDeleteDataFrameAnalytics: false, canStartStopDataFrameAnalytics: false, + // Alerts + canCreateMlAlerts: false, }; export type UserMlCapabilities = typeof userMlCapabilities; diff --git a/x-pack/plugins/ml/public/alerting/interim_results_control.tsx b/x-pack/plugins/ml/public/alerting/interim_results_control.tsx new file mode 100644 index 0000000000000..fa930d9a0ea0f --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/interim_results_control.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 React, { FC } from 'react'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface InterimResultsControlProps { + value: boolean; + onChange: (update: boolean) => void; +} + +export const InterimResultsControl: FC = React.memo( + ({ value, onChange }) => { + return ( + + + } + checked={value} + onChange={onChange.bind(null, !value)} + /> + + ); + } +); diff --git a/x-pack/plugins/ml/public/alerting/job_selector.tsx b/x-pack/plugins/ml/public/alerting/job_selector.tsx index 969ed5af79107..60bb7517406b8 100644 --- a/x-pack/plugins/ml/public/alerting/job_selector.tsx +++ b/x-pack/plugins/ml/public/alerting/job_selector.tsx @@ -19,7 +19,7 @@ interface JobSelection { export interface JobSelectorControlProps { jobSelection?: JobSelection; - onSelectionChange: (jobSelection: JobSelection) => void; + onChange: (jobSelection: JobSelection) => void; adJobsApiService: MlApiServices['jobs']; /** * Validation is handled by alerting framework @@ -29,7 +29,7 @@ export interface JobSelectorControlProps { export const JobSelectorControl: FC = ({ jobSelection, - onSelectionChange, + onChange, adJobsApiService, errors, }) => { @@ -70,7 +70,7 @@ export const JobSelectorControl: FC = ({ } }, [adJobsApiService]); - const onChange: EuiComboBoxProps['onChange'] = useCallback( + const onSelectionChange: EuiComboBoxProps['onChange'] = useCallback( (selectedOptions) => { const selectedJobIds: JobId[] = []; const selectedGroupIds: string[] = []; @@ -81,7 +81,7 @@ export const JobSelectorControl: FC = ({ selectedGroupIds.push(label); } }); - onSelectionChange({ + onChange({ ...(selectedJobIds.length > 0 ? { jobIds: selectedJobIds } : {}), ...(selectedGroupIds.length > 0 ? { groupIds: selectedGroupIds } : {}), }); @@ -114,7 +114,7 @@ export const JobSelectorControl: FC = ({ selectedOptions={selectedOptions} options={options} - onChange={onChange} + onChange={onSelectionChange} fullWidth data-test-subj={'mlAnomalyAlertJobSelection'} isInvalid={!!errors?.length} diff --git a/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx b/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx new file mode 100644 index 0000000000000..ba573fe42f5f2 --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx @@ -0,0 +1,105 @@ +/* + * 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, useCallback, useEffect, useMemo, useState } from 'react'; +import { JobId } from '../../common/types/anomaly_detection_jobs'; +import { useMlKibana } from '../application/contexts/kibana'; +import { ML_ALERT_TYPES } from '../../common/constants/alerts'; +import { PLUGIN_ID } from '../../common/constants/app'; + +interface MlAnomalyAlertFlyoutProps { + jobIds: JobId[]; + onSave?: () => void; + onCloseFlyout: () => void; +} + +/** + * Invoke alerting flyout from the ML plugin context. + * @param jobIds + * @param onCloseFlyout + * @constructor + */ +export const MlAnomalyAlertFlyout: FC = ({ + jobIds, + onCloseFlyout, + onSave, +}) => { + const { + services: { triggersActionsUi }, + } = useMlKibana(); + + const AddAlertFlyout = useMemo( + () => + triggersActionsUi && + triggersActionsUi.getAddAlertFlyout({ + consumer: PLUGIN_ID, + onClose: () => { + onCloseFlyout(); + }, + // Callback for successful save + reloadAlerts: async () => { + if (onSave) { + onSave(); + } + }, + canChangeTrigger: false, + alertTypeId: ML_ALERT_TYPES.ANOMALY_DETECTION, + metadata: {}, + initialValues: { + params: { + jobSelection: { + jobIds, + }, + }, + }, + }), + [triggersActionsUi] + ); + + return <>{AddAlertFlyout}; +}; + +interface JobListMlAnomalyAlertFlyoutProps { + setShowFunction: (callback: Function) => void; + unsetShowFunction: () => void; +} + +/** + * Component to wire the Alerting flyout with the Job list view. + * @param setShowFunction + * @param unsetShowFunction + * @constructor + */ +export const JobListMlAnomalyAlertFlyout: FC = ({ + setShowFunction, + unsetShowFunction, +}) => { + const [isVisible, setIsVisible] = useState(false); + const [jobIds, setJobIds] = useState(); + + const showFlyoutCallback = useCallback((jobIdsUpdate: JobId[]) => { + setJobIds(jobIdsUpdate); + setIsVisible(true); + }, []); + + useEffect(() => { + setShowFunction(showFlyoutCallback); + return () => { + unsetShowFunction(); + }; + }, []); + + return isVisible && jobIds ? ( + setIsVisible(false)} + onSave={() => { + setIsVisible(false); + }} + /> + ) : null; +}; diff --git a/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx b/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx index 5991a603890d7..3dd023a6187dd 100644 --- a/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx +++ b/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx @@ -5,8 +5,9 @@ * 2.0. */ -import React, { FC, useCallback, useEffect, useMemo } from 'react'; +import React, { FC, useCallback, useMemo } from 'react'; import { EuiSpacer, EuiForm } from '@elastic/eui'; +import useMount from 'react-use/lib/useMount'; import { JobSelectorControl } from './job_selector'; import { useMlKibana } from '../application/contexts/kibana'; import { jobsApiProvider } from '../application/services/ml_api_service/jobs'; @@ -18,6 +19,7 @@ import { PreviewAlertCondition } from './preview_alert_condition'; import { ANOMALY_THRESHOLD } from '../../common'; import { MlAnomalyDetectionAlertParams } from '../../common/types/alerts'; import { ANOMALY_RESULT_TYPE } from '../../common/constants/anomalies'; +import { InterimResultsControl } from './interim_results_control'; interface MlAnomalyAlertTriggerProps { alertParams: MlAnomalyDetectionAlertParams; @@ -25,12 +27,14 @@ interface MlAnomalyAlertTriggerProps { key: T, value: MlAnomalyDetectionAlertParams[T] ) => void; + setAlertProperty: (prop: string, update: Partial) => void; errors: Record; } const MlAnomalyAlertTrigger: FC = ({ alertParams, setAlertParams, + setAlertProperty, errors, }) => { const { @@ -49,21 +53,26 @@ const MlAnomalyAlertTrigger: FC = ({ [] ); - useEffect(function setDefaults() { - if (alertParams.severity === undefined) { - onAlertParamChange('severity')(ANOMALY_THRESHOLD.CRITICAL); + useMount(function setDefaults() { + const { jobSelection, ...rest } = alertParams; + if (Object.keys(rest).length === 0) { + setAlertProperty('params', { + // Set defaults + severity: ANOMALY_THRESHOLD.CRITICAL, + resultType: ANOMALY_RESULT_TYPE.BUCKET, + includeInterim: true, + // Preserve job selection + jobSelection, + }); } - if (alertParams.resultType === undefined) { - onAlertParamChange('resultType')(ANOMALY_RESULT_TYPE.BUCKET); - } - }, []); + }); return ( = ({ onChange={useCallback(onAlertParamChange('severity'), [])} /> + + + diff --git a/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts b/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts index 7f55eba9cbdc2..1d7bd06989bd9 100644 --- a/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts +++ b/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts @@ -7,14 +7,12 @@ import { i18n } from '@kbn/i18n'; import { lazy } from 'react'; -import { MlStartDependencies } from '../plugin'; import { ML_ALERT_TYPES } from '../../common/constants/alerts'; import { MlAnomalyDetectionAlertParams } from '../../common/types/alerts'; +import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public'; -export function registerMlAlerts( - alertTypeRegistry: MlStartDependencies['triggersActionsUi']['alertTypeRegistry'] -) { - alertTypeRegistry.register({ +export function registerMlAlerts(triggersActionsUi: TriggersAndActionsUIPublicPluginSetup) { + triggersActionsUi.alertTypeRegistry.register({ id: ML_ALERT_TYPES.ANOMALY_DETECTION, description: i18n.translate('xpack.ml.alertTypes.anomalyDetection.description', { defaultMessage: 'Alert when anomaly detection jobs results match the condition.', diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 0199e13e93d8c..3df67bc16ab05 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -81,6 +81,7 @@ const App: FC = ({ coreStart, deps, appMountParams }) => { storage: localStorage, embeddable: deps.embeddable, maps: deps.maps, + triggersActionsUi: deps.triggersActionsUi, ...coreStart, }; diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index 1dd30d5d99335..99d4b77547d9d 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -19,6 +19,7 @@ import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/p import type { EmbeddableStart } from '../../../../../../../src/plugins/embeddable/public'; import type { MapsStartApi } from '../../../../../maps/public'; import type { LensPublicStart } from '../../../../../lens/public'; +import { TriggersAndActionsUIPublicPluginStart } from '../../../../../triggers_actions_ui/public'; interface StartPlugins { data: DataPublicPluginStart; @@ -28,6 +29,7 @@ interface StartPlugins { embeddable: EmbeddableStart; maps?: MapsStartApi; lens?: LensPublicStart; + triggersActionsUi?: TriggersAndActionsUIPublicPluginStart; } export type StartServices = CoreStart & StartPlugins & { diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_flyout.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_flyout.js deleted file mode 100644 index 49dc06888161f..0000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_flyout.js +++ /dev/null @@ -1,182 +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 PropTypes from 'prop-types'; -import React, { Component } from 'react'; - -import { - EuiButton, - EuiButtonEmpty, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; - -import { loadFullJob } from '../utils'; -import { mlCreateWatchService } from './create_watch_service'; -import { CreateWatch } from './create_watch_view'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { withKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; - -function getSuccessToast(id, url) { - return { - title: i18n.translate( - 'xpack.ml.jobsList.createWatchFlyout.watchCreatedSuccessfullyNotificationMessage', - { - defaultMessage: 'Watch {id} created successfully', - values: { id }, - } - ), - text: ( - - - - - {i18n.translate('xpack.ml.jobsList.createWatchFlyout.editWatchButtonLabel', { - defaultMessage: 'Edit watch', - })} - - - - - ), - }; -} - -export class CreateWatchFlyoutUI extends Component { - constructor(props) { - super(props); - - this.state = { - jobId: null, - bucketSpan: null, - }; - } - - componentDidMount() { - if (typeof this.props.setShowFunction === 'function') { - this.props.setShowFunction(this.showFlyout); - } - } - - componentWillUnmount() { - if (typeof this.props.unsetShowFunction === 'function') { - this.props.unsetShowFunction(); - } - } - - closeFlyout = (watchCreated = false) => { - this.setState({ isFlyoutVisible: false }, () => { - if (typeof this.props.flyoutHidden === 'function') { - this.props.flyoutHidden(watchCreated); - } - }); - }; - - showFlyout = (jobId) => { - loadFullJob(jobId) - .then((job) => { - const bucketSpan = job.analysis_config.bucket_span; - mlCreateWatchService.config.includeInfluencers = job.analysis_config.influencers.length > 0; - - this.setState({ - job, - jobId, - bucketSpan, - isFlyoutVisible: true, - }); - }) - .catch((error) => { - console.error(error); - }); - }; - - save = () => { - const { toasts } = this.props.kibana.services.notifications; - mlCreateWatchService - .createNewWatch(this.state.jobId) - .then((resp) => { - toasts.addSuccess(getSuccessToast(resp.id, resp.url)); - this.closeFlyout(true); - }) - .catch((error) => { - toasts.addDanger( - i18n.translate( - 'xpack.ml.jobsList.createWatchFlyout.watchNotSavedErrorNotificationMessage', - { - defaultMessage: 'Could not save watch', - } - ) - ); - console.error(error); - }); - }; - - render() { - const { jobId, bucketSpan } = this.state; - - let flyout; - - if (this.state.isFlyoutVisible) { - flyout = ( - - - -

- -

-
-
- - - - - - - - - - - - - - - - - -
- ); - } - return
{flyout}
; - } -} -CreateWatchFlyoutUI.propTypes = { - setShowFunction: PropTypes.func.isRequired, - unsetShowFunction: PropTypes.func.isRequired, - flyoutHidden: PropTypes.func, -}; - -export const CreateWatchFlyout = withKibana(CreateWatchFlyoutUI); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js deleted file mode 100644 index cd81355b3f97e..0000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js +++ /dev/null @@ -1,199 +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 { template } from 'lodash'; -import { http } from '../../../../services/http_service'; - -import emailBody from './email.html'; -import emailInfluencersBody from './email_influencers.html'; -import { DEFAULT_WATCH_SEVERITY } from './select_severity'; -import { watch } from './watch.js'; -import { i18n } from '@kbn/i18n'; -import { getBasePath, getApplication } from '../../../../util/dependency_cache'; - -const compiledEmailBody = template(emailBody); -const compiledEmailInfluencersBody = template(emailInfluencersBody); - -const emailSection = { - send_email: { - throttle_period_in_millis: 900000, // 15m - email: { - profile: 'standard', - to: [], - subject: i18n.translate('xpack.ml.newJob.simple.watcher.email.mlWatcherAlertSubjectTitle', { - defaultMessage: 'ML Watcher Alert', - }), - body: { - html: '', - }, - }, - }, -}; - -// generate a random number between min and max -function randomNumber(min, max) { - return Math.floor(Math.random() * (max - min + 1) + min); -} - -function saveWatch(watchModel) { - const path = `/api/watcher/watch/${watchModel.id}`; - - return http({ - path, - method: 'PUT', - body: JSON.stringify(watchModel.upstreamJSON), - }); -} - -class CreateWatchService { - constructor() { - this.config = {}; - - this.STATUS = { - SAVE_FAILED: -1, - SAVING: 0, - SAVED: 1, - }; - - this.status = { - realtimeJob: null, - watch: null, - }; - } - - reset() { - this.status.realtimeJob = null; - this.status.watch = null; - - this.config.id = ''; - this.config.includeEmail = false; - this.config.email = ''; - this.config.interval = '20m'; - this.config.watcherEditURL = ''; - this.config.includeInfluencers = false; - - // Current implementation means that default needs to match that of the select severity control. - const { display, val } = DEFAULT_WATCH_SEVERITY; - this.config.threshold = { display, val }; - } - - createNewWatch = function (jobId) { - return new Promise((resolve, reject) => { - this.status.watch = this.STATUS.SAVING; - if (jobId !== undefined) { - const id = `ml-${jobId}`; - this.config.id = id; - - // set specific properties of the the watch - watch.input.search.request.body.query.bool.filter[0].term.job_id = jobId; - watch.input.search.request.body.query.bool.filter[1].range.timestamp.gte = `now-${this.config.interval}`; - watch.input.search.request.body.aggs.bucket_results.filter.range.anomaly_score.gte = this.config.threshold.val; - - if (this.config.includeEmail && this.config.email !== '') { - const { getUrlForApp } = getApplication(); - const emails = this.config.email.split(','); - emailSection.send_email.email.to = emails; - - // create the html by adding the variables to the compiled email body. - emailSection.send_email.email.body.html = compiledEmailBody({ - serverAddress: getUrlForApp('ml', { absolute: true }), - influencersSection: - this.config.includeInfluencers === true - ? compiledEmailInfluencersBody({ - topInfluencersLabel: i18n.translate( - 'xpack.ml.newJob.simple.watcher.email.topInfluencersLabel', - { - defaultMessage: 'Top influencers:', - } - ), - }) - : '', - elasticStackMachineLearningAlertLabel: i18n.translate( - 'xpack.ml.newJob.simple.watcher.email.elasticStackMachineLearningAlertLabel', - { - defaultMessage: 'Elastic Stack Machine Learning Alert', - } - ), - jobLabel: i18n.translate('xpack.ml.newJob.simple.watcher.email.jobLabel', { - defaultMessage: 'Job', - }), - timeLabel: i18n.translate('xpack.ml.newJob.simple.watcher.email.timeLabel', { - defaultMessage: 'Time', - }), - anomalyScoreLabel: i18n.translate( - 'xpack.ml.newJob.simple.watcher.email.anomalyScoreLabel', - { - defaultMessage: 'Anomaly score', - } - ), - openInAnomalyExplorerLinkText: i18n.translate( - 'xpack.ml.newJob.simple.watcher.email.openInAnomalyExplorerLinkText', - { - defaultMessage: 'Click here to open in Anomaly Explorer.', - } - ), - topRecordsLabel: i18n.translate( - 'xpack.ml.newJob.simple.watcher.email.topRecordsLabel', - { defaultMessage: 'Top records:' } - ), - }); - - // add email section to watch - watch.actions.send_email = emailSection.send_email; - } - - // set the trigger interval to be a random number between 60 and 120 seconds - // this is to avoid all watches firing at once if the server restarts - // and the watches synchronize - const triggerInterval = randomNumber(60, 120); - watch.trigger.schedule.interval = `${triggerInterval}s`; - - const watchModel = { - id, - upstreamJSON: { - id, - type: 'json', - isNew: false, // Set to false, as we want to allow watches to be overwritten. - isActive: true, - watch, - }, - }; - - const basePath = getBasePath(); - if (id !== '') { - saveWatch(watchModel) - .then(() => { - this.status.watch = this.STATUS.SAVED; - this.config.watcherEditURL = `${basePath.get()}/app/management/insightsAndAlerting/watcher/watches/watch/${id}/edit?_g=()`; - resolve({ - id, - url: this.config.watcherEditURL, - }); - }) - .catch((resp) => { - this.status.watch = this.STATUS.SAVE_FAILED; - reject(resp); - }); - } - } else { - this.status.watch = this.STATUS.SAVE_FAILED; - reject(); - } - }); - }; - - loadWatch(jobId) { - const id = `ml-${jobId}`; - const path = `/api/watcher/watch/${id}`; - return http({ - path, - method: 'GET', - }); - } -} - -export const mlCreateWatchService = new CreateWatchService(); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_view.js deleted file mode 100644 index 2997d56b68f06..0000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_view.js +++ /dev/null @@ -1,215 +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 PropTypes from 'prop-types'; -import React, { Component } from 'react'; - -import { EuiCheckbox, EuiFieldText, EuiCallOut } from '@elastic/eui'; - -import { has } from 'lodash'; - -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { parseInterval } from '../../../../../../common/util/parse_interval'; -import { ml } from '../../../../services/ml_api_service'; -import { SelectSeverity } from './select_severity'; -import { mlCreateWatchService } from './create_watch_service'; -const STATUS = mlCreateWatchService.STATUS; - -export class CreateWatch extends Component { - static propTypes = { - jobId: PropTypes.string.isRequired, - bucketSpan: PropTypes.string.isRequired, - }; - - constructor(props) { - super(props); - mlCreateWatchService.reset(); - this.config = mlCreateWatchService.config; - - this.state = { - jobId: this.props.jobId, - bucketSpan: this.props.bucketSpan, - interval: this.config.interval, - threshold: this.config.threshold, - includeEmail: this.config.emailIncluded, - email: this.config.email, - emailEnabled: false, - status: null, - watchAlreadyExists: false, - }; - } - - componentDidMount() { - // make the interval 2 times the bucket span - if (this.state.bucketSpan) { - const intervalObject = parseInterval(this.state.bucketSpan); - let bs = intervalObject.asMinutes() * 2; - if (bs < 1) { - bs = 1; - } - - const interval = `${bs}m`; - this.setState({ interval }, () => { - this.config.interval = interval; - }); - } - - // load elasticsearch settings to see if email has been configured - ml.getNotificationSettings().then((resp) => { - if (has(resp, 'defaults.xpack.notification.email')) { - this.setState({ emailEnabled: true }); - } - }); - - mlCreateWatchService - .loadWatch(this.state.jobId) - .then(() => { - this.setState({ watchAlreadyExists: true }); - }) - .catch(() => { - this.setState({ watchAlreadyExists: false }); - }); - } - - onThresholdChange = (threshold) => { - this.setState({ threshold }, () => { - this.config.threshold = threshold; - }); - }; - - onIntervalChange = (e) => { - const interval = e.target.value; - this.setState({ interval }, () => { - this.config.interval = interval; - }); - }; - - onIncludeEmailChanged = (e) => { - const includeEmail = e.target.checked; - this.setState({ includeEmail }, () => { - this.config.includeEmail = includeEmail; - }); - }; - - onEmailChange = (e) => { - const email = e.target.value; - this.setState({ email }, () => { - this.config.email = email; - }); - }; - - render() { - const { status } = this.state; - - if (status === null || status === STATUS.SAVING || status === STATUS.SAVE_FAILED) { - return ( -
-
-
-
- -
- - ), - }} - /> -
- -
-
- -
-
- -
-
-
- {this.state.emailEnabled && ( -
- - } - checked={this.state.includeEmail} - onChange={this.onIncludeEmailChanged} - /> - {this.state.includeEmail && ( -
- -
- )} -
- )} - {this.state.watchAlreadyExists && ( - - } - /> - )} -
- ); - } else if (status === STATUS.SAVED) { - return ( -
- -
- ); - } else { - return
; - } - } -} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email.html b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email.html deleted file mode 100644 index 713a68ba0c036..0000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - <%= elasticStackMachineLearningAlertLabel %> - -
-
- - - <%= jobLabel %> - : {{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0._source.job_id}} -
- - - <%= timeLabel %> - : {{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0.fields.timestamp_iso8601.0}} -
- - - <%= anomalyScoreLabel %> - : {{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0.fields.score.0}} -
-
- - - <%= openInAnomalyExplorerLinkText %> - -
-
- - <%= influencersSection %> - - - <%= topRecordsLabel %> - -
- {{#ctx.payload.aggregations.record_results.top_record_hits.hits.hits}} - {{_source.function}}({{_source.field_name}}) {{_source.by_field_value}} {{_source.over_field_value}} {{_source.partition_field_value}} [{{fields.score.0}}] -
- {{/ctx.payload.aggregations.record_results.top_record_hits.hits.hits}} - - - diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email_influencers.html b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email_influencers.html deleted file mode 100644 index ab22ef672e97b..0000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email_influencers.html +++ /dev/null @@ -1,9 +0,0 @@ - - <%= topInfluencersLabel %> - -
- {{#ctx.payload.aggregations.influencer_results.top_influencer_hits.hits.hits}} - {{_source.influencer_field_name}} = {{_source.influencer_field_value}} [{{fields.score.0}}] -
- {{/ctx.payload.aggregations.influencer_results.top_influencer_hits.hits.hits}} -
diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/index.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/index.js deleted file mode 100644 index 0658867183280..0000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/index.js +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { CreateWatchFlyout } from './create_watch_flyout'; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/select_severity.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/select_severity.tsx deleted file mode 100644 index 347e25816672b..0000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/select_severity.tsx +++ /dev/null @@ -1,134 +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. - */ - -/* - * React component for rendering a select element with threshold levels. - * This is basically a copy of SelectSeverity in public/application/components/controls/select_severity - * but which stores its state internally rather than in the appState - */ -import React, { Fragment, FC, useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; - -import { getSeverityColor } from '../../../../../../common/util/anomaly_utils'; - -const warningLabel = i18n.translate('xpack.ml.controls.selectSeverity.warningLabel', { - defaultMessage: 'warning', -}); -const minorLabel = i18n.translate('xpack.ml.controls.selectSeverity.minorLabel', { - defaultMessage: 'minor', -}); -const majorLabel = i18n.translate('xpack.ml.controls.selectSeverity.majorLabel', { - defaultMessage: 'major', -}); -const criticalLabel = i18n.translate('xpack.ml.controls.selectSeverity.criticalLabel', { - defaultMessage: 'critical', -}); - -const optionsMap = { - [warningLabel]: 0, - [minorLabel]: 25, - [majorLabel]: 50, - [criticalLabel]: 75, -}; - -interface TableSeverity { - val: number; - display: string; - color: string; -} - -export const SEVERITY_OPTIONS: TableSeverity[] = [ - { - val: 0, - display: warningLabel, - color: getSeverityColor(0), - }, - { - val: 25, - display: minorLabel, - color: getSeverityColor(25), - }, - { - val: 50, - display: majorLabel, - color: getSeverityColor(50), - }, - { - val: 75, - display: criticalLabel, - color: getSeverityColor(75), - }, -]; - -function optionValueToThreshold(value: number) { - // Get corresponding threshold object with required display and val properties from the specified value. - let threshold = SEVERITY_OPTIONS.find((opt) => opt.val === value); - - // Default to warning if supplied value doesn't map to one of the options. - if (threshold === undefined) { - threshold = SEVERITY_OPTIONS[0]; - } - - return threshold; -} - -export const DEFAULT_WATCH_SEVERITY = SEVERITY_OPTIONS[3]; - -const getSeverityOptions = () => - SEVERITY_OPTIONS.map(({ color, display, val }) => ({ - value: display, - inputDisplay: ( - - - {display} - - - ), - dropdownDisplay: ( - - - {display} - - - -

- -

-
-
- ), - })); - -interface Props { - onChange: (sev: TableSeverity) => void; -} - -export const SelectSeverity: FC = ({ onChange }) => { - const [severity, setSeverity] = useState(DEFAULT_WATCH_SEVERITY); - - const onSeverityChange = (valueDisplay: string) => { - const option = optionValueToThreshold(optionsMap[valueDisplay]); - setSeverity(option); - onChange(option); - }; - - return ( - - ); -}; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/watch.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/watch.js deleted file mode 100644 index 2fcde2184bf06..0000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/watch.js +++ /dev/null @@ -1,232 +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 { ML_RESULTS_INDEX_PATTERN } from '../../../../../../common/constants/index_patterns'; - -export const watch = { - trigger: { - schedule: { - interval: '60s', - }, - }, - input: { - search: { - request: { - search_type: 'query_then_fetch', - indices: [ML_RESULTS_INDEX_PATTERN], - body: { - size: 0, - query: { - bool: { - filter: [ - { - term: { - job_id: null, - }, - }, - { - range: { - timestamp: { - gte: null, - }, - }, - }, - { - terms: { - result_type: ['bucket', 'record', 'influencer'], - }, - }, - ], - }, - }, - aggs: { - bucket_results: { - filter: { - range: { - anomaly_score: { - gte: null, - }, - }, - }, - aggs: { - top_bucket_hits: { - top_hits: { - sort: [ - { - anomaly_score: { - order: 'desc', - }, - }, - ], - _source: { - includes: [ - 'job_id', - 'result_type', - 'timestamp', - 'anomaly_score', - 'is_interim', - ], - }, - size: 1, - script_fields: { - start: { - script: { - lang: 'painless', - source: `LocalDateTime.ofEpochSecond((doc["timestamp"].value.getMillis()-((doc["bucket_span"].value * 1000) - * params.padding)) / 1000, 0, ZoneOffset.UTC).toString()+\":00.000Z\"`, - params: { - padding: 10, - }, - }, - }, - end: { - script: { - lang: 'painless', - source: `LocalDateTime.ofEpochSecond((doc["timestamp"].value.getMillis()+((doc["bucket_span"].value * 1000) - * params.padding)) / 1000, 0, ZoneOffset.UTC).toString()+\":00.000Z\"`, - params: { - padding: 10, - }, - }, - }, - timestamp_epoch: { - script: { - lang: 'painless', - source: 'doc["timestamp"].value.getMillis()/1000', - }, - }, - timestamp_iso8601: { - script: { - lang: 'painless', - source: 'doc["timestamp"].value', - }, - }, - score: { - script: { - lang: 'painless', - source: 'Math.round(doc["anomaly_score"].value)', - }, - }, - }, - }, - }, - }, - }, - influencer_results: { - filter: { - range: { - influencer_score: { - gte: 3, - }, - }, - }, - aggs: { - top_influencer_hits: { - top_hits: { - sort: [ - { - influencer_score: { - order: 'desc', - }, - }, - ], - _source: { - includes: [ - 'result_type', - 'timestamp', - 'influencer_field_name', - 'influencer_field_value', - 'influencer_score', - 'isInterim', - ], - }, - size: 3, - script_fields: { - score: { - script: { - lang: 'painless', - source: 'Math.round(doc["influencer_score"].value)', - }, - }, - }, - }, - }, - }, - }, - record_results: { - filter: { - range: { - record_score: { - gte: 3, - }, - }, - }, - aggs: { - top_record_hits: { - top_hits: { - sort: [ - { - record_score: { - order: 'desc', - }, - }, - ], - _source: { - includes: [ - 'result_type', - 'timestamp', - 'record_score', - 'is_interim', - 'function', - 'field_name', - 'by_field_value', - 'over_field_value', - 'partition_field_value', - ], - }, - size: 3, - script_fields: { - score: { - script: { - lang: 'painless', - source: 'Math.round(doc["record_score"].value)', - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - condition: { - compare: { - 'ctx.payload.aggregations.bucket_results.doc_count': { - gt: 0, - }, - }, - }, - actions: { - log: { - logging: { - level: 'info', - text: '', // this gets populated below. - }, - }, - }, -}; - -// Add logging text. Broken over a few lines due to its length. -let txt = - 'Alert for job [{{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0._source.job_id}}] at '; -txt += - '[{{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0.fields.timestamp_iso8601.0}}] score '; -txt += '[{{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0.fields.score.0}}]'; -watch.actions.log.logging.text = txt; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js index 8f955e771327e..471295938acde 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js @@ -17,7 +17,8 @@ export function actionsMenuContent( showEditJobFlyout, showDeleteJobModal, showStartDatafeedModal, - refreshJobs + refreshJobs, + showCreateAlertFlyout ) { const canCreateJob = checkPermission('canCreateJob') && mlNodesAvailable(); const canUpdateJob = checkPermission('canUpdateJob'); @@ -25,6 +26,7 @@ export function actionsMenuContent( const canUpdateDatafeed = checkPermission('canUpdateDatafeed'); const canStartStopDatafeed = checkPermission('canStartStopDatafeed') && mlNodesAvailable(); const canCloseJob = checkPermission('canCloseJob') && mlNodesAvailable(); + const canCreateMlAlerts = checkPermission('canCreateMlAlerts'); return [ { @@ -59,6 +61,22 @@ export function actionsMenuContent( }, 'data-test-subj': 'mlActionButtonStopDatafeed', }, + { + name: i18n.translate('xpack.ml.jobsList.managementActions.createAlertLabel', { + defaultMessage: 'Create alert', + }), + description: i18n.translate('xpack.ml.jobsList.managementActions.createAlertLabel', { + defaultMessage: 'Create alert', + }), + icon: 'bell', + enabled: (item) => item.deleting !== true, + available: () => canCreateMlAlerts, + onClick: (item) => { + showCreateAlertFlyout([item.id]); + closeMenu(true); + }, + 'data-test-subj': 'mlActionButtonCreateAlert', + }, { name: i18n.translate('xpack.ml.jobsList.managementActions.closeJobLabel', { defaultMessage: 'Close job', diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index 261c58bebaaa8..4674342990df4 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -116,8 +116,8 @@ export class JobsList extends Component { onSelectionChange: this.props.selectJobChange, }; // Adding 'width' props to columns for use in the Kibana management jobs list table - // The version of the table used in ML > Job Managment depends on many EUI class overrides that set the width explicitly. - // The ML > Job Managment table won't change as the overwritten class styles take precedence, though these values may need to + // The version of the table used in ML > Job Management depends on many EUI class overrides that set the width explicitly. + // The ML > Job Management table won't change as the overwritten class styles take precedence, though these values may need to // be updated if we move to always using props for width. const columns = [ { @@ -299,7 +299,8 @@ export class JobsList extends Component { this.props.showEditJobFlyout, this.props.showDeleteJobModal, this.props.showStartDatafeedModal, - this.props.refreshJobs + this.props.refreshJobs, + this.props.showCreateAlertFlyout ), }); } @@ -371,6 +372,7 @@ JobsList.propTypes = { showEditJobFlyout: PropTypes.func, showDeleteJobModal: PropTypes.func, showStartDatafeedModal: PropTypes.func, + showCreateAlertFlyout: PropTypes.func, refreshJobs: PropTypes.func, selectedJobsCount: PropTypes.number.isRequired, loading: PropTypes.bool, diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js index 352bd839ba1f4..ac7224b3f3164 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js @@ -28,7 +28,6 @@ import { JobFilterBar } from '../job_filter_bar'; import { EditJobFlyout } from '../edit_job_flyout'; import { DeleteJobModal } from '../delete_job_modal'; import { StartDatafeedModal } from '../start_datafeed_modal'; -import { CreateWatchFlyout } from '../create_watch_flyout'; import { MultiJobActions } from '../multi_job_actions'; import { NewJobButton } from '../new_job_button'; import { JobStatsBar } from '../jobs_stats_bar'; @@ -40,6 +39,7 @@ import { UpgradeWarning } from '../../../../components/upgrade'; import { RefreshJobsListButton } from '../refresh_jobs_list_button'; import { DELETING_JOBS_REFRESH_INTERVAL_MS } from '../../../../../../common/constants/jobs_list'; +import { JobListMlAnomalyAlertFlyout } from '../../../../../alerting/ml_alerting_flyout'; let deletingJobsRefreshTimeout = null; @@ -66,7 +66,7 @@ export class JobsListView extends Component { this.showEditJobFlyout = () => {}; this.showDeleteJobModal = () => {}; this.showStartDatafeedModal = () => {}; - this.showCreateWatchFlyout = () => {}; + this.showCreateAlertFlyout = () => {}; // work around to keep track of whether the component is mounted // used to block timeouts for results polling // which can run after unmounting @@ -205,14 +205,14 @@ export class JobsListView extends Component { this.showStartDatafeedModal = () => {}; }; - setShowCreateWatchFlyoutFunction = (func) => { - this.showCreateWatchFlyout = func; + setShowCreateAlertFlyoutFunction = (func) => { + this.showCreateAlertFlyout = func; }; - unsetShowCreateWatchFlyoutFunction = () => { - this.showCreateWatchFlyout = () => {}; + unsetShowCreateAlertFlyoutFunction = () => { + this.showCreateAlertFlyout = () => {}; }; - getShowCreateWatchFlyoutFunction = () => { - return this.showCreateWatchFlyout; + getShowCreateAlertFlyoutFunction = () => { + return this.showCreateAlertFlyout; }; selectJobChange = (selectedJobs) => { @@ -477,6 +477,7 @@ export class JobsListView extends Component { allJobIds={jobIds} showStartDatafeedModal={this.showStartDatafeedModal} showDeleteJobModal={this.showDeleteJobModal} + showCreateAlertFlyout={this.showCreateAlertFlyout} refreshJobs={() => this.refreshJobSummaryList(true)} /> this.refreshJobSummaryList(true)} /> -
diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js index 5760fbeb38642..e1314eb718836 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js @@ -27,6 +27,7 @@ class MultiJobActionsMenuUI extends Component { this.canDeleteJob = checkPermission('canDeleteJob'); this.canStartStopDatafeed = checkPermission('canStartStopDatafeed') && mlNodesAvailable(); this.canCloseJob = checkPermission('canCloseJob') && mlNodesAvailable(); + this.canCreateMlAlerts = checkPermission('canCreateMlAlerts'); } onButtonClick = () => { @@ -144,6 +145,26 @@ class MultiJobActionsMenuUI extends Component { ); } + if (this.canCreateMlAlerts) { + items.push( + { + this.props.showCreateAlertFlyout(this.props.jobs.map(({ id }) => id)); + this.closePopover(); + }} + data-test-subj="mlADJobListMultiSelectCreateAlertActionButton" + > + + + ); + } + return ( )} @@ -67,4 +68,5 @@ MultiJobActions.propTypes = { showStartDatafeedModal: PropTypes.func.isRequired, showDeleteJobModal: PropTypes.func.isRequired, refreshJobs: PropTypes.func.isRequired, + showCreateAlertFlyout: PropTypes.func.isRequired, }; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/start_datafeed_modal.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/start_datafeed_modal.js index 3ac6455bd745f..5f5759e49208c 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/start_datafeed_modal.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/start_datafeed_modal.js @@ -39,8 +39,8 @@ export class StartDatafeedModal extends Component { isModalVisible: false, startTime: now, endTime: now, - createWatch: false, - allowCreateWatch: false, + createAlert: false, + allowCreateAlert: false, initialSpecifiedStartTime: now, now, timeRangeValid: true, @@ -48,7 +48,7 @@ export class StartDatafeedModal extends Component { this.initialSpecifiedStartTime = now; this.refreshJobs = this.props.refreshJobs; - this.getShowCreateWatchFlyoutFunction = this.props.getShowCreateWatchFlyoutFunction; + this.getShowCreateAlertFlyoutFunction = this.props.getShowCreateAlertFlyoutFunction; } componentDidMount() { @@ -71,8 +71,8 @@ export class StartDatafeedModal extends Component { this.setState({ endTime: time }); }; - setCreateWatch = (e) => { - this.setState({ createWatch: e.target.checked }); + setCreateAlert = (e) => { + this.setState({ createAlert: e.target.checked }); }; closeModal = () => { @@ -83,21 +83,21 @@ export class StartDatafeedModal extends Component { this.setState({ timeRangeValid }); }; - showModal = (jobs, showCreateWatchFlyout) => { + showModal = (jobs, showCreateAlertFlyout) => { const startTime = undefined; const now = moment(); const endTime = now; const initialSpecifiedStartTime = getLowestLatestTime(jobs); - const allowCreateWatch = jobs.length === 1; + const allowCreateAlert = jobs.length > 0; this.setState({ jobs, isModalVisible: true, startTime, endTime, initialSpecifiedStartTime, - showCreateWatchFlyout, - allowCreateWatch, - createWatch: false, + showCreateAlertFlyout, + allowCreateAlert, + createAlert: false, now, }); }; @@ -112,9 +112,8 @@ export class StartDatafeedModal extends Component { : this.state.endTime; forceStartDatafeeds(jobs, start, end, () => { - if (this.state.createWatch && jobs.length === 1) { - const jobId = jobs[0].id; - this.getShowCreateWatchFlyoutFunction()(jobId); + if (this.state.createAlert && jobs.length > 0) { + this.getShowCreateAlertFlyoutFunction()(jobs.map((job) => job.id)); } this.refreshJobs(); }); @@ -127,7 +126,7 @@ export class StartDatafeedModal extends Component { initialSpecifiedStartTime, startTime, endTime, - createWatch, + createAlert, now, timeRangeValid, } = this.state; @@ -172,15 +171,15 @@ export class StartDatafeedModal extends Component {
} - checked={createWatch} - onChange={this.setCreateWatch} + checked={createAlert} + onChange={this.setCreateAlert} />
)} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/post_save_options/post_save_options.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/post_save_options/post_save_options.tsx index a39ffd171d1ca..6cefc239905c7 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/post_save_options/post_save_options.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/post_save_options/post_save_options.tsx @@ -13,38 +13,21 @@ import { JobRunner } from '../../../../../common/job_runner'; import { useMlKibana } from '../../../../../../../contexts/kibana'; import { extractErrorMessage } from '../../../../../../../../../common/util/errors'; -// @ts-ignore -import { CreateWatchFlyout } from '../../../../../../jobs_list/components/create_watch_flyout/index'; import { JobCreatorContext } from '../../../job_creator_context'; import { DATAFEED_STATE } from '../../../../../../../../../common/constants/states'; +import { MlAnomalyAlertFlyout } from '../../../../../../../../alerting/ml_alerting_flyout'; interface Props { jobRunner: JobRunner | null; } -type ShowFlyout = (jobId: string) => void; - export const PostSaveOptions: FC = ({ jobRunner }) => { const { services: { notifications }, } = useMlKibana(); const { jobCreator } = useContext(JobCreatorContext); const [datafeedState, setDatafeedState] = useState(DATAFEED_STATE.STOPPED); - const [watchFlyoutVisible, setWatchFlyoutVisible] = useState(false); - const [watchCreated, setWatchCreated] = useState(false); - - function setShowCreateWatchFlyoutFunction(showFlyout: ShowFlyout) { - showFlyout(jobCreator.jobId); - } - - function flyoutHidden(jobCreated: boolean) { - setWatchFlyoutVisible(false); - setWatchCreated(jobCreated); - } - - function unsetShowCreateWatchFlyoutFunction() { - setWatchFlyoutVisible(false); - } + const [alertFlyoutVisible, setAlertFlyoutVisible] = useState(false); async function startJobInRealTime() { const { toasts } = notifications; @@ -93,28 +76,26 @@ export const PostSaveOptions: FC = ({ jobRunner }) => { />
+ setWatchFlyoutVisible(true)} - data-test-subj="mlJobWizardButtonCreateWatch" + onClick={setAlertFlyoutVisible.bind(null, true)} + data-test-subj="mlJobWizardButtonCreateAlert" > - {datafeedState === DATAFEED_STATE.STARTED && watchFlyoutVisible && ( - )} diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index b4eb5a6d702b7..212d6fe13a6b4 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -62,7 +62,7 @@ export interface MlStartDependencies { embeddable: EmbeddableStart; maps?: MapsStartApi; lens?: LensPublicStart; - triggersActionsUi: TriggersAndActionsUIPublicPluginStart; + triggersActionsUi?: TriggersAndActionsUIPublicPluginStart; } export interface MlSetupDependencies { @@ -76,7 +76,7 @@ export interface MlSetupDependencies { kibanaVersion: string; share: SharePluginSetup; indexPatternManagement: IndexPatternManagementSetup; - triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; + triggersActionsUi?: TriggersAndActionsUIPublicPluginSetup; } export type MlCoreSetup = CoreSetup; @@ -129,6 +129,10 @@ export class MlPlugin implements Plugin { this.urlGenerator = registerUrlGenerator(pluginsSetup.share, core); } + if (pluginsSetup.triggersActionsUi) { + registerMlAlerts(pluginsSetup.triggersActionsUi); + } + const licensing = pluginsSetup.licensing.license$.pipe(take(1)); licensing.subscribe(async (license) => { const [coreStart] = await core.getStartServices(); @@ -190,7 +194,7 @@ export class MlPlugin implements Plugin { http: core.http, i18n: core.i18n, }); - registerMlAlerts(deps.triggersActionsUi.alertTypeRegistry); + return { urlGenerator: this.urlGenerator, }; diff --git a/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts b/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts index 3b83e6d005077..5ef883cc50fbb 100644 --- a/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts +++ b/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts @@ -309,6 +309,13 @@ export function alertingServiceProvider(mlClient: MlClient) { result_type: Object.values(ANOMALY_RESULT_TYPE), }, }, + ...(params.includeInterim + ? [] + : [ + { + term: { is_interim: false }, + }, + ]), ], }, }, diff --git a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts index 8bfa825baacd9..49a63d2796969 100644 --- a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts +++ b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts @@ -51,7 +51,7 @@ describe('check_capabilities', () => { ); const { capabilities } = await getCapabilities(); const count = Object.keys(capabilities).length; - expect(count).toBe(28); + expect(count).toBe(29); }); }); @@ -102,6 +102,7 @@ describe('check_capabilities', () => { expect(capabilities.canDeleteDataFrameAnalytics).toBe(false); expect(capabilities.canCreateDataFrameAnalytics).toBe(false); expect(capabilities.canStartStopDataFrameAnalytics).toBe(false); + expect(capabilities.canCreateMlAlerts).toBe(false); }); test('full capabilities', async () => { diff --git a/x-pack/plugins/ml/server/routes/alerting.ts b/x-pack/plugins/ml/server/routes/alerting.ts index b7a1be2434e8b..7b7f3a7db9723 100644 --- a/x-pack/plugins/ml/server/routes/alerting.ts +++ b/x-pack/plugins/ml/server/routes/alerting.ts @@ -17,6 +17,8 @@ export function alertingRoutes({ router, routeGuard }: RouteInitialization) { * @api {post} /api/ml/alerting/preview Preview alerting condition * @apiName PreviewAlert * @apiDescription Returns a preview of the alerting condition + * + * @apiSchema (body) mlAnomalyDetectionAlertPreviewRequest */ router.post( { diff --git a/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts b/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts index 636185808f9a5..9e13b7ed81a15 100644 --- a/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts @@ -27,6 +27,7 @@ export const mlAnomalyDetectionAlertParams = schema.object({ ), severity: schema.number(), resultType: schema.string(), + includeInterim: schema.boolean({ defaultValue: true }), }); export const mlAnomalyDetectionAlertPreviewRequest = schema.object({ diff --git a/x-pack/plugins/security_solution/common/machine_learning/empty_ml_capabilities.ts b/x-pack/plugins/security_solution/common/machine_learning/empty_ml_capabilities.ts index e966e3fb714ad..54c2beaa06b09 100644 --- a/x-pack/plugins/security_solution/common/machine_learning/empty_ml_capabilities.ts +++ b/x-pack/plugins/security_solution/common/machine_learning/empty_ml_capabilities.ts @@ -37,6 +37,7 @@ export const emptyMlCapabilities: MlCapabilitiesResponse = { canDeleteDataFrameAnalytics: false, canCreateDataFrameAnalytics: false, canStartStopDataFrameAnalytics: false, + canCreateMlAlerts: false, }, isPlatinumOrTrialLicense: false, mlFeatureEnabledInSpace: false, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e24db7d2cf9c3..e65bf0a8c83c6 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13288,12 +13288,6 @@ "xpack.ml.jobsList.closeJobErrorMessage": "ジョブをクローズできませんでした", "xpack.ml.jobsList.collapseJobDetailsAriaLabel": "{itemId} の詳細を非表示", "xpack.ml.jobsList.createNewJobButtonLabel": "ジョブを作成", - "xpack.ml.jobsList.createWatchFlyout.closeButtonLabel": "閉じる", - "xpack.ml.jobsList.createWatchFlyout.editWatchButtonLabel": "ウォッチを編集", - "xpack.ml.jobsList.createWatchFlyout.pageTitle": "{jobId} のウォッチを作成", - "xpack.ml.jobsList.createWatchFlyout.saveButtonLabel": "保存", - "xpack.ml.jobsList.createWatchFlyout.watchCreatedSuccessfullyNotificationMessage": "ウォッチ {id} が作成されました", - "xpack.ml.jobsList.createWatchFlyout.watchNotSavedErrorNotificationMessage": "ウォッチを保存できませんでした", "xpack.ml.jobsList.datafeedStateLabel": "データフィード状態", "xpack.ml.jobsList.deleteActionStatusText": "削除", "xpack.ml.jobsList.deletedActionStatusText": "削除されました", @@ -13443,7 +13437,6 @@ "xpack.ml.jobsList.startDatafeedModal.continueFromNowLabel": "今から続行", "xpack.ml.jobsList.startDatafeedModal.continueFromSpecifiedTimeLabel": "特定の時刻から続行", "xpack.ml.jobsList.startDatafeedModal.continueFromStartTimeLabel": "{formattedLatestStartTime} から続行", - "xpack.ml.jobsList.startDatafeedModal.createWatchDescription": "データフィードの開始後ウォッチを作成します", "xpack.ml.jobsList.startDatafeedModal.enterDateText\"": "日付を入力", "xpack.ml.jobsList.startDatafeedModal.noEndTimeLabel": "終了時刻が指定されていません (リアルタイム検索)", "xpack.ml.jobsList.startDatafeedModal.searchEndTimeTitle": "検索終了時刻", @@ -13657,23 +13650,7 @@ "xpack.ml.newJob.recognize.viewResultsAriaLabel": "結果を表示", "xpack.ml.newJob.recognize.viewResultsLinkText": "結果を表示", "xpack.ml.newJob.recognize.visualizationsLabel": "ビジュアライゼーション", - "xpack.ml.newJob.simple.createWatchView.emailAddressPlaceholder": "メールアドレス", - "xpack.ml.newJob.simple.createWatchView.nowLabel": "今 - {selectInterval}", - "xpack.ml.newJob.simple.createWatchView.sendEmailLabel": "メールを送信", - "xpack.ml.newJob.simple.createWatchView.severityThresholdLabel": "深刻度のしきい値", - "xpack.ml.newJob.simple.createWatchView.successLabel": "成功", - "xpack.ml.newJob.simple.createWatchView.timeRangeLabel": "時間範囲", - "xpack.ml.newJob.simple.createWatchView.watchAlreadyExistsWarningMessage": "警告、ウォッチ mi-{jobId} は既に存在します。適用をクリックするとオリジナルが上書きされます。", - "xpack.ml.newJob.simple.createWatchView.watchEmailAddressAriaLabel": "ウォッチのメールアドレス", "xpack.ml.newJob.simple.recognize.jobsCreationFailedTitle": "ジョブの作成に失敗", - "xpack.ml.newJob.simple.watcher.email.anomalyScoreLabel": "異常スコア", - "xpack.ml.newJob.simple.watcher.email.elasticStackMachineLearningAlertLabel": "Elastic Stack 機械学習アラート", - "xpack.ml.newJob.simple.watcher.email.jobLabel": "ジョブ名", - "xpack.ml.newJob.simple.watcher.email.mlWatcherAlertSubjectTitle": "ML Watcher アラート", - "xpack.ml.newJob.simple.watcher.email.openInAnomalyExplorerLinkText": "異常エクスプローラーを開くにはここをクリックしてください。", - "xpack.ml.newJob.simple.watcher.email.timeLabel": "時間", - "xpack.ml.newJob.simple.watcher.email.topInfluencersLabel": "トップ影響因子:", - "xpack.ml.newJob.simple.watcher.email.topRecordsLabel": "トップの記録:", "xpack.ml.newJob.wizard.autoSetJobCreatorTimeRange.error": "インデックスの開始時刻と終了時刻の取得中にエラーが発生しました", "xpack.ml.newJob.wizard.categorizationAnalyzerFlyout.closeButton": "閉じる", "xpack.ml.newJob.wizard.categorizationAnalyzerFlyout.saveButton": "保存", @@ -13904,7 +13881,6 @@ "xpack.ml.newJob.wizard.summaryStep.jobDetails.splitField.title": "フィールドの分割", "xpack.ml.newJob.wizard.summaryStep.jobDetails.summaryCountField.title": "サマリーカウントフィールド", "xpack.ml.newJob.wizard.summaryStep.jobDetails.useDedicatedIndex.title": "専用インデックスを使用", - "xpack.ml.newJob.wizard.summaryStep.postSaveOptions.createWatch": "ウォッチを作成", "xpack.ml.newJob.wizard.summaryStep.postSaveOptions.startJobInRealTime": "リアルタイムで実行中のジョブを開始", "xpack.ml.newJob.wizard.summaryStep.postSaveOptions.startJobInRealTimeError": "ジョブの開始エラー", "xpack.ml.newJob.wizard.summaryStep.postSaveOptions.startJobInRealTimeSuccess": "ジョブ {jobId} が開始しました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 5d583971552b5..cdc394a237e3e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13320,12 +13320,6 @@ "xpack.ml.jobsList.closeJobErrorMessage": "作业无法关闭", "xpack.ml.jobsList.collapseJobDetailsAriaLabel": "隐藏 {itemId} 的详情", "xpack.ml.jobsList.createNewJobButtonLabel": "创建作业", - "xpack.ml.jobsList.createWatchFlyout.closeButtonLabel": "关闭", - "xpack.ml.jobsList.createWatchFlyout.editWatchButtonLabel": "编辑监视", - "xpack.ml.jobsList.createWatchFlyout.pageTitle": "创建 {jobId} 的监视", - "xpack.ml.jobsList.createWatchFlyout.saveButtonLabel": "保存", - "xpack.ml.jobsList.createWatchFlyout.watchCreatedSuccessfullyNotificationMessage": "监视 {id} 已成功创建", - "xpack.ml.jobsList.createWatchFlyout.watchNotSavedErrorNotificationMessage": "无法保存监视", "xpack.ml.jobsList.datafeedStateLabel": "数据馈送状态", "xpack.ml.jobsList.deleteActionStatusText": "删除", "xpack.ml.jobsList.deletedActionStatusText": "已删除", @@ -13475,7 +13469,6 @@ "xpack.ml.jobsList.startDatafeedModal.continueFromNowLabel": "从当前继续", "xpack.ml.jobsList.startDatafeedModal.continueFromSpecifiedTimeLabel": "从指定时间继续", "xpack.ml.jobsList.startDatafeedModal.continueFromStartTimeLabel": "从 {formattedLatestStartTime} 继续", - "xpack.ml.jobsList.startDatafeedModal.createWatchDescription": "在数据馈送开始后创建监视", "xpack.ml.jobsList.startDatafeedModal.enterDateText\"": "输入日期", "xpack.ml.jobsList.startDatafeedModal.noEndTimeLabel": "无结束时间(实时搜索)", "xpack.ml.jobsList.startDatafeedModal.searchEndTimeTitle": "搜索结束时间", @@ -13694,23 +13687,7 @@ "xpack.ml.newJob.recognize.viewResultsAriaLabel": "查看结果", "xpack.ml.newJob.recognize.viewResultsLinkText": "查看结果", "xpack.ml.newJob.recognize.visualizationsLabel": "可视化", - "xpack.ml.newJob.simple.createWatchView.emailAddressPlaceholder": "电子邮件地址", - "xpack.ml.newJob.simple.createWatchView.nowLabel": "立即 - {selectInterval}", - "xpack.ml.newJob.simple.createWatchView.sendEmailLabel": "发送电子邮件", - "xpack.ml.newJob.simple.createWatchView.severityThresholdLabel": "严重性阈值", - "xpack.ml.newJob.simple.createWatchView.successLabel": "成功", - "xpack.ml.newJob.simple.createWatchView.timeRangeLabel": "时间范围", - "xpack.ml.newJob.simple.createWatchView.watchAlreadyExistsWarningMessage": "警告,监视 ml-{jobId} 已存在,点击“应用”将覆盖原始监视。", - "xpack.ml.newJob.simple.createWatchView.watchEmailAddressAriaLabel": "监视电子邮件地址", "xpack.ml.newJob.simple.recognize.jobsCreationFailedTitle": "作业创建失败", - "xpack.ml.newJob.simple.watcher.email.anomalyScoreLabel": "异常分数", - "xpack.ml.newJob.simple.watcher.email.elasticStackMachineLearningAlertLabel": "Elastic Stack Machine Learning 告警", - "xpack.ml.newJob.simple.watcher.email.jobLabel": "作业", - "xpack.ml.newJob.simple.watcher.email.mlWatcherAlertSubjectTitle": "ML Watcher 告警", - "xpack.ml.newJob.simple.watcher.email.openInAnomalyExplorerLinkText": "单击此处在 Anomaly Explorer 中打开。", - "xpack.ml.newJob.simple.watcher.email.timeLabel": "时间", - "xpack.ml.newJob.simple.watcher.email.topInfluencersLabel": "排在前面的影响因素:", - "xpack.ml.newJob.simple.watcher.email.topRecordsLabel": "排在前面的记录:", "xpack.ml.newJob.wizard.autoSetJobCreatorTimeRange.error": "检索索引的开始和结束时间", "xpack.ml.newJob.wizard.categorizationAnalyzerFlyout.closeButton": "关闭", "xpack.ml.newJob.wizard.categorizationAnalyzerFlyout.saveButton": "保存", @@ -13941,7 +13918,6 @@ "xpack.ml.newJob.wizard.summaryStep.jobDetails.splitField.title": "分割字段", "xpack.ml.newJob.wizard.summaryStep.jobDetails.summaryCountField.title": "汇总计数字段", "xpack.ml.newJob.wizard.summaryStep.jobDetails.useDedicatedIndex.title": "使用专用索引", - "xpack.ml.newJob.wizard.summaryStep.postSaveOptions.createWatch": "创建监视", "xpack.ml.newJob.wizard.summaryStep.postSaveOptions.startJobInRealTime": "启动实时运行的作业", "xpack.ml.newJob.wizard.summaryStep.postSaveOptions.startJobInRealTimeError": "启动作业时出错", "xpack.ml.newJob.wizard.summaryStep.postSaveOptions.startJobInRealTimeSuccess": "作业 {jobId} 已启动", From 7fab0e63bc35348268386f37805b78b8b629a614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20G=C3=B3mez?= Date: Mon, 15 Feb 2021 22:46:38 +0100 Subject: [PATCH 17/23] [Logs UI] Replace custom `useInterval` with `react-use` version (#90966) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../infra/public/hooks/use_interval.ts | 27 ------------------- .../page_results_content.tsx | 2 +- .../logs/log_entry_rate/page_content.tsx | 2 +- .../use_log_entry_rate_results_url_state.tsx | 2 +- 4 files changed, 3 insertions(+), 30 deletions(-) delete mode 100644 x-pack/plugins/infra/public/hooks/use_interval.ts diff --git a/x-pack/plugins/infra/public/hooks/use_interval.ts b/x-pack/plugins/infra/public/hooks/use_interval.ts deleted file mode 100644 index e2f33c9458e9a..0000000000000 --- a/x-pack/plugins/infra/public/hooks/use_interval.ts +++ /dev/null @@ -1,27 +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 { useEffect, useRef } from 'react'; - -export function useInterval(callback: () => void, delay: number | null) { - const savedCallback = useRef(callback); - - useEffect(() => { - savedCallback.current = callback; - }, [callback]); - - useEffect(() => { - function tick() { - savedCallback.current(); - } - - if (delay !== null) { - const id = setInterval(tick, delay); - return () => clearInterval(id); - } - }, [delay]); -} diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx index 616e3ed3f11f5..1206e5c365441 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx @@ -10,6 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel, EuiSuperDatePicker } from import { i18n } from '@kbn/i18n'; import moment from 'moment'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import useInterval from 'react-use/lib/useInterval'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { useTrackPageview } from '../../../../../observability/public'; @@ -17,7 +18,6 @@ import { TimeRange } from '../../../../common/time/time_range'; import { CategoryJobNoticesSection } from '../../../components/logging/log_analysis_job_status'; import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { ViewLogInContext } from '../../../containers/logs/view_log_in_context'; -import { useInterval } from '../../../hooks/use_interval'; import { PageViewLogInContext } from '../stream/page_view_log_in_context'; import { TopCategoriesSection } from './sections/top_categories'; import { useLogEntryCategoriesResults } from './use_log_entry_categories_results'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx index 5fd00527b8b70..114f8ff9db3b3 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import React, { memo, useEffect, useCallback } from 'react'; +import useInterval from 'react-use/lib/useInterval'; import { SubscriptionSplashContent } from '../../../components/subscription_splash_content'; import { isJobStatusWithResults } from '../../../../common/log_analysis'; import { LoadingPage } from '../../../components/loading_page'; @@ -27,7 +28,6 @@ import { useLogEntryRateModuleContext } from '../../../containers/logs/log_analy import { useLogSourceContext } from '../../../containers/logs/log_source'; import { LogEntryRateResultsContent } from './page_results_content'; import { LogEntryRateSetupContent } from './page_setup_content'; -import { useInterval } from '../../../hooks/use_interval'; const JOB_STATUS_POLLING_INTERVAL = 30000; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results_url_state.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results_url_state.tsx index ccfae14fd4a59..a845e59ce6d32 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results_url_state.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results_url_state.tsx @@ -6,13 +6,13 @@ */ import { useCallback, useMemo, useState } from 'react'; +import useInterval from 'react-use/lib/useInterval'; import datemath from '@elastic/datemath'; import moment from 'moment'; import * as rt from 'io-ts'; import { TimeRange as KibanaTimeRange } from '../../../../../../../src/plugins/data/public'; import { TimeRange } from '../../../../common/time/time_range'; import { useUrlState } from '../../../utils/use_url_state'; -import { useInterval } from '../../../hooks/use_interval'; import { useKibanaTimefilterTime, useSyncKibanaTimeFilterTime, From 52cd0d94d5044a38d43fedccd345164773f35bf6 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Tue, 16 Feb 2021 09:12:56 +0200 Subject: [PATCH 18/23] [TSVB] Fixes chart scroll when legend has many items (#91394) * [TSVB] Fixes chart scroll when legend has many items * Fix functional test * Follow another approach to work well with FF --- .../public/application/components/vis_types/_vis_types.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/_vis_types.scss b/src/plugins/vis_type_timeseries/public/application/components/vis_types/_vis_types.scss index 9c07721fa00b3..198f0f42d503c 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/_vis_types.scss +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/_vis_types.scss @@ -2,6 +2,7 @@ display: flex; flex-direction: column; flex: 1 1 100%; + overflow: auto; .tvbVisTimeSeries { overflow: hidden; From 22bb8d39f05016dcecaf4fb4bc9bbdf493f398d4 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 16 Feb 2021 11:27:33 +0100 Subject: [PATCH 19/23] switch to es archiver fixtures instead of sample data (#91397) --- x-pack/test/accessibility/apps/lens.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/x-pack/test/accessibility/apps/lens.ts b/x-pack/test/accessibility/apps/lens.ts index 2f5ebe3c1a2dc..dcde0b10b7a05 100644 --- a/x-pack/test/accessibility/apps/lens.ts +++ b/x-pack/test/accessibility/apps/lens.ts @@ -11,15 +11,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'visualize', 'timePicker', 'home', 'lens']); const a11y = getService('a11y'); const testSubjects = getService('testSubjects'); + const esArchiver = getService('esArchiver'); const listingTable = getService('listingTable'); describe('Lens', () => { const lensChartName = 'MyLensChart'; before(async () => { - await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { - useActualUrl: true, - }); - await PageObjects.home.addSampleDataSet('flights'); + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.loadIfNeeded('lens/basic'); }); after(async () => { @@ -28,6 +27,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await listingTable.checkListingSelectAllCheckbox(); await listingTable.clickDeleteSelected(); await PageObjects.common.clickConfirmOnModal(); + await esArchiver.unload('logstash_functional'); + await esArchiver.unload('lens/basic'); }); it('lens', async () => { @@ -41,17 +42,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); await PageObjects.timePicker.ensureHiddenNoDataPopover(); + await PageObjects.lens.goToTimeRange(); await PageObjects.lens.configureDimension({ dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', operation: 'terms', - field: 'DestCityName', + field: 'ip', }); await PageObjects.lens.configureDimension({ dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', operation: 'avg', - field: 'AvgTicketPrice', + field: 'bytes', }); await a11y.testAppSnapshot(); @@ -76,6 +78,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); await PageObjects.timePicker.ensureHiddenNoDataPopover(); + await PageObjects.lens.goToTimeRange(); await PageObjects.lens.openDimensionEditor('lnsXY_xDimensionPanel > lns-empty-dimension'); await a11y.testAppSnapshot(); @@ -96,13 +99,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.configureDimension({ dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', operation: 'date_histogram', - field: 'timestamp', + field: '@timestamp', }); await PageObjects.lens.configureDimension({ dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', operation: 'avg', - field: 'AvgTicketPrice', + field: 'bytes', }); await testSubjects.click('lnsSuggestion-barChart > lnsSuggestion'); @@ -118,7 +121,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', operation: 'terms', - field: 'DestCityName', + field: 'ip', }, 1 ); @@ -127,7 +130,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', operation: 'median', - field: 'FlightTimeMin', + field: 'bytes', }, 1 ); From f06e722a9986434e0ff4eeba5a4d107998a14361 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Tue, 16 Feb 2021 03:28:13 -0700 Subject: [PATCH 20/23] [Search Sessions] Search session UI telemetry (#89950) Co-authored-by: Liza K Co-authored-by: Anton Dosov --- ...n-plugins-data-public.searchinterceptor.md | 2 +- ...ns-data-public.searchinterceptor.search.md | 2 +- src/plugins/data/public/index.ts | 1 + src/plugins/data/public/public.api.md | 66 ++++++++++++---- .../collectors/create_usage_collector.test.ts | 62 ++++++++++++++- .../collectors/create_usage_collector.ts | 59 ++++++++++---- .../data/public/search/collectors/mocks.ts | 28 +++++++ .../data/public/search/collectors/types.ts | 76 ++++++++++++++++++- src/plugins/data/public/search/index.ts | 8 +- src/plugins/data/public/search/mocks.ts | 2 + .../data/public/search/search_interceptor.ts | 7 +- src/plugins/data/public/search/types.ts | 2 +- x-pack/plugins/data_enhanced/public/plugin.ts | 12 ++- .../public/search/search_interceptor.test.ts | 44 +---------- .../public/search/search_interceptor.ts | 9 --- .../sessions_mgmt/application/index.tsx | 1 + .../sessions_mgmt/components/main.test.tsx | 10 ++- .../search/sessions_mgmt/components/main.tsx | 2 + .../components/table/table.test.tsx | 13 +++- .../sessions_mgmt/components/table/table.tsx | 10 ++- .../public/search/sessions_mgmt/index.ts | 3 +- .../public/search/sessions_mgmt/lib/api.ts | 13 +++- .../sessions_mgmt/lib/get_columns.test.tsx | 47 +++++++++--- .../search/sessions_mgmt/lib/get_columns.tsx | 11 ++- ...onnected_search_session_indicator.test.tsx | 30 ++++++++ .../connected_search_session_indicator.tsx | 31 ++++++-- .../search_session_tour.tsx | 34 ++++++++- .../search_session_indicator.tsx | 4 + 28 files changed, 463 insertions(+), 126 deletions(-) create mode 100644 src/plugins/data/public/search/collectors/mocks.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md index 5f266e7d8bd8c..2247813562dc7 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md @@ -28,6 +28,6 @@ export declare class SearchInterceptor | --- | --- | --- | | [getTimeoutMode()](./kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md) | | | | [handleSearchError(e, timeoutSignal, options)](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) | | | -| [search(request, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search method. Overrides the AbortSignal with one that will abort either when cancelPending is called, when the request times out, or when the original AbortSignal is aborted. Updates pendingCount$ when the request is started/finalized. | +| [search(request, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search method. Overrides the AbortSignal with one that will abort either when the request times out, or when the original AbortSignal is aborted. Updates pendingCount$ when the request is started/finalized. | | [showError(e)](./kibana-plugin-plugins-data-public.searchinterceptor.showerror.md) | | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md index 61f8eeb973f4c..a54b43da4add8 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md @@ -4,7 +4,7 @@ ## SearchInterceptor.search() method -Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort either when `cancelPending` is called, when the request times out, or when the original `AbortSignal` is aborted. Updates `pendingCount$` when the request is started/finalized. +Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort either when the request times out, or when the original `AbortSignal` is aborted. Updates `pendingCount$` when the request is started/finalized. Signature: diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 83a248ee2c3de..df799ede08a31 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -389,6 +389,7 @@ export type { ISessionService, SearchSessionInfoProvider, ISessionsClient, + SearchUsageCollector, } from './search'; export { ISearchOptions, isErrorResponse, isCompleteResponse, isPartialResponse } from '../common'; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 4668ce2208610..0920d0d716d73 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1661,7 +1661,7 @@ export interface ISearchSetup { aggs: AggsSetup; session: ISessionService; sessionsClient: ISessionsClient; - // Warning: (ae-forgotten-export) The symbol "SearchUsageCollector" needs to be exported by the entry point index.d.ts + // Warning: (ae-incompatible-release-tags) The symbol "usageCollector" is marked as @public, but its signature references "SearchUsageCollector" which is marked as @internal // // (undocumented) usageCollector?: SearchUsageCollector; @@ -2329,6 +2329,8 @@ export interface SearchInterceptorDeps { toasts: ToastsSetup; // (undocumented) uiSettings: CoreSetup_2['uiSettings']; + // Warning: (ae-incompatible-release-tags) The symbol "usageCollector" is marked as @public, but its signature references "SearchUsageCollector" which is marked as @internal + // // (undocumented) usageCollector?: SearchUsageCollector; } @@ -2453,6 +2455,38 @@ export class SearchTimeoutError extends KbnError { mode: TimeoutErrorMode; } +// @internal (undocumented) +export interface SearchUsageCollector { + // (undocumented) + trackQueryTimedOut: () => Promise; + // (undocumented) + trackSessionCancelled: () => Promise; + // (undocumented) + trackSessionDeleted: () => Promise; + // (undocumented) + trackSessionExtended: () => Promise; + // (undocumented) + trackSessionIndicatorSaveDisabled: () => Promise; + // (undocumented) + trackSessionIndicatorTourLoading: () => Promise; + // (undocumented) + trackSessionIndicatorTourRestored: () => Promise; + // (undocumented) + trackSessionIsRestored: () => Promise; + // (undocumented) + trackSessionReloaded: () => Promise; + // (undocumented) + trackSessionSavedResults: () => Promise; + // (undocumented) + trackSessionSentToBackground: () => Promise; + // (undocumented) + trackSessionsListLoaded: () => Promise; + // (undocumented) + trackSessionViewRestored: () => Promise; + // (undocumented) + trackViewSessionsList: () => Promise; +} + // Warning: (ae-missing-release-tag) "SortDirection" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2607,21 +2641,21 @@ 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: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/index.ts:398:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:413:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:418:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:422:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:425: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:42:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/collectors/create_usage_collector.test.ts b/src/plugins/data/public/search/collectors/create_usage_collector.test.ts index df9903a4683e1..145bb191fde11 100644 --- a/src/plugins/data/public/search/collectors/create_usage_collector.test.ts +++ b/src/plugins/data/public/search/collectors/create_usage_collector.test.ts @@ -45,12 +45,66 @@ describe('Search Usage Collector', () => { ); }); - test('tracks query cancellation', async () => { - await usageCollector.trackQueriesCancelled(); + test('tracks session sent to background', async () => { + await usageCollector.trackSessionSentToBackground(); expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); - expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.LOADED); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_SENT_TO_BACKGROUND + ); + }); + + test('tracks session saved results', async () => { + await usageCollector.trackSessionSavedResults(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_SAVED_RESULTS + ); + }); + + test('tracks session view restored', async () => { + await usageCollector.trackSessionViewRestored(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_VIEW_RESTORED + ); + }); + + test('tracks session is restored', async () => { + await usageCollector.trackSessionIsRestored(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_IS_RESTORED + ); + }); + + test('tracks session reloaded', async () => { + await usageCollector.trackSessionReloaded(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_RELOADED + ); + }); + + test('tracks session extended', async () => { + await usageCollector.trackSessionExtended(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_EXTENDED + ); + }); + + test('tracks session cancelled', async () => { + await usageCollector.trackSessionCancelled(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( - SEARCH_EVENT_TYPE.QUERIES_CANCELLED + SEARCH_EVENT_TYPE.SESSION_CANCELLED ); }); }); diff --git a/src/plugins/data/public/search/collectors/create_usage_collector.ts b/src/plugins/data/public/search/collectors/create_usage_collector.ts index e9a192a2710c4..3fe135ea29152 100644 --- a/src/plugins/data/public/search/collectors/create_usage_collector.ts +++ b/src/plugins/data/public/search/collectors/create_usage_collector.ts @@ -7,6 +7,7 @@ */ import { first } from 'rxjs/operators'; +import { UiCounterMetricType } from '@kbn/analytics'; import { StartServicesAccessor } from '../../../../../core/public'; import { METRIC_TYPE, UsageCollectionSetup } from '../../../../usage_collection/public'; import { SEARCH_EVENT_TYPE, SearchUsageCollector } from './types'; @@ -20,22 +21,48 @@ export const createUsageCollector = ( return application.currentAppId$.pipe(first()).toPromise(); }; - return { - trackQueryTimedOut: async () => { - const currentApp = await getCurrentApp(); - return usageCollection?.reportUiCounter( - currentApp!, - METRIC_TYPE.LOADED, - SEARCH_EVENT_TYPE.QUERY_TIMED_OUT - ); - }, - trackQueriesCancelled: async () => { + const getCollector = (metricType: UiCounterMetricType, eventType: SEARCH_EVENT_TYPE) => { + return async () => { const currentApp = await getCurrentApp(); - return usageCollection?.reportUiCounter( - currentApp!, - METRIC_TYPE.LOADED, - SEARCH_EVENT_TYPE.QUERIES_CANCELLED - ); - }, + return usageCollection?.reportUiCounter(currentApp!, metricType, eventType); + }; + }; + + return { + trackQueryTimedOut: getCollector(METRIC_TYPE.LOADED, SEARCH_EVENT_TYPE.QUERY_TIMED_OUT), + trackSessionIndicatorTourLoading: getCollector( + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.SESSION_INDICATOR_TOUR_LOADING + ), + trackSessionIndicatorTourRestored: getCollector( + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.SESSION_INDICATOR_TOUR_RESTORED + ), + trackSessionIndicatorSaveDisabled: getCollector( + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.SESSION_INDICATOR_SAVE_DISABLED + ), + trackSessionSentToBackground: getCollector( + METRIC_TYPE.CLICK, + SEARCH_EVENT_TYPE.SESSION_SENT_TO_BACKGROUND + ), + trackSessionSavedResults: getCollector( + METRIC_TYPE.CLICK, + SEARCH_EVENT_TYPE.SESSION_SAVED_RESULTS + ), + trackSessionViewRestored: getCollector( + METRIC_TYPE.CLICK, + SEARCH_EVENT_TYPE.SESSION_VIEW_RESTORED + ), + trackSessionIsRestored: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_IS_RESTORED), + trackSessionReloaded: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_RELOADED), + trackSessionExtended: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_EXTENDED), + trackSessionCancelled: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_CANCELLED), + trackSessionDeleted: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_DELETED), + trackViewSessionsList: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_VIEW_LIST), + trackSessionsListLoaded: getCollector( + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.SESSIONS_LIST_LOADED + ), }; }; diff --git a/src/plugins/data/public/search/collectors/mocks.ts b/src/plugins/data/public/search/collectors/mocks.ts new file mode 100644 index 0000000000000..2a546d6310d7f --- /dev/null +++ b/src/plugins/data/public/search/collectors/mocks.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 { SearchUsageCollector } from './types'; + +export function createSearchUsageCollectorMock(): jest.Mocked { + return { + trackQueryTimedOut: jest.fn(), + trackSessionIndicatorTourLoading: jest.fn(), + trackSessionIndicatorTourRestored: jest.fn(), + trackSessionIndicatorSaveDisabled: jest.fn(), + trackSessionSentToBackground: jest.fn(), + trackSessionSavedResults: jest.fn(), + trackSessionViewRestored: jest.fn(), + trackSessionIsRestored: jest.fn(), + trackSessionReloaded: jest.fn(), + trackSessionExtended: jest.fn(), + trackSessionCancelled: jest.fn(), + trackSessionDeleted: jest.fn(), + trackViewSessionsList: jest.fn(), + trackSessionsListLoaded: jest.fn(), + }; +} diff --git a/src/plugins/data/public/search/collectors/types.ts b/src/plugins/data/public/search/collectors/types.ts index 9668b4dcbefa2..49c240d1ccb16 100644 --- a/src/plugins/data/public/search/collectors/types.ts +++ b/src/plugins/data/public/search/collectors/types.ts @@ -6,12 +6,84 @@ * Side Public License, v 1. */ +/** + * @internal + */ export enum SEARCH_EVENT_TYPE { + /** + * A search reached the timeout configured in UI setting search:timeout + */ QUERY_TIMED_OUT = 'queryTimedOut', - QUERIES_CANCELLED = 'queriesCancelled', + /** + * The session indicator was automatically brought up because of a long running query + */ + SESSION_INDICATOR_TOUR_LOADING = 'sessionIndicatorTourLoading', + /** + * The session indicator was automatically brought up because of a restored session + */ + SESSION_INDICATOR_TOUR_RESTORED = 'sessionIndicatorTourRestored', + /** + * The session indicator was disabled because of a completion timeout + */ + SESSION_INDICATOR_SAVE_DISABLED = 'sessionIndicatorSaveDisabled', + /** + * The user clicked to continue a session in the background (prior to results completing) + */ + SESSION_SENT_TO_BACKGROUND = 'sessionSentToBackground', + /** + * The user clicked to save the session (after results completing) + */ + SESSION_SAVED_RESULTS = 'sessionSavedResults', + /** + * The user clicked to view a completed session + */ + SESSION_VIEW_RESTORED = 'sessionViewRestored', + /** + * The session was successfully restored upon a user navigating + */ + SESSION_IS_RESTORED = 'sessionIsRestored', + /** + * The user clicked to reload an expired/cancelled session + */ + SESSION_RELOADED = 'sessionReloaded', + /** + * The user clicked to extend the expiration of a session + */ + SESSION_EXTENDED = 'sessionExtended', + /** + * The user clicked to cancel a session + */ + SESSION_CANCELLED = 'sessionCancelled', + /** + * The user clicked to delete a session + */ + SESSION_DELETED = 'sessionDeleted', + /** + * The user clicked a link to view the list of sessions + */ + SESSION_VIEW_LIST = 'sessionViewList', + /** + * The user landed on the sessions management page + */ + SESSIONS_LIST_LOADED = 'sessionsListLoaded', } +/** + * @internal + */ export interface SearchUsageCollector { trackQueryTimedOut: () => Promise; - trackQueriesCancelled: () => Promise; + trackSessionIndicatorTourLoading: () => Promise; + trackSessionIndicatorTourRestored: () => Promise; + trackSessionIndicatorSaveDisabled: () => Promise; + trackSessionSentToBackground: () => Promise; + trackSessionSavedResults: () => Promise; + trackSessionViewRestored: () => Promise; + trackSessionIsRestored: () => Promise; + trackSessionReloaded: () => Promise; + trackSessionExtended: () => Promise; + trackSessionCancelled: () => Promise; + trackSessionDeleted: () => Promise; + trackViewSessionsList: () => Promise; + trackSessionsListLoaded: () => Promise; } diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index b1e0bc490823a..fded4c46992c0 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -8,7 +8,13 @@ export * from './expressions'; -export { ISearchSetup, ISearchStart, ISearchStartSearchSource, SearchEnhancements } from './types'; +export { + ISearchSetup, + ISearchStart, + ISearchStartSearchSource, + SearchEnhancements, + SearchUsageCollector, +} from './types'; export { ES_SEARCH_STRATEGY, diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts index b16468120d95a..273bbfe9e7b08 100644 --- a/src/plugins/data/public/search/mocks.ts +++ b/src/plugins/data/public/search/mocks.ts @@ -10,6 +10,7 @@ import { searchAggsSetupMock, searchAggsStartMock } from './aggs/mocks'; import { searchSourceMock } from './search_source/mocks'; import { ISearchSetup, ISearchStart } from './types'; import { getSessionsClientMock, getSessionServiceMock } from './session/mocks'; +import { createSearchUsageCollectorMock } from './collectors/mocks'; function createSetupContract(): jest.Mocked { return { @@ -17,6 +18,7 @@ function createSetupContract(): jest.Mocked { __enhance: jest.fn(), session: getSessionServiceMock(), sessionsClient: getSessionsClientMock(), + usageCollector: createSearchUsageCollectorMock(), }; } diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index f33740cc45bf9..f46a3d258f948 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -155,13 +155,14 @@ export class SearchInterceptor { const { signal: timeoutSignal } = timeoutController; const timeout$ = timeout ? timer(timeout) : NEVER; const subscription = timeout$.subscribe(() => { + this.deps.usageCollector?.trackQueryTimedOut(); timeoutController.abort(); }); const selfAbortController = new AbortController(); // Get a combined `AbortSignal` that will be aborted whenever the first of the following occurs: - // 1. The user manually aborts (via `cancelPending`) + // 1. The internal abort controller aborts // 2. The request times out // 3. abort() is called on `selfAbortController`. This is used by session service to abort all pending searches that it tracks // in the current session @@ -221,8 +222,8 @@ export class SearchInterceptor { /** * Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort - * either when `cancelPending` is called, when the request times out, or when the original - * `AbortSignal` is aborted. Updates `pendingCount$` when the request is started/finalized. + * either when the request times out, or when the original `AbortSignal` is aborted. Updates + * `pendingCount$` when the request is started/finalized. * * @param request * @options diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts index 01f5cf3de38bd..391be8e053746 100644 --- a/src/plugins/data/public/search/types.ts +++ b/src/plugins/data/public/search/types.ts @@ -15,7 +15,7 @@ import { IndexPatternsContract } from '../../common/index_patterns/index_pattern import { UsageCollectionSetup } from '../../../usage_collection/public'; import { ISessionsClient, ISessionService } from './session'; -export { ISearchStartSearchSource }; +export { ISearchStartSearchSource, SearchUsageCollector }; export interface SearchEnhancements { searchInterceptor: ISearchInterceptor; diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 0a116545e6e36..29f3494433bef 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -8,7 +8,11 @@ import React from 'react'; import moment from 'moment'; import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; -import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { + DataPublicPluginSetup, + DataPublicPluginStart, + SearchUsageCollector, +} from '../../../../src/plugins/data/public'; import { BfetchPublicSetup } from '../../../../src/plugins/bfetch/public'; import { ManagementSetup } from '../../../../src/plugins/management/public'; import { SharePluginStart } from '../../../../src/plugins/share/public'; @@ -40,6 +44,7 @@ export class DataEnhancedPlugin private enhancedSearchInterceptor!: EnhancedSearchInterceptor; private config!: ConfigSchema; private readonly storage = new Storage(window.localStorage); + private usageCollector?: SearchUsageCollector; constructor(private initializerContext: PluginInitializerContext) {} @@ -71,8 +76,10 @@ export class DataEnhancedPlugin this.config = this.initializerContext.config.get(); if (this.config.search.sessions.enabled) { const sessionsConfig = this.config.search.sessions; - registerSearchSessionsMgmt(core, sessionsConfig, { management }); + registerSearchSessionsMgmt(core, sessionsConfig, { data, management }); } + + this.usageCollector = data.search.usageCollector; } public start(core: CoreStart, plugins: DataEnhancedStartDependencies) { @@ -90,6 +97,7 @@ export class DataEnhancedPlugin disableSaveAfterSessionCompletesTimeout: moment .duration(this.config.search.sessions.notTouchedTimeout) .asMilliseconds(), + usageCollector: this.usageCollector, }) ) ), diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts index 04a777b9b6897..02671974e5053 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts @@ -16,6 +16,7 @@ import { SearchTimeoutError, SearchSessionState, PainlessError, + DataPublicPluginSetup, } from 'src/plugins/data/public'; import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { bfetchPluginMock } from '../../../../../src/plugins/bfetch/public/mocks'; @@ -51,14 +52,15 @@ function mockFetchImplementation(responses: any[]) { } describe('EnhancedSearchInterceptor', () => { - let mockUsageCollector: any; let sessionService: jest.Mocked; let sessionState$: BehaviorSubject; + let dataPluginMockSetup: DataPublicPluginSetup; beforeEach(() => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); sessionState$ = new BehaviorSubject(SearchSessionState.None); + dataPluginMockSetup = dataPluginMock.createSetupContract(); const dataPluginMockStart = dataPluginMock.createStartContract(); sessionService = { ...(dataPluginMockStart.search.session as jest.Mocked), @@ -80,11 +82,6 @@ describe('EnhancedSearchInterceptor', () => { complete.mockClear(); jest.clearAllTimers(); - mockUsageCollector = { - trackQueryTimedOut: jest.fn(), - trackQueriesCancelled: jest.fn(), - }; - const mockPromise = new Promise((resolve) => { resolve([ { @@ -102,7 +99,7 @@ describe('EnhancedSearchInterceptor', () => { startServices: mockPromise as any, http: mockCoreSetup.http, uiSettings: mockCoreSetup.uiSettings, - usageCollector: mockUsageCollector, + usageCollector: dataPluginMockSetup.search.usageCollector, session: sessionService, }); }); @@ -455,39 +452,6 @@ describe('EnhancedSearchInterceptor', () => { }); }); - describe('cancelPending', () => { - test('should abort all pending requests', async () => { - mockFetchImplementation([ - { - time: 10, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - { - time: 20, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - ]); - - searchInterceptor.search({}).subscribe({ next, error }); - searchInterceptor.search({}).subscribe({ next, error }); - searchInterceptor.cancelPending(); - - await timeTravel(); - - const areAllRequestsAborted = fetchMock.mock.calls.every(([_, signal]) => signal?.aborted); - expect(areAllRequestsAborted).toBe(true); - expect(mockUsageCollector.trackQueriesCancelled).toBeCalledTimes(1); - }); - }); - describe('session', () => { beforeEach(() => { const responses = [ diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts index f211021e45773..0dfec1a35d900 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts @@ -46,15 +46,6 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { : TimeoutErrorMode.CONTACT; } - /** - * Abort our `AbortController`, which in turn aborts any intercepted searches. - */ - public cancelPending = () => { - this.abortController.abort(); - this.abortController = new AbortController(); - if (this.deps.usageCollector) this.deps.usageCollector.trackQueriesCancelled(); - }; - public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) { const { combinedSignal, timeoutSignal, cleanup, abort } = this.setupAbortSignal({ abortSignal: options.abortSignal, diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/application/index.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/application/index.tsx index 177cfbbb4fd7e..2dfca534c20b5 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/application/index.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/application/index.tsx @@ -50,6 +50,7 @@ export class SearchSessionsMgmtApp { notifications, urls: share.urlGenerators, application, + usageCollector: pluginsSetup.data.search.usageCollector, }); const documentation = new AsyncSearchIntroDocumentation(docLinks); diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx index 1f8f603400c9f..6b94eccc4e707 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx @@ -13,14 +13,17 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { coreMock } from 'src/core/public/mocks'; import { SessionsClient } from 'src/plugins/data/public/search'; -import { SessionsConfigSchema } from '..'; +import { IManagementSectionsPluginsSetup, SessionsConfigSchema } from '..'; import { SearchSessionsMgmtAPI } from '../lib/api'; import { AsyncSearchIntroDocumentation } from '../lib/documentation'; import { LocaleWrapper, mockUrls } from '../__mocks__'; import { SearchSessionsMgmtMain } from './main'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { managementPluginMock } from '../../../../../../../src/plugins/management/public/mocks'; let mockCoreSetup: MockedKeys; let mockCoreStart: MockedKeys; +let mockPluginsSetup: IManagementSectionsPluginsSetup; let mockConfig: SessionsConfigSchema; let sessionsClient: SessionsClient; let api: SearchSessionsMgmtAPI; @@ -29,6 +32,10 @@ describe('Background Search Session Management Main', () => { beforeEach(() => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); + mockPluginsSetup = { + data: dataPluginMock.createSetupContract(), + management: managementPluginMock.createSetupContract(), + }; mockConfig = { defaultExpiration: moment.duration('7d'), management: { @@ -67,6 +74,7 @@ describe('Background Search Session Management Main', () => { ; let mockCoreStart: CoreStart; +let mockPluginsSetup: IManagementSectionsPluginsSetup; let mockConfig: SessionsConfigSchema; let sessionsClient: SessionsClient; let api: SearchSessionsMgmtAPI; @@ -29,6 +32,10 @@ describe('Background Search Session Management Table', () => { beforeEach(async () => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); + mockPluginsSetup = { + data: dataPluginMock.createSetupContract(), + management: managementPluginMock.createSetupContract(), + }; mockConfig = { defaultExpiration: moment.duration('7d'), management: { @@ -79,6 +86,7 @@ describe('Background Search Session Management Table', () => { { { { ([]); const [isLoading, setIsLoading] = useState(false); const [debouncedIsLoading, setDebouncedIsLoading] = useState(false); @@ -71,7 +72,8 @@ export function SearchSessionsMgmtTable({ core, api, timezone, config, ...props // initial data load useEffect(() => { doRefresh(); - }, [doRefresh]); + plugins.data.search.usageCollector?.trackSessionsListLoaded(); + }, [doRefresh, plugins]); useInterval(doRefresh, refreshInterval); @@ -110,7 +112,7 @@ export function SearchSessionsMgmtTable({ core, api, timezone, config, ...props rowProps={() => ({ 'data-test-subj': 'searchSessionsRow', })} - columns={getColumns(core, api, config, timezone, onActionComplete)} + columns={getColumns(core, plugins, api, config, timezone, onActionComplete)} items={tableData} pagination={pagination} search={search} 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 e916eed6bcbc4..0ac8fa798cc92 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 @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import type { CoreStart, HttpStart, I18nStart, IUiSettingsClient } from 'kibana/public'; import { CoreSetup } from 'kibana/public'; -import type { DataPublicPluginStart } from 'src/plugins/data/public'; +import type { DataPublicPluginSetup, DataPublicPluginStart } from 'src/plugins/data/public'; import type { ManagementSetup } from 'src/plugins/management/public'; import type { SharePluginStart } from 'src/plugins/share/public'; import type { ConfigSchema } from '../../../config'; @@ -18,6 +18,7 @@ import type { AsyncSearchIntroDocumentation } from './lib/documentation'; import { SEARCH_SESSIONS_MANAGEMENT_ID } from '../../../../../../src/plugins/data/public'; export interface IManagementSectionsPluginsSetup { + data: DataPublicPluginSetup; management: ManagementSetup; } diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts index 39da58cb76918..838b51994aa71 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts @@ -11,7 +11,10 @@ import moment from 'moment'; import { from, race, timer } from 'rxjs'; import { mapTo, tap } from 'rxjs/operators'; import type { SharePluginStart } from 'src/plugins/share/public'; -import { ISessionsClient } from '../../../../../../../src/plugins/data/public'; +import { + ISessionsClient, + SearchUsageCollector, +} from '../../../../../../../src/plugins/data/public'; import { SearchSessionStatus } from '../../../../common/search'; import { ACTION } from '../components/actions'; import { PersistedSearchSessionSavedObjectAttributes, UISession } from '../types'; @@ -84,17 +87,18 @@ const mapToUISession = (urls: UrlGeneratorsStart, config: SessionsConfigSchema) }; }; -interface SearcgSessuibManagementDeps { +interface SearchSessionManagementDeps { urls: UrlGeneratorsStart; notifications: NotificationsStart; application: ApplicationStart; + usageCollector?: SearchUsageCollector; } export class SearchSessionsMgmtAPI { constructor( private sessionsClient: ISessionsClient, private config: SessionsConfigSchema, - private deps: SearcgSessuibManagementDeps + private deps: SearchSessionManagementDeps ) {} public async fetchTableData(): Promise { @@ -151,6 +155,7 @@ export class SearchSessionsMgmtAPI { } public reloadSearchSession(reloadUrl: string) { + this.deps.usageCollector?.trackSessionReloaded(); this.deps.application.navigateToUrl(reloadUrl); } @@ -160,6 +165,7 @@ export class SearchSessionsMgmtAPI { // Cancel and expire public async sendCancel(id: string): Promise { + this.deps.usageCollector?.trackSessionDeleted(); try { await this.sessionsClient.delete(id); @@ -179,6 +185,7 @@ export class SearchSessionsMgmtAPI { // Extend public async sendExtend(id: string, expires: string): Promise { + this.deps.usageCollector?.trackSessionExtended(); try { await this.sessionsClient.extend(id, expires); diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx index fc0a8849006d3..29f0033aaf012 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx @@ -13,16 +13,19 @@ import moment from 'moment'; import { ReactElement } from 'react'; import { coreMock } from 'src/core/public/mocks'; import { SessionsClient } from 'src/plugins/data/public/search'; -import { SessionsConfigSchema } from '../'; +import { IManagementSectionsPluginsSetup, SessionsConfigSchema } from '../'; import { SearchSessionStatus } from '../../../../common/search'; import { OnActionComplete } from '../components'; import { UISession } from '../types'; import { mockUrls } from '../__mocks__'; import { SearchSessionsMgmtAPI } from './api'; import { getColumns } from './get_columns'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { managementPluginMock } from '../../../../../../../src/plugins/management/public/mocks'; let mockCoreSetup: MockedKeys; let mockCoreStart: CoreStart; +let mockPluginsSetup: IManagementSectionsPluginsSetup; let mockConfig: SessionsConfigSchema; let api: SearchSessionsMgmtAPI; let sessionsClient: SessionsClient; @@ -35,6 +38,10 @@ describe('Search Sessions Management table column factory', () => { beforeEach(async () => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); + mockPluginsSetup = { + data: dataPluginMock.createSetupContract(), + management: managementPluginMock.createSetupContract(), + }; mockConfig = { defaultExpiration: moment.duration('7d'), management: { @@ -72,7 +79,7 @@ describe('Search Sessions Management table column factory', () => { }); test('returns columns', () => { - const columns = getColumns(mockCoreStart, api, mockConfig, tz, handleAction); + const columns = getColumns(mockCoreStart, mockPluginsSetup, api, mockConfig, tz, handleAction); expect(columns).toMatchInlineSnapshot(` Array [ Object { @@ -124,9 +131,14 @@ describe('Search Sessions Management table column factory', () => { describe('name', () => { test('rendering', () => { - const [, nameColumn] = getColumns(mockCoreStart, api, mockConfig, tz, handleAction) as Array< - EuiTableFieldDataColumnType - >; + const [, nameColumn] = getColumns( + mockCoreStart, + mockPluginsSetup, + api, + mockConfig, + tz, + handleAction + ) as Array>; const name = mount(nameColumn.render!(mockSession.name, mockSession) as ReactElement); @@ -137,9 +149,14 @@ describe('Search Sessions Management table column factory', () => { // Status column describe('status', () => { test('render in_progress', () => { - const [, , status] = getColumns(mockCoreStart, api, mockConfig, tz, handleAction) as Array< - EuiTableFieldDataColumnType - >; + const [, , status] = getColumns( + mockCoreStart, + mockPluginsSetup, + api, + mockConfig, + tz, + handleAction + ) as Array>; const statusLine = mount(status.render!(mockSession.status, mockSession) as ReactElement); expect( @@ -148,9 +165,14 @@ describe('Search Sessions Management table column factory', () => { }); test('error handling', () => { - const [, , status] = getColumns(mockCoreStart, api, mockConfig, tz, handleAction) as Array< - EuiTableFieldDataColumnType - >; + const [, , status] = getColumns( + mockCoreStart, + mockPluginsSetup, + api, + mockConfig, + tz, + handleAction + ) as Array>; mockSession.status = 'INVALID' as SearchSessionStatus; const statusLine = mount(status.render!(mockSession.status, mockSession) as ReactElement); @@ -168,6 +190,7 @@ describe('Search Sessions Management table column factory', () => { const [, , , createdDateCol] = getColumns( mockCoreStart, + mockPluginsSetup, api, mockConfig, tz, @@ -184,6 +207,7 @@ describe('Search Sessions Management table column factory', () => { const [, , , createdDateCol] = getColumns( mockCoreStart, + mockPluginsSetup, api, mockConfig, tz, @@ -198,6 +222,7 @@ describe('Search Sessions Management table column factory', () => { test('error handling', () => { const [, , , createdDateCol] = getColumns( mockCoreStart, + mockPluginsSetup, api, mockConfig, tz, diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx index cbd42ec56bb8b..d34998d023178 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx @@ -21,7 +21,7 @@ import { capitalize } from 'lodash'; import React from 'react'; import { FormattedMessage } from 'react-intl'; import { RedirectAppLinks } from '../../../../../../../src/plugins/kibana_react/public'; -import { SessionsConfigSchema } from '../'; +import { IManagementSectionsPluginsSetup, SessionsConfigSchema } from '../'; import { SearchSessionStatus } from '../../../../common/search'; import { TableText } from '../components'; import { OnActionComplete, PopoverActionsMenu } from '../components'; @@ -45,6 +45,7 @@ function isSessionRestorable(status: SearchSessionStatus) { export const getColumns = ( core: CoreStart, + plugins: IManagementSectionsPluginsSetup, api: SearchSessionsMgmtAPI, config: SessionsConfigSchema, timezone: string, @@ -83,6 +84,10 @@ export const getColumns = ( width: '20%', render: (name: UISession['name'], { restoreUrl, reloadUrl, status }) => { const isRestorable = isSessionRestorable(status); + const href = isRestorable ? restoreUrl : reloadUrl; + const trackAction = isRestorable + ? plugins.data.search.usageCollector?.trackSessionViewRestored + : plugins.data.search.usageCollector?.trackSessionReloaded; const notRestorableWarning = isRestorable ? null : ( <> {' '} @@ -99,8 +104,10 @@ export const getColumns = ( ); return ( + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} trackAction?.()} data-test-subj="sessionManagementNameCol" > 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 aacb86f269727..0aef27310e090 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 @@ -16,18 +16,22 @@ import { ISessionService, RefreshInterval, SearchSessionState, + SearchUsageCollector, TimefilterContract, } from '../../../../../../../src/plugins/data/public'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { TOUR_RESTORE_STEP_KEY, TOUR_TAKING_TOO_LONG_STEP_KEY } from './search_session_tour'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from 'react-intl'; +import { createSearchUsageCollectorMock } from '../../../../../../../src/plugins/data/public/search/collectors/mocks'; const coreStart = coreMock.createStart(); const application = coreStart.application; const dataStart = dataPluginMock.createStartContract(); const sessionService = dataStart.search.session as jest.Mocked; let storage: Storage; +let usageCollector: jest.Mocked; + const refreshInterval$ = new BehaviorSubject({ value: 0, pause: true }); const timeFilter = dataStart.query.timefilter.timefilter as jest.Mocked; timeFilter.getRefreshIntervalUpdate$.mockImplementation(() => refreshInterval$); @@ -41,6 +45,7 @@ function Container({ children }: { children?: ReactNode }) { beforeEach(() => { storage = new Storage(new StubBrowserStorage()); + usageCollector = createSearchUsageCollectorMock(); refreshInterval$.next({ value: 0, pause: true }); sessionService.isSessionStorageReady.mockImplementation(() => true); sessionService.getSearchSessionIndicatorUiConfig.mockImplementation(() => ({ @@ -57,6 +62,7 @@ test("shouldn't show indicator in case no active search session", async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const { getByTestId, container } = render( @@ -84,6 +90,7 @@ test("shouldn't show indicator in case app hasn't opt-in", async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const { getByTestId, container } = render( @@ -113,6 +120,7 @@ test('should show indicator in case there is an active search session', async () timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const { getByTestId } = render( @@ -137,6 +145,7 @@ test('should be disabled in case uiConfig says so ', async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); render( @@ -185,6 +194,7 @@ test('should be disabled during auto-refresh', async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); render( @@ -222,6 +232,7 @@ describe('Completed inactivity', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); render( @@ -253,12 +264,14 @@ describe('Completed inactivity', () => { }); expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled(); + expect(usageCollector.trackSessionIndicatorSaveDisabled).toHaveBeenCalledTimes(0); act(() => { jest.advanceTimersByTime(2.5 * 60 * 1000); }); expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled(); + expect(usageCollector.trackSessionIndicatorSaveDisabled).toHaveBeenCalledTimes(1); }); }); @@ -280,6 +293,7 @@ describe('tour steps', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const rendered = render( @@ -307,6 +321,9 @@ describe('tour steps', () => { expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeFalsy(); expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeTruthy(); + + expect(usageCollector.trackSessionIndicatorTourLoading).toHaveBeenCalledTimes(1); + expect(usageCollector.trackSessionIndicatorTourRestored).toHaveBeenCalledTimes(0); }); test("doesn't show tour step if state changed before delay", async () => { @@ -317,6 +334,7 @@ describe('tour steps', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const rendered = render( @@ -337,6 +355,9 @@ describe('tour steps', () => { expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeFalsy(); expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeFalsy(); + + expect(usageCollector.trackSessionIndicatorTourLoading).toHaveBeenCalledTimes(0); + expect(usageCollector.trackSessionIndicatorTourRestored).toHaveBeenCalledTimes(0); }); }); @@ -348,6 +369,7 @@ describe('tour steps', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const rendered = render( @@ -360,6 +382,10 @@ describe('tour steps', () => { expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeTruthy(); expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeTruthy(); + + expect(usageCollector.trackSessionIndicatorTourLoading).toHaveBeenCalledTimes(0); + expect(usageCollector.trackSessionIsRestored).toHaveBeenCalledTimes(1); + expect(usageCollector.trackSessionIndicatorTourRestored).toHaveBeenCalledTimes(1); }); test("doesn't show tour for irrelevant state", async () => { @@ -370,6 +396,7 @@ describe('tour steps', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const rendered = render( @@ -383,5 +410,8 @@ describe('tour steps', () => { expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeFalsy(); expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeFalsy(); + + expect(usageCollector.trackSessionIndicatorTourLoading).toHaveBeenCalledTimes(0); + expect(usageCollector.trackSessionIndicatorTourRestored).toHaveBeenCalledTimes(0); }); }); 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 81769e5a25544..7c70a270bd30a 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 @@ -5,8 +5,8 @@ * 2.0. */ -import React, { useCallback, useState } from 'react'; -import { debounce, distinctUntilChanged, map, mapTo, switchMap } from 'rxjs/operators'; +import React, { useCallback, useEffect, useState } from 'react'; +import { debounce, distinctUntilChanged, map, mapTo, switchMap, tap } from 'rxjs/operators'; import { merge, of, timer } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; import { i18n } from '@kbn/i18n'; @@ -15,7 +15,8 @@ import { ISessionService, SearchSessionState, TimefilterContract, -} from '../../../../../../../src/plugins/data/public/'; + SearchUsageCollector, +} from '../../../../../../../src/plugins/data/public'; import { RedirectAppLinks } from '../../../../../../../src/plugins/kibana_react/public'; import { ApplicationStart } from '../../../../../../../src/core/public'; import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; @@ -31,6 +32,7 @@ export interface SearchSessionIndicatorDeps { * after the last search in the session has completed */ disableSaveAfterSessionCompletesTimeout: number; + usageCollector?: SearchUsageCollector; } export const createConnectedSearchSessionIndicator = ({ @@ -39,6 +41,7 @@ export const createConnectedSearchSessionIndicator = ({ timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }: SearchSessionIndicatorDeps): React.FC => { const isAutoRefreshEnabled = () => !timeFilter.getRefreshInterval().pause; const isAutoRefreshEnabled$ = timeFilter @@ -55,7 +58,10 @@ export const createConnectedSearchSessionIndicator = ({ ? merge(of(false), timer(disableSaveAfterSessionCompletesTimeout).pipe(mapTo(true))) : of(false) ), - distinctUntilChanged() + distinctUntilChanged(), + tap((value) => { + if (value) usageCollector?.trackSessionIndicatorSaveDisabled(); + }) ); return () => { @@ -123,7 +129,8 @@ export const createConnectedSearchSessionIndicator = ({ storage, searchSessionIndicator, state, - saveDisabled + saveDisabled, + usageCollector ); const onOpened = useCallback( @@ -138,18 +145,31 @@ export const createConnectedSearchSessionIndicator = ({ const onContinueInBackground = useCallback(() => { if (saveDisabled) return; + usageCollector?.trackSessionSentToBackground(); sessionService.save(); }, [saveDisabled]); const onSaveResults = useCallback(() => { if (saveDisabled) return; + usageCollector?.trackSessionSavedResults(); sessionService.save(); }, [saveDisabled]); const onCancel = useCallback(() => { + usageCollector?.trackSessionCancelled(); sessionService.cancel(); }, []); + const onViewSearchSessions = useCallback(() => { + usageCollector?.trackViewSessionsList(); + }, []); + + useEffect(() => { + if (state === SearchSessionState.Restored) { + usageCollector?.trackSessionIsRestored(); + } + }, [state]); + if (!sessionService.isSessionStorageReady()) return null; return ( @@ -164,6 +184,7 @@ export const createConnectedSearchSessionIndicator = ({ onSaveResults={onSaveResults} onCancel={onCancel} onOpened={onOpened} + onViewSearchSessions={onViewSearchSessions} /> ); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx index 7987278f400ff..1568d54962eca 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx @@ -6,9 +6,13 @@ */ import { useCallback, useEffect } from 'react'; +import { once } from 'lodash'; import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; import { SearchSessionIndicatorRef } from '../search_session_indicator'; -import { SearchSessionState } from '../../../../../../../src/plugins/data/public'; +import { + SearchSessionState, + SearchUsageCollector, +} from '../../../../../../../src/plugins/data/public'; const TOUR_TAKING_TOO_LONG_TIMEOUT = 10000; export const TOUR_TAKING_TOO_LONG_STEP_KEY = `data.searchSession.tour.takingTooLong`; @@ -18,7 +22,8 @@ export function useSearchSessionTour( storage: IStorageWrapper, searchSessionIndicatorRef: SearchSessionIndicatorRef | null, state: SearchSessionState, - searchSessionsDisabled: boolean + searchSessionsDisabled: boolean, + usageCollector?: SearchUsageCollector ) { const markOpenedDone = useCallback(() => { safeSet(storage, TOUR_TAKING_TOO_LONG_STEP_KEY); @@ -28,6 +33,26 @@ export function useSearchSessionTour( safeSet(storage, TOUR_RESTORE_STEP_KEY); }, [storage]); + // Makes sure `trackSessionIndicatorTourLoading` is called only once per sessionId + // if to call `usageCollector?.trackSessionIndicatorTourLoading()` directly inside the `useEffect` below + // it might happen that we cause excessive logging + // ESLint: React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead. + // eslint-disable-next-line react-hooks/exhaustive-deps + const trackSessionIndicatorTourLoading = useCallback( + once(() => usageCollector?.trackSessionIndicatorTourLoading()), + [usageCollector, state] + ); + + // Makes sure `trackSessionIndicatorTourRestored` is called only once per sessionId + // if to call `usageCollector?.trackSessionIndicatorTourRestored()` directly inside the `useEffect` below + // it might happen that we cause excessive logging + // ESLint: React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead. + // eslint-disable-next-line react-hooks/exhaustive-deps + const trackSessionIndicatorTourRestored = useCallback( + once(() => usageCollector?.trackSessionIndicatorTourRestored()), + [usageCollector, state] + ); + useEffect(() => { if (searchSessionsDisabled) return; if (!searchSessionIndicatorRef) return; @@ -36,6 +61,7 @@ export function useSearchSessionTour( if (state === SearchSessionState.Loading) { if (!safeHas(storage, TOUR_TAKING_TOO_LONG_STEP_KEY)) { timeoutHandle = window.setTimeout(() => { + trackSessionIndicatorTourLoading(); searchSessionIndicatorRef.openPopover(); }, TOUR_TAKING_TOO_LONG_TIMEOUT); } @@ -43,6 +69,7 @@ export function useSearchSessionTour( if (state === SearchSessionState.Restored) { if (!safeHas(storage, TOUR_RESTORE_STEP_KEY)) { + trackSessionIndicatorTourRestored(); searchSessionIndicatorRef.openPopover(); } } @@ -57,6 +84,9 @@ export function useSearchSessionTour( searchSessionsDisabled, markOpenedDone, markRestoredDone, + usageCollector, + trackSessionIndicatorTourRestored, + trackSessionIndicatorTourLoading, ]); return { 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 0d31ce0c98f19..24ffc1359acae 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 @@ -30,6 +30,7 @@ export interface SearchSessionIndicatorProps { onContinueInBackground?: () => void; onCancel?: () => void; viewSearchSessionsLink?: string; + onViewSearchSessions?: () => void; onSaveResults?: () => void; managementDisabled?: boolean; managementDisabledReasonText?: string; @@ -78,13 +79,16 @@ const ContinueInBackgroundButton = ({ const ViewAllSearchSessionsButton = ({ viewSearchSessionsLink = 'management/kibana/search_sessions', + onViewSearchSessions = () => {}, buttonProps = {}, managementDisabled, managementDisabledReasonText, }: ActionButtonProps) => ( + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} Date: Tue, 16 Feb 2021 14:00:07 +0200 Subject: [PATCH 21/23] [TSVB] Fixes the timeseries legend, renders the metric, gauge charts for series with empty strings (#90760) * [TSVB] Fixes the legend for empty values and renders the metric, gauge charts * Change i18n id Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../application/components/vis_types/table/vis.js | 8 +++++++- .../application/components/vis_with_splits.js | 10 ++++++++-- .../visualizations/views/timeseries/index.js | 15 +++++++++++++-- .../application/visualizations/views/top_n.js | 7 ++++++- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js index e4ab4eaa0a671..24d0ca1b588f7 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js @@ -8,6 +8,7 @@ import _, { isArray, last, get } from 'lodash'; import React, { Component } from 'react'; +import { i18n } from '@kbn/i18n'; import PropTypes from 'prop-types'; import { RedirectAppLinks } from '../../../../../../kibana_react/public'; import { createTickFormatter } from '../../lib/tick_formatter'; @@ -88,7 +89,12 @@ class TableVis extends Component { }); return (
{rowDisplay} + {rowDisplay || + i18n.translate('visTypeTimeseries.emptyTextValue', { + defaultMessage: '(empty)', + })} +
- {item.labelFormatted ? labelDateFormatter(item.labelFormatted) : item.label} + {label || + i18n.translate('visTypeTimeseries.emptyTextValue', { + defaultMessage: '(empty)', + })}
From 2ef468ef82388824d8b3f30d262d8f7a7b0e51e8 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 16 Feb 2021 07:19:41 -0500 Subject: [PATCH 22/23] Fixes session idle timeout (#91070) * Fix calls to `/api/saved_objects_tagging/tags` Seen on all pages. * Fix calls to `/api/ui_counters/_report` Seen on all pages. * Fix calls to `/api/index_management/indices/reload` Seen on page: /app/management/data/index_management * Fix calls to `/api/watcher/watches` Seen on page: /app/management/insightsAndAlerting/watcher/watches * Fix calls to `/api/rollup/jobs` Seen on page: /app/management/data/rollup_jobs/job_list * Fix calls to `/api/cross_cluster_replication/follower_indices` Seen on page: /app/management/data/cross_cluster_replication/follower_indices * Fix calls to `/api/cross_cluster_replication/auto_follow_patterns` Seen on page: /app/management/data/cross_cluster_replication/auto_follow_patterns * Fix calls to `/api/transform/transforms` and `/api/transform/transforms/_stats` Seen on page: /app/management/data/transform * Fix calls to `/api/console/proxy` Seen on page: /app/dev_tools#/console * Fix calls to `/api/monitoring/v1/clusters` and `/api/monitoring/v1/elasticsearch_settings/check/cluster` Seen on page: /app/monitoring --- src/plugins/console/public/lib/es/es.ts | 12 ++- .../console/public/lib/mappings/mappings.js | 2 +- .../request/send_request.test.helpers.ts | 22 +++-- .../public/request/send_request.ts | 13 ++- .../request/use_request.test.helpers.tsx | 33 ++++--- .../public/request/use_request.ts | 88 +++++++++++-------- .../saved_objects_tagging_oss/common/index.ts | 2 +- .../saved_objects_tagging_oss/common/types.ts | 6 +- .../public/services/create_reporter.ts | 1 + .../public/app/services/api.js | 6 +- .../app/store/actions/auto_follow_pattern.js | 2 +- .../app/store/actions/follower_index.js | 2 +- .../index_table/index_table.container.js | 4 +- .../index_list/index_table/index_table.js | 6 +- .../public/application/services/api.ts | 14 ++- .../store/actions/reload_indices.js | 4 +- .../checkers/settings_checker.js | 4 +- .../monitoring/public/services/clusters.js | 18 ++-- .../public/application/services/api.js | 4 +- .../public/application/services/http.ts | 13 ++- .../store/actions/refresh_clusters.js | 2 +- .../sections/job_list/job_list.container.js | 4 +- .../crud_app/sections/job_list/job_list.js | 5 +- .../rollup/public/crud_app/services/api.js | 5 +- .../crud_app/store/actions/refresh_jobs.js | 4 +- .../saved_objects_tagging/common/types.ts | 1 + .../saved_objects_tagging/public/plugin.ts | 2 +- .../public/services/tags/tags_client.test.ts | 12 ++- .../public/services/tags/tags_client.ts | 16 +++- .../transform/public/app/hooks/use_api.ts | 16 +++- .../public/app/hooks/use_get_transforms.ts | 5 +- 31 files changed, 222 insertions(+), 106 deletions(-) diff --git a/src/plugins/console/public/lib/es/es.ts b/src/plugins/console/public/lib/es/es.ts index 8053fca91b7d1..03ee218fa2e1d 100644 --- a/src/plugins/console/public/lib/es/es.ts +++ b/src/plugins/console/public/lib/es/es.ts @@ -9,6 +9,10 @@ import $ from 'jquery'; import { stringify } from 'query-string'; +interface SendOptions { + asSystemRequest?: boolean; +} + const esVersion: string[] = []; export function getVersion() { @@ -20,13 +24,19 @@ export function getContentType(body: any) { return 'application/json'; } -export function send(method: string, path: string, data: any) { +export function send( + method: string, + path: string, + data: any, + { asSystemRequest }: SendOptions = {} +) { const wrappedDfd = $.Deferred(); const options: JQuery.AjaxSettings = { url: '../api/console/proxy?' + stringify({ path, method }, { sort: false }), headers: { 'kbn-xsrf': 'kibana', + ...(asSystemRequest && { 'kbn-system-request': 'true' }), }, data, contentType: getContentType(data), diff --git a/src/plugins/console/public/lib/mappings/mappings.js b/src/plugins/console/public/lib/mappings/mappings.js index 244cc781498a7..d4996f9fd8862 100644 --- a/src/plugins/console/public/lib/mappings/mappings.js +++ b/src/plugins/console/public/lib/mappings/mappings.js @@ -250,7 +250,7 @@ function retrieveSettings(settingsKey, settingsToRetrieve) { // Fetch autocomplete info if setting is set to true, and if user has made changes. if (settingsToRetrieve[settingsKey] === true) { - return es.send('GET', settingKeyToPathMap[settingsKey], null); + return es.send('GET', settingKeyToPathMap[settingsKey], null, true); } else { const settingsPromise = new $.Deferred(); if (settingsToRetrieve[settingsKey] === false) { diff --git a/src/plugins/es_ui_shared/public/request/send_request.test.helpers.ts b/src/plugins/es_ui_shared/public/request/send_request.test.helpers.ts index 5244a6c1e8bf1..3ef33b651f4d2 100644 --- a/src/plugins/es_ui_shared/public/request/send_request.test.helpers.ts +++ b/src/plugins/es_ui_shared/public/request/send_request.test.helpers.ts @@ -41,20 +41,26 @@ export const createSendRequestHelpers = (): SendRequestHelpers => { // Set up successful request helpers. sendRequestSpy - .withArgs(successRequest.path, { - body: JSON.stringify(successRequest.body), - query: undefined, - }) + .withArgs( + successRequest.path, + sinon.match({ + body: JSON.stringify(successRequest.body), + query: undefined, + }) + ) .resolves(successResponse); const sendSuccessRequest = () => sendRequest({ ...successRequest }); const getSuccessResponse = () => ({ data: successResponse.data, error: null }); // Set up failed request helpers. sendRequestSpy - .withArgs(errorRequest.path, { - body: JSON.stringify(errorRequest.body), - query: undefined, - }) + .withArgs( + errorRequest.path, + sinon.match({ + body: JSON.stringify(errorRequest.body), + query: undefined, + }) + ) .rejects(errorResponse); const sendErrorRequest = () => sendRequest({ ...errorRequest }); const getErrorResponse = () => ({ diff --git a/src/plugins/es_ui_shared/public/request/send_request.ts b/src/plugins/es_ui_shared/public/request/send_request.ts index 32703f21a4668..11ab99cfb6978 100644 --- a/src/plugins/es_ui_shared/public/request/send_request.ts +++ b/src/plugins/es_ui_shared/public/request/send_request.ts @@ -13,6 +13,11 @@ export interface SendRequestConfig { method: 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head'; query?: HttpFetchQuery; body?: any; + /** + * If set, flags this as a "system request" to indicate that this is not a user-initiated request. For more information, see + * HttpFetchOptions#asSystemRequest. + */ + asSystemRequest?: boolean; } export interface SendRequestResponse { @@ -22,11 +27,15 @@ export interface SendRequestResponse { export const sendRequest = async ( httpClient: HttpSetup, - { path, method, body, query }: SendRequestConfig + { path, method, body, query, asSystemRequest }: SendRequestConfig ): Promise> => { try { const stringifiedBody = typeof body === 'string' ? body : JSON.stringify(body); - const response = await httpClient[method](path, { body: stringifiedBody, query }); + const response = await httpClient[method](path, { + body: stringifiedBody, + query, + asSystemRequest, + }); return { data: response.data ? response.data : response, diff --git a/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx b/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx index 9f41d13112bc8..82d3764dbf72a 100644 --- a/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx +++ b/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx @@ -123,10 +123,13 @@ export const createUseRequestHelpers = (): UseRequestHelpers => { // Set up successful request helpers. sendRequestSpy - .withArgs(successRequest.path, { - body: JSON.stringify(successRequest.body), - query: undefined, - }) + .withArgs( + successRequest.path, + sinon.match({ + body: JSON.stringify(successRequest.body), + query: undefined, + }) + ) .resolves(successResponse); const setupSuccessRequest = (overrides = {}, requestTimings?: number[]) => setupUseRequest({ ...successRequest, ...overrides }, requestTimings); @@ -134,10 +137,13 @@ export const createUseRequestHelpers = (): UseRequestHelpers => { // Set up failed request helpers. sendRequestSpy - .withArgs(errorRequest.path, { - body: JSON.stringify(errorRequest.body), - query: undefined, - }) + .withArgs( + errorRequest.path, + sinon.match({ + body: JSON.stringify(errorRequest.body), + query: undefined, + }) + ) .rejects(errorResponse); const setupErrorRequest = (overrides = {}, requestTimings?: number[]) => setupUseRequest({ ...errorRequest, ...overrides }, requestTimings); @@ -152,10 +158,13 @@ export const createUseRequestHelpers = (): UseRequestHelpers => { // Set up failed request helpers with the alternative error shape. sendRequestSpy - .withArgs(errorWithBodyRequest.path, { - body: JSON.stringify(errorWithBodyRequest.body), - query: undefined, - }) + .withArgs( + errorWithBodyRequest.path, + sinon.match({ + body: JSON.stringify(errorWithBodyRequest.body), + query: undefined, + }) + ) .rejects(errorWithBodyResponse); const setupErrorWithBodyRequest = (overrides = {}) => setupUseRequest({ ...errorWithBodyRequest, ...overrides }); diff --git a/src/plugins/es_ui_shared/public/request/use_request.ts b/src/plugins/es_ui_shared/public/request/use_request.ts index 99eb38ff6023f..33085bdbf4478 100644 --- a/src/plugins/es_ui_shared/public/request/use_request.ts +++ b/src/plugins/es_ui_shared/public/request/use_request.ts @@ -65,49 +65,59 @@ export const useRequest = ( /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [path, method, queryStringified, bodyStringified]); - const resendRequest = useCallback(async () => { - // If we're on an interval, this allows us to reset it if the user has manually requested the - // data, to avoid doubled-up requests. - clearPollInterval(); - - const requestId = ++requestCountRef.current; - - // We don't clear error or data, so it's up to the consumer to decide whether to display the - // "old" error/data or loading state when a new request is in-flight. - setIsLoading(true); - - const response = await sendRequest(httpClient, requestBody); - const { data: serializedResponseData, error: responseError } = response; - - const isOutdatedRequest = requestId !== requestCountRef.current; - const isUnmounted = isMounted.current === false; - - // Ignore outdated or irrelevant data. - if (isOutdatedRequest || isUnmounted) { - return; - } - - // Surface to consumers that at least one request has resolved. - isInitialRequestRef.current = false; + const resendRequest = useCallback( + async (asSystemRequest?: boolean) => { + // If we're on an interval, this allows us to reset it if the user has manually requested the + // data, to avoid doubled-up requests. + clearPollInterval(); - setError(responseError); - // If there's an error, keep the data from the last request in case it's still useful to the user. - if (!responseError) { - const responseData = deserializer - ? deserializer(serializedResponseData) - : serializedResponseData; - setData(responseData); - } - // Setting isLoading to false also acts as a signal for scheduling the next poll request. - setIsLoading(false); - }, [requestBody, httpClient, deserializer, clearPollInterval]); + const requestId = ++requestCountRef.current; + + // We don't clear error or data, so it's up to the consumer to decide whether to display the + // "old" error/data or loading state when a new request is in-flight. + setIsLoading(true); + + // Any requests that are sent in the background (without user interaction) should be flagged as "system requests". This should not be + // confused with any terminology in Elasticsearch. This is a Kibana-specific construct that allows the server to differentiate between + // user-initiated and requests "system"-initiated requests, for purposes like security features. + const requestPayload = { ...requestBody, asSystemRequest }; + const response = await sendRequest(httpClient, requestPayload); + const { data: serializedResponseData, error: responseError } = response; + + const isOutdatedRequest = requestId !== requestCountRef.current; + const isUnmounted = isMounted.current === false; + + // Ignore outdated or irrelevant data. + if (isOutdatedRequest || isUnmounted) { + return; + } + + // Surface to consumers that at least one request has resolved. + isInitialRequestRef.current = false; + + setError(responseError); + // If there's an error, keep the data from the last request in case it's still useful to the user. + if (!responseError) { + const responseData = deserializer + ? deserializer(serializedResponseData) + : serializedResponseData; + setData(responseData); + } + // Setting isLoading to false also acts as a signal for scheduling the next poll request. + setIsLoading(false); + }, + [requestBody, httpClient, deserializer, clearPollInterval] + ); const scheduleRequest = useCallback(() => { // If there's a scheduled poll request, this new one will supersede it. clearPollInterval(); if (pollIntervalMs) { - pollIntervalIdRef.current = setTimeout(resendRequest, pollIntervalMs); + pollIntervalIdRef.current = setTimeout( + () => resendRequest(true), // This is happening on an interval in the background, so we flag it as a "system request". + pollIntervalMs + ); } }, [pollIntervalMs, resendRequest, clearPollInterval]); @@ -137,11 +147,15 @@ export const useRequest = ( }; }, [clearPollInterval]); + const resendRequestForConsumer = useCallback(() => { + return resendRequest(); + }, [resendRequest]); + return { isInitialRequest: isInitialRequestRef.current, isLoading, error, data, - resendRequest, // Gives the user the ability to manually request data + resendRequest: resendRequestForConsumer, // Gives the user the ability to manually request data }; }; diff --git a/src/plugins/saved_objects_tagging_oss/common/index.ts b/src/plugins/saved_objects_tagging_oss/common/index.ts index 231bec46f57ab..a892f41c69314 100644 --- a/src/plugins/saved_objects_tagging_oss/common/index.ts +++ b/src/plugins/saved_objects_tagging_oss/common/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { Tag, TagAttributes, ITagsClient } from './types'; +export { Tag, TagAttributes, GetAllTagsOptions, ITagsClient } from './types'; diff --git a/src/plugins/saved_objects_tagging_oss/common/types.ts b/src/plugins/saved_objects_tagging_oss/common/types.ts index 344e18a5fd76d..205f6984ed618 100644 --- a/src/plugins/saved_objects_tagging_oss/common/types.ts +++ b/src/plugins/saved_objects_tagging_oss/common/types.ts @@ -19,10 +19,14 @@ export interface TagAttributes { color: string; } +export interface GetAllTagsOptions { + asSystemRequest?: boolean; +} + export interface ITagsClient { create(attributes: TagAttributes): Promise; get(id: string): Promise; - getAll(): Promise; + getAll(options?: GetAllTagsOptions): Promise; delete(id: string): Promise; update(id: string, attributes: TagAttributes): Promise; } diff --git a/src/plugins/usage_collection/public/services/create_reporter.ts b/src/plugins/usage_collection/public/services/create_reporter.ts index ef4c007735ff4..e5006646fe368 100644 --- a/src/plugins/usage_collection/public/services/create_reporter.ts +++ b/src/plugins/usage_collection/public/services/create_reporter.ts @@ -24,6 +24,7 @@ export function createReporter(config: AnalyicsReporterConfig): Reporter { async http(report) { const response = await fetch.post('/api/ui_counters/_report', { body: JSON.stringify({ report }), + asSystemRequest: true, }); if (response.status !== 'ok') { diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/api.js b/x-pack/plugins/cross_cluster_replication/public/app/services/api.js index e1be717db221c..8067b2cc11b9a 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/api.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/api.js @@ -48,7 +48,8 @@ export const getHttpClient = () => { const createIdString = (ids) => ids.map((id) => encodeURIComponent(id)).join(','); /* Auto Follow Pattern */ -export const loadAutoFollowPatterns = () => httpClient.get(`${API_BASE_PATH}/auto_follow_patterns`); +export const loadAutoFollowPatterns = (asSystemRequest) => + httpClient.get(`${API_BASE_PATH}/auto_follow_patterns`, { asSystemRequest }); export const getAutoFollowPattern = (id) => httpClient.get(`${API_BASE_PATH}/auto_follow_patterns/${encodeURIComponent(id)}`); @@ -100,7 +101,8 @@ export const resumeAutoFollowPattern = (id) => { }; /* Follower Index */ -export const loadFollowerIndices = () => httpClient.get(`${API_BASE_PATH}/follower_indices`); +export const loadFollowerIndices = (asSystemRequest) => + httpClient.get(`${API_BASE_PATH}/follower_indices`, { asSystemRequest }); export const getFollowerIndex = (id) => httpClient.get(`${API_BASE_PATH}/follower_indices/${encodeURIComponent(id)}`); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js index e6a9f02b913ca..79d0eeabb817d 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js @@ -39,7 +39,7 @@ export const loadAutoFollowPatterns = (isUpdating = false) => label: t.AUTO_FOLLOW_PATTERN_LOAD, scope, status: isUpdating ? API_STATUS.UPDATING : API_STATUS.LOADING, - handler: async () => await loadAutoFollowPatternsRequest(), + handler: async () => await loadAutoFollowPatternsRequest(isUpdating), }); export const getAutoFollowPattern = (id) => diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js index 9f8b20622d6ec..7422ba6c84491 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js @@ -40,7 +40,7 @@ export const loadFollowerIndices = (isUpdating = false) => label: t.FOLLOWER_INDEX_LOAD, scope, status: isUpdating ? API_STATUS.UPDATING : API_STATUS.LOADING, - handler: async () => await loadFollowerIndicesRequest(), + handler: async () => await loadFollowerIndicesRequest(isUpdating), }); export const getFollowerIndex = (id) => diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.js index a435d9be54864..93ad0e0dc3be5 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.js @@ -76,8 +76,8 @@ const mapDispatchToProps = (dispatch) => { loadIndices: () => { dispatch(loadIndices()); }, - reloadIndices: (indexNames) => { - dispatch(reloadIndices(indexNames)); + reloadIndices: (indexNames, options) => { + dispatch(reloadIndices(indexNames, options)); }, }; }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js index d966c39b76c17..f488290692e7e 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js @@ -103,7 +103,11 @@ export class IndexTable extends Component { componentDidMount() { this.props.loadIndices(); this.interval = setInterval( - () => this.props.reloadIndices(this.props.indices.map((i) => i.name)), + () => + this.props.reloadIndices( + this.props.indices.map((i) => i.name), + { asSystemRequest: true } + ), REFRESH_RATE_INDEX_LIST ); const { location, filterChanged } = this.props; diff --git a/x-pack/plugins/index_management/public/application/services/api.ts b/x-pack/plugins/index_management/public/application/services/api.ts index ad080b0723b1c..a7109854d676f 100644 --- a/x-pack/plugins/index_management/public/application/services/api.ts +++ b/x-pack/plugins/index_management/public/application/services/api.ts @@ -40,6 +40,10 @@ import { useRequest, sendRequest } from './use_request'; import { httpService } from './http'; import { UiMetricService } from './ui_metric'; +interface ReloadIndicesOptions { + asSystemRequest?: boolean; +} + // Temporary hack to provide the uiMetricService instance to this file. // TODO: Refactor and export an ApiService instance through the app dependencies context let uiMetricService: UiMetricService; @@ -78,11 +82,17 @@ export async function loadIndices() { return response.data ? response.data : response; } -export async function reloadIndices(indexNames: string[]) { +export async function reloadIndices( + indexNames: string[], + { asSystemRequest }: ReloadIndicesOptions = {} +) { const body = JSON.stringify({ indexNames, }); - const response = await httpService.httpClient.post(`${API_BASE_PATH}/indices/reload`, { body }); + const response = await httpService.httpClient.post(`${API_BASE_PATH}/indices/reload`, { + body, + asSystemRequest, + }); return response.data ? response.data : response; } diff --git a/x-pack/plugins/index_management/public/application/store/actions/reload_indices.js b/x-pack/plugins/index_management/public/application/store/actions/reload_indices.js index 71838d61c20f8..9498e55154839 100644 --- a/x-pack/plugins/index_management/public/application/store/actions/reload_indices.js +++ b/x-pack/plugins/index_management/public/application/store/actions/reload_indices.js @@ -12,10 +12,10 @@ import { loadIndices } from './load_indices'; import { notificationService } from '../../services/notification'; export const reloadIndicesSuccess = createAction('INDEX_MANAGEMENT_RELOAD_INDICES_SUCCESS'); -export const reloadIndices = (indexNames) => async (dispatch) => { +export const reloadIndices = (indexNames, options) => async (dispatch) => { let indices; try { - indices = await request(indexNames); + indices = await request(indexNames, options); } catch (error) { // an index has been deleted // or the user does not have privileges for one of the indices on the current page, diff --git a/x-pack/plugins/monitoring/public/lib/elasticsearch_settings/checkers/settings_checker.js b/x-pack/plugins/monitoring/public/lib/elasticsearch_settings/checkers/settings_checker.js index 8f19fb6ab87be..92a172f4ef3df 100644 --- a/x-pack/plugins/monitoring/public/lib/elasticsearch_settings/checkers/settings_checker.js +++ b/x-pack/plugins/monitoring/public/lib/elasticsearch_settings/checkers/settings_checker.js @@ -44,7 +44,9 @@ export class SettingsChecker { async executeCheck() { try { - const { data } = await this.$http.get(this.getApi()); + const { data } = await this.$http.get(this.getApi(), { + headers: { 'kbn-system-request': 'true' }, + }); const { found, reason } = data; return { found, reason }; diff --git a/x-pack/plugins/monitoring/public/services/clusters.js b/x-pack/plugins/monitoring/public/services/clusters.js index 638b3a91b9874..71ae128072b7f 100644 --- a/x-pack/plugins/monitoring/public/services/clusters.js +++ b/x-pack/plugins/monitoring/public/services/clusters.js @@ -38,14 +38,18 @@ export function monitoringClustersProvider($injector) { async function getClusters() { try { - const response = await $http.post(url, { - ccs, - timeRange: { - min: min.toISOString(), - max: max.toISOString(), + const response = await $http.post( + url, + { + ccs, + timeRange: { + min: min.toISOString(), + max: max.toISOString(), + }, + codePaths, }, - codePaths, - }); + { headers: { 'kbn-system-request': 'true' } } + ); return formatClusters(response.data); } catch (err) { const Private = $injector.get('Private'); diff --git a/x-pack/plugins/remote_clusters/public/application/services/api.js b/x-pack/plugins/remote_clusters/public/application/services/api.js index c0d21f577dae8..6dd04b7090283 100644 --- a/x-pack/plugins/remote_clusters/public/application/services/api.js +++ b/x-pack/plugins/remote_clusters/public/application/services/api.js @@ -9,8 +9,8 @@ import { UIM_CLUSTER_ADD, UIM_CLUSTER_UPDATE } from '../constants'; import { trackUserRequest } from './ui_metric'; import { sendGet, sendPost, sendPut, sendDelete } from './http'; -export async function loadClusters() { - return await sendGet(); +export async function loadClusters(options) { + return await sendGet(undefined, options); } export async function addCluster(cluster) { diff --git a/x-pack/plugins/remote_clusters/public/application/services/http.ts b/x-pack/plugins/remote_clusters/public/application/services/http.ts index 7f205023dfa8a..831e706b5fa08 100644 --- a/x-pack/plugins/remote_clusters/public/application/services/http.ts +++ b/x-pack/plugins/remote_clusters/public/application/services/http.ts @@ -10,11 +10,15 @@ import { API_BASE_PATH } from '../../../common/constants'; let _httpClient: HttpSetup; +interface SendGetOptions { + asSystemRequest?: boolean; +} + export function init(httpClient: HttpSetup): void { _httpClient = httpClient; } -export function getFullPath(path: string): string { +export function getFullPath(path?: string): string { if (path) { return `${API_BASE_PATH}/${path}`; } @@ -35,8 +39,11 @@ export function sendPost( }); } -export function sendGet(path: string): Promise { - return _httpClient.get(getFullPath(path)); +export function sendGet( + path?: string, + { asSystemRequest }: SendGetOptions = {} +): Promise { + return _httpClient.get(getFullPath(path), { asSystemRequest }); } export function sendPut( diff --git a/x-pack/plugins/remote_clusters/public/application/store/actions/refresh_clusters.js b/x-pack/plugins/remote_clusters/public/application/store/actions/refresh_clusters.js index 8a765e171a8af..3dae779f0dc78 100644 --- a/x-pack/plugins/remote_clusters/public/application/store/actions/refresh_clusters.js +++ b/x-pack/plugins/remote_clusters/public/application/store/actions/refresh_clusters.js @@ -14,7 +14,7 @@ import { REFRESH_CLUSTERS_SUCCESS } from '../action_types'; export const refreshClusters = () => async (dispatch) => { let clusters; try { - clusters = await sendLoadClustersRequest(); + clusters = await sendLoadClustersRequest({ asSystemRequest: true }); } catch (error) { return showApiWarning( error, diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.container.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.container.js index e71b3b6870267..ce7e29af8323d 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.container.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.container.js @@ -32,8 +32,8 @@ const mapDispatchToProps = (dispatch) => { loadJobs: () => { dispatch(loadJobs()); }, - refreshJobs: () => { - dispatch(refreshJobs()); + refreshJobs: (options) => { + dispatch(refreshJobs(options)); }, openDetailPanel: (jobId) => { dispatch(openDetailPanel({ jobId: jobId })); diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js index d5038f40a686b..589546a11ef38 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js @@ -73,7 +73,10 @@ export class JobListUi extends Component { } componentDidMount() { - this.interval = setInterval(this.props.refreshJobs, REFRESH_RATE_MS); + this.interval = setInterval( + () => this.props.refreshJobs({ asSystemRequest: true }), + REFRESH_RATE_MS + ); } componentWillUnmount() { diff --git a/x-pack/plugins/rollup/public/crud_app/services/api.js b/x-pack/plugins/rollup/public/crud_app/services/api.js index 66efb6c2f09a0..b12cc62c9daa8 100644 --- a/x-pack/plugins/rollup/public/crud_app/services/api.js +++ b/x-pack/plugins/rollup/public/crud_app/services/api.js @@ -19,8 +19,9 @@ import { trackUserRequest } from './track_ui_metric'; const apiPrefix = '/api/rollup'; -export async function loadJobs() { - const { jobs } = await getHttp().get(`${apiPrefix}/jobs`); +export async function loadJobs({ asSystemRequest } = {}) { + const fetchOptions = { asSystemRequest }; + const { jobs } = await getHttp().get(`${apiPrefix}/jobs`, fetchOptions); return jobs; } diff --git a/x-pack/plugins/rollup/public/crud_app/store/actions/refresh_jobs.js b/x-pack/plugins/rollup/public/crud_app/store/actions/refresh_jobs.js index 37b6e7a893fbe..562341a020523 100644 --- a/x-pack/plugins/rollup/public/crud_app/store/actions/refresh_jobs.js +++ b/x-pack/plugins/rollup/public/crud_app/store/actions/refresh_jobs.js @@ -10,10 +10,10 @@ import { i18n } from '@kbn/i18n'; import { loadJobs as sendLoadJobsRequest, deserializeJobs, showApiWarning } from '../../services'; import { REFRESH_JOBS_SUCCESS } from '../action_types'; -export const refreshJobs = () => async (dispatch) => { +export const refreshJobs = (options) => async (dispatch) => { let jobs; try { - jobs = await sendLoadJobsRequest(); + jobs = await sendLoadJobsRequest(options); } catch (error) { return showApiWarning( error, diff --git a/x-pack/plugins/saved_objects_tagging/common/types.ts b/x-pack/plugins/saved_objects_tagging/common/types.ts index bd65f74044bc1..c0b92a71a3d1b 100644 --- a/x-pack/plugins/saved_objects_tagging/common/types.ts +++ b/x-pack/plugins/saved_objects_tagging/common/types.ts @@ -21,5 +21,6 @@ export type TagWithRelations = Tag & { export type { Tag, TagAttributes, + GetAllTagsOptions, ITagsClient, } from '../../../../src/plugins/saved_objects_tagging_oss/common'; diff --git a/x-pack/plugins/saved_objects_tagging/public/plugin.ts b/x-pack/plugins/saved_objects_tagging/public/plugin.ts index 9821bfb397802..d4e3f8678fe1f 100644 --- a/x-pack/plugins/saved_objects_tagging/public/plugin.ts +++ b/x-pack/plugins/saved_objects_tagging/public/plugin.ts @@ -66,7 +66,7 @@ export class SavedObjectTaggingPlugin public start({ http, application, overlays }: CoreStart) { this.tagCache = new TagsCache({ - refreshHandler: () => this.tagClient!.getAll(), + refreshHandler: () => this.tagClient!.getAll({ asSystemRequest: true }), refreshInterval: this.config.cacheRefreshInterval, }); this.tagClient = new TagsClient({ http, changeListener: this.tagCache }); diff --git a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.test.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.test.ts index 39e2df073591e..24409e8596265 100644 --- a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.test.ts @@ -156,7 +156,17 @@ describe('TagsClient', () => { await tagsClient.getAll(); expect(http.get).toHaveBeenCalledTimes(1); - expect(http.get).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags`); + expect(http.get).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags`, { + asSystemRequest: undefined, + }); + }); + it('allows `asSystemRequest` option to be set', async () => { + await tagsClient.getAll({ asSystemRequest: true }); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(http.get).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags`, { + asSystemRequest: true, + }); }); it('returns the tag objects from the response', async () => { const tags = await tagsClient.getAll(); diff --git a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts index 8a99af7af6d02..ef484f0a550b1 100644 --- a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts +++ b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts @@ -6,7 +6,13 @@ */ import { HttpSetup } from 'src/core/public'; -import { Tag, TagAttributes, ITagsClient, TagWithRelations } from '../../../common/types'; +import { + Tag, + TagAttributes, + GetAllTagsOptions, + ITagsClient, + TagWithRelations, +} from '../../../common/types'; import { ITagsChangeListener } from './tags_cache'; export interface TagsClientOptions { @@ -83,8 +89,12 @@ export class TagsClient implements ITagInternalClient { return tag; } - public async getAll() { - const { tags } = await this.http.get<{ tags: Tag[] }>('/api/saved_objects_tagging/tags'); + public async getAll({ asSystemRequest }: GetAllTagsOptions = {}) { + const fetchOptions = { asSystemRequest }; + const { tags } = await this.http.get<{ tags: Tag[] }>( + '/api/saved_objects_tagging/tags', + fetchOptions + ); trapErrors(() => { if (this.changeListener) { diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index 580641cb86bc2..388bc8b432fc4 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -54,6 +54,10 @@ export interface FieldHistogramRequestConfig { type?: KBN_FIELD_TYPES; } +interface FetchOptions { + asSystemRequest?: boolean; +} + export const useApi = () => { const { http } = useAppDependencies(); @@ -68,9 +72,11 @@ export const useApi = () => { return e; } }, - async getTransforms(): Promise { + async getTransforms( + fetchOptions: FetchOptions = {} + ): Promise { try { - return await http.get(`${API_BASE_PATH}transforms`); + return await http.get(`${API_BASE_PATH}transforms`, fetchOptions); } catch (e) { return e; } @@ -84,9 +90,11 @@ export const useApi = () => { return e; } }, - async getTransformsStats(): Promise { + async getTransformsStats( + fetchOptions: FetchOptions = {} + ): Promise { try { - return await http.get(`${API_BASE_PATH}transforms/_stats`); + return await http.get(`${API_BASE_PATH}transforms/_stats`, fetchOptions); } catch (e) { return e; } diff --git a/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts b/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts index 919131341cd5b..dbb268b44cfd2 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts @@ -39,8 +39,9 @@ export const useGetTransforms = ( return; } - const transformConfigs = await api.getTransforms(); - const transformStats = await api.getTransformsStats(); + const fetchOptions = { asSystemRequest: true }; + const transformConfigs = await api.getTransforms(fetchOptions); + const transformStats = await api.getTransformsStats(fetchOptions); if ( !isGetTransformsResponseSchema(transformConfigs) || From 7f1071149da6358e3d543052932c0069a03ebcd8 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 16 Feb 2021 13:46:02 +0100 Subject: [PATCH 23/23] fix readonly error (#91104) --- x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts | 6 ++++++ x-pack/plugins/lens/server/routes/telemetry.ts | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts b/x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts index b09d757b37141..f010c0b8114b5 100644 --- a/x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts +++ b/x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts @@ -98,6 +98,12 @@ export class LensReportManager { this.write(); } catch (e) { // Silent error because events will be reported during the next timer + + // If posting stats is forbidden for the current user, stop attempting to send them, + // but keep them in storage to push in case the user logs in with sufficient permissions at some point. + if (e.response && e.response.status === 403) { + this.stop(); + } } } } diff --git a/x-pack/plugins/lens/server/routes/telemetry.ts b/x-pack/plugins/lens/server/routes/telemetry.ts index d4eec5beaba90..cb8cf4b15f8d9 100644 --- a/x-pack/plugins/lens/server/routes/telemetry.ts +++ b/x-pack/plugins/lens/server/routes/telemetry.ts @@ -9,6 +9,7 @@ import Boom from '@hapi/boom'; import { errors } from '@elastic/elasticsearch'; import { CoreSetup } from 'src/core/server'; import { schema } from '@kbn/config-schema'; +import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; import { BASE_API_URL } from '../../common'; import { PluginStartContract } from '../plugin'; @@ -73,6 +74,9 @@ export async function initLensUsageRoute(setup: CoreSetup) return res.ok({ body: {} }); } catch (e) { + if (SavedObjectsErrorHelpers.isForbiddenError(e)) { + return res.forbidden(); + } if (e instanceof errors.ResponseError && e.statusCode === 404) { return res.notFound(); }