From f5e66d6a2398e9b2fb82482f2568472cd77a8842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Tue, 25 Jan 2022 14:33:28 +0100 Subject: [PATCH 01/46] [Index Management] Indices list only load specific index properties from ES (#123629) --- x-pack/plugins/index_management/server/lib/fetch_indices.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/index_management/server/lib/fetch_indices.ts b/x-pack/plugins/index_management/server/lib/fetch_indices.ts index 99d2b8e2b236b..84d9897cc3392 100644 --- a/x-pack/plugins/index_management/server/lib/fetch_indices.ts +++ b/x-pack/plugins/index_management/server/lib/fetch_indices.ts @@ -19,6 +19,8 @@ async function fetchIndicesCall( const { body: indices } = await client.asCurrentUser.indices.get({ index: indexNamesString, expand_wildcards: ['hidden', 'all'], + // only get specified properties in the response + filter_path: ['*.aliases', '*.settings.index.hidden', '*.data_stream'], }); if (!Object.keys(indices).length) { @@ -53,7 +55,7 @@ async function fetchIndicesCall( isFrozen: hit.sth === 'true', // sth value coming back as a string from ES aliases: aliases.length ? aliases : 'none', // @ts-expect-error @elastic/elasticsearch https://github.com/elastic/elasticsearch-specification/issues/532 - hidden: index.settings.index.hidden === 'true', + hidden: index.settings?.index.hidden === 'true', data_stream: index.data_stream!, }); } From 791b31db2e9273fc003263c874bb9f1bd518d65e Mon Sep 17 00:00:00 2001 From: Max Kovalev Date: Tue, 25 Jan 2022 15:43:26 +0200 Subject: [PATCH 02/46] [Maps] Delete button should be toggleable in Edit Features (#122017) * 116700 - Drawing buttons now have a toggle mode * e-116700 - Refactoring and fixing issue with cursor symbol when drawing is disabled * 116700 - refactoring * 116700 - refactoring Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../mb_map/draw_control/draw_control.tsx | 14 +++++++----- .../feature_edit_tools/feature_edit_tools.tsx | 22 +++++++++++++------ .../feature_edit_tools/index.ts | 2 +- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx index 11b8c80e9fdec..a404db91a942e 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx @@ -115,23 +115,23 @@ export class DrawControl extends Component { } _updateDrawControl() { - if (!this.props.drawShape) { - return; - } - if (!this._mbDrawControlAdded) { this.props.mbMap.addControl(this._mbDrawControl); this._mbDrawControlAdded = true; - this.props.mbMap.getCanvas().style.cursor = 'crosshair'; this.props.mbMap.on('draw.modechange', this._onModeChange); this.props.mbMap.on('draw.create', this._onDraw); + if (this.props.onClick) { this.props.mbMap.on('click', this._onClick); } } + this.props.mbMap.getCanvas().style.cursor = + !this.props.drawShape || this.props.drawShape === DRAW_SHAPE.SIMPLE_SELECT ? '' : 'crosshair'; + const { DRAW_LINE_STRING, DRAW_POLYGON, DRAW_POINT, SIMPLE_SELECT } = this._mbDrawControl.modes; const drawMode = this._mbDrawControl.getMode(); + if (drawMode !== DRAW_RECTANGLE && this.props.drawShape === DRAW_SHAPE.BOUNDS) { this._mbDrawControl.changeMode(DRAW_RECTANGLE); } else if (drawMode !== DRAW_CIRCLE && this.props.drawShape === DRAW_SHAPE.DISTANCE) { @@ -142,7 +142,9 @@ export class DrawControl extends Component { this._mbDrawControl.changeMode(DRAW_LINE_STRING); } else if (drawMode !== DRAW_POINT && this.props.drawShape === DRAW_SHAPE.POINT) { this._mbDrawControl.changeMode(DRAW_POINT); - } else if (drawMode !== SIMPLE_SELECT && this.props.drawShape === DRAW_SHAPE.SIMPLE_SELECT) { + } else if (this.props.drawShape === DRAW_SHAPE.DELETE) { + this._mbDrawControl.changeMode(SIMPLE_SELECT); + } else { this._mbDrawControl.changeMode(SIMPLE_SELECT); } } diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/feature_draw_controls/feature_edit_tools/feature_edit_tools.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/feature_draw_controls/feature_edit_tools/feature_edit_tools.tsx index 994b36ff3934e..1179f3557c62c 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/feature_draw_controls/feature_edit_tools/feature_edit_tools.tsx +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/feature_draw_controls/feature_edit_tools/feature_edit_tools.tsx @@ -19,7 +19,7 @@ export interface ReduxStateProps { } export interface ReduxDispatchProps { - setDrawShape: (shapeToDraw: DRAW_SHAPE) => void; + setDrawShape: (shapeToDraw: DRAW_SHAPE | null) => void; } export interface OwnProps { @@ -36,6 +36,14 @@ export function FeatureEditTools(props: Props) { const drawPointSelected = props.drawShape === DRAW_SHAPE.POINT; const deleteSelected = props.drawShape === DRAW_SHAPE.DELETE; + function toggleDrawShape(mode: DRAW_SHAPE) { + if (mode && props.drawShape === mode) { + props.setDrawShape(null); + } else { + props.setDrawShape(mode); + } + } + return ( props.setDrawShape(DRAW_SHAPE.LINE)} + onClick={() => toggleDrawShape(DRAW_SHAPE.LINE)} iconType={VectorLineIcon} aria-label={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawLineLabel', { defaultMessage: 'Draw line', @@ -63,7 +71,7 @@ export function FeatureEditTools(props: Props) { props.setDrawShape(DRAW_SHAPE.POLYGON)} + onClick={() => toggleDrawShape(DRAW_SHAPE.POLYGON)} iconType="node" aria-label={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawPolygonLabel', { defaultMessage: 'Draw polygon', @@ -78,7 +86,7 @@ export function FeatureEditTools(props: Props) { props.setDrawShape(DRAW_SHAPE.DISTANCE)} + onClick={() => toggleDrawShape(DRAW_SHAPE.DISTANCE)} iconType={VectorCircleIcon} aria-label={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawCircleLabel', { defaultMessage: 'Draw circle', @@ -93,7 +101,7 @@ export function FeatureEditTools(props: Props) { props.setDrawShape(DRAW_SHAPE.BOUNDS)} + onClick={() => toggleDrawShape(DRAW_SHAPE.BOUNDS)} iconType={VectorSquareIcon} aria-label={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawBBoxLabel', { defaultMessage: 'Draw bounding box', @@ -110,7 +118,7 @@ export function FeatureEditTools(props: Props) { props.setDrawShape(DRAW_SHAPE.POINT)} + onClick={() => toggleDrawShape(DRAW_SHAPE.POINT)} iconType="dot" aria-label={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawPointLabel', { defaultMessage: 'Draw point', @@ -125,7 +133,7 @@ export function FeatureEditTools(props: Props) { props.setDrawShape(DRAW_SHAPE.DELETE)} + onClick={() => toggleDrawShape(DRAW_SHAPE.DELETE)} iconType="trash" aria-label={i18n.translate( 'xpack.maps.toolbarOverlay.featureDraw.deletePointOrShapeLabel', diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/feature_draw_controls/feature_edit_tools/index.ts b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/feature_draw_controls/feature_edit_tools/index.ts index 7e993407b821c..4b9854aaca0b9 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/feature_draw_controls/feature_edit_tools/index.ts +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/feature_draw_controls/feature_edit_tools/index.ts @@ -30,7 +30,7 @@ function mapDispatchToProps( dispatch: ThunkDispatch ): ReduxDispatchProps { return { - setDrawShape: (shapeToDraw: DRAW_SHAPE) => { + setDrawShape: (shapeToDraw: DRAW_SHAPE | null) => { dispatch(updateEditShape(shapeToDraw)); }, }; From aaf162103a67a766f51e0886bd7180f691139f4f Mon Sep 17 00:00:00 2001 From: Max Kovalev Date: Tue, 25 Jan 2022 15:43:55 +0200 Subject: [PATCH 03/46] [Maps] change "show as" from EuiSelect to EuiButtonGroup (#121960) * 121897 - changed 'show as' from select to buttons group * 121897 - Refactoring of component * 121897 - refactoring Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../es_geo_grid_source/render_as_select.tsx | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select.tsx index 641dac31fe166..17fec469fe4ae 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select.tsx @@ -6,18 +6,20 @@ */ import React from 'react'; -import { EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { EuiFormRow, EuiButtonGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { RENDER_AS } from '../../../../common/constants'; const options = [ { + id: RENDER_AS.POINT, label: i18n.translate('xpack.maps.source.esGeoGrid.pointsDropdownOption', { defaultMessage: 'clusters', }), value: RENDER_AS.POINT, }, { + id: RENDER_AS.GRID, label: i18n.translate('xpack.maps.source.esGeoGrid.gridRectangleDropdownOption', { defaultMessage: 'grids', }), @@ -30,21 +32,17 @@ export function RenderAsSelect(props: { onChange: (newValue: RENDER_AS) => void; isColumnCompressed?: boolean; }) { + const currentOption = options.find((option) => option.value === props.renderAs) || options[0]; + if (props.renderAs === RENDER_AS.HEATMAP) { return null; } - function onChange(selectedOptions: Array>) { - if (!selectedOptions || !selectedOptions.length) { - return; + function onChange(id: string) { + const data = options.find((option) => option.id === id); + if (data) { + props.onChange(data.value as RENDER_AS); } - props.onChange(selectedOptions[0].value as RENDER_AS); - } - - const selectedOptions = []; - const selectedOption = options.find((option) => option.value === props.renderAs); - if (selectedOption) { - selectedOptions.push(selectedOption); } return ( @@ -54,13 +52,16 @@ export function RenderAsSelect(props: { })} display={props.isColumnCompressed ? 'columnCompressed' : 'row'} > - ); From 10c96e5c0d9a65086df4b0ef8e5211b8a986303e Mon Sep 17 00:00:00 2001 From: Max Kovalev Date: Tue, 25 Jan 2022 15:44:18 +0200 Subject: [PATCH 04/46] [Maps] Should be able to zoom-in on selected range of timeslider (#122131) * 105596 - Added calendar button that allows users to set global time of kibana according selected time slice * #105596 - refactoring; Title and aria-label updated for zoom-in button * #105596 - icon filling was removed Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../map_container/map_container.tsx | 15 +++++++++++++-- .../connected_components/timeslider/_index.scss | 7 ++----- .../timeslider/timeslider.tsx | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx index dda8c406e4ab9..bfc8474fec88e 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx @@ -14,6 +14,7 @@ import uuid from 'uuid/v4'; import { Filter } from '@kbn/es-query'; import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; import { Observable } from 'rxjs'; +import moment from 'moment'; import { MBMap } from '../mb_map'; import { RightSideControls } from '../right_side_controls'; import { Timeslider } from '../timeslider'; @@ -21,7 +22,7 @@ import { ToolbarOverlay } from '../toolbar_overlay'; import { EditLayerPanel } from '../edit_layer_panel'; import { AddLayerPanel } from '../add_layer_panel'; import { ExitFullScreenButton } from '../../../../../../src/plugins/kibana_react/public'; -import { getCoreChrome } from '../../kibana_services'; +import { getCoreChrome, getData } from '../../kibana_services'; import { RawValue } from '../../../common/constants'; import { FLYOUT_STATE } from '../../reducers/ui'; import { MapSettings } from '../../reducers/map'; @@ -156,6 +157,13 @@ export class MapContainer extends Component { }, 5000); }; + _updateGlobalTimeRange(data: number[]) { + getData().query.timefilter.timefilter.setTime({ + from: moment(data[0]).toISOString(), + to: moment(data[1]).toISOString(), + }); + } + render() { const { addFilters, @@ -240,7 +248,10 @@ export class MapContainer extends Component { - + * { - align-items: center; - } - &:first-child { padding: 0 $euiSize; } diff --git a/x-pack/plugins/maps/public/connected_components/timeslider/timeslider.tsx b/x-pack/plugins/maps/public/connected_components/timeslider/timeslider.tsx index c91c6bc5f91c1..eaa2aa158e55a 100644 --- a/x-pack/plugins/maps/public/connected_components/timeslider/timeslider.tsx +++ b/x-pack/plugins/maps/public/connected_components/timeslider/timeslider.tsx @@ -23,6 +23,7 @@ export interface Props { isTimesliderOpen: boolean; timeRange: TimeRange; waitForTimesliceToLoad$: Observable; + updateGlobalTimeRange: (timeslice: number[]) => void; } interface State { @@ -182,6 +183,21 @@ class KeyedTimeslider extends Component { {prettyPrintTimeslice(this.state.timeslice)} + { + this.props.updateGlobalTimeRange(this.state.timeslice); + }} + iconType="calendar" + aria-label={i18n.translate('xpack.maps.timeslider.setGlobalTime', { + defaultMessage: 'Set global time to {timeslice}', + values: { timeslice: prettyPrintTimeslice(this.state.timeslice) }, + })} + title={i18n.translate('xpack.maps.timeslider.setGlobalTime', { + defaultMessage: 'Set global time to {timeslice}', + values: { timeslice: prettyPrintTimeslice(this.state.timeslice) }, + })} + /> +
Date: Tue, 25 Jan 2022 13:49:01 +0000 Subject: [PATCH 05/46] [Fleet] Allow empty strings for required text fields in package policies (#123610) * Allow empty strings for required text fields in package policies * make empty yaml check more explicit --- .../services/validate_package_policy.test.ts | 53 ++++++++++++++++++- .../services/validate_package_policy.ts | 2 +- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts b/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts index 306d31879e0c6..98056d6906c59 100644 --- a/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts +++ b/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts @@ -253,7 +253,7 @@ describe('Fleet - validatePackagePolicy()', () => { enabled: true, vars: { 'foo-input-var-name': { value: undefined, type: 'text' }, - 'foo-input2-var-name': { value: '', type: 'text' }, + 'foo-input2-var-name': { value: undefined, type: 'text' }, 'foo-input3-var-name': { value: [], type: 'text' }, }, streams: [ @@ -555,6 +555,57 @@ describe('Fleet - validatePackagePolicy()', () => { inputs: null, }); }); + + it('returns no errors when required field is present but empty', () => { + expect( + validatePackagePolicy( + { + ...validPackagePolicy, + inputs: [ + { + type: 'foo', + policy_template: 'pkgPolicy1', + enabled: true, + vars: { + 'foo-input-var-name': { value: '', type: 'text' }, + 'foo-input2-var-name': { value: '', type: 'text' }, + 'foo-input3-var-name': { value: ['test'], type: 'text' }, + }, + streams: [ + { + data_stream: { dataset: 'foo', type: 'logs' }, + enabled: true, + vars: { 'var-name': { value: 'test_yaml: value', type: 'yaml' } }, + }, + ], + }, + ], + }, + mockPackage, + safeLoad + ) + ).toEqual({ + name: null, + description: null, + namespace: null, + inputs: { + foo: { + streams: { + foo: { + vars: { + 'var-name': null, + }, + }, + }, + vars: { + 'foo-input-var-name': null, + 'foo-input2-var-name': null, + 'foo-input3-var-name': null, + }, + }, + }, + }); + }); }); describe('works for packages with multiple policy templates (aka integrations)', () => { diff --git a/x-pack/plugins/fleet/common/services/validate_package_policy.ts b/x-pack/plugins/fleet/common/services/validate_package_policy.ts index 8e2744d970371..b6befdf8c790e 100644 --- a/x-pack/plugins/fleet/common/services/validate_package_policy.ts +++ b/x-pack/plugins/fleet/common/services/validate_package_policy.ts @@ -217,7 +217,7 @@ export const validatePackagePolicyConfig = ( } if (varDef.required) { - if (parsedValue === undefined || (typeof parsedValue === 'string' && !parsedValue)) { + if (parsedValue === undefined || (varDef.type === 'yaml' && parsedValue === '')) { errors.push( i18n.translate('xpack.fleet.packagePolicyValidation.requiredErrorMessage', { defaultMessage: '{fieldName} is required', From 95f2967c2443c5685754d4c52670d8a2dc78907e Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Tue, 25 Jan 2022 17:27:43 +0300 Subject: [PATCH 06/46] [Lens] Improve color stop UI (#119165) * First very draft version * Added validation, clean up code * Some fixes * Adapt components to the new UI design * Some fixes * Fix validation * Fix lint errors * Fix metric vis for new color stop UI * Fix problems with keeping state of auto detecting max/min value * Add tests * Fix CI * Fix tests * Fix some lint problems * Fix CI * Fix min/max behavior for heatmap * Fix checks. * Fix auto value when we add new color range * Fix check task * Fix some issues * Some fixes * Fix functional tests * small fix for heatmap * Fix test * Update comment-description * fix PR comments * do some refactoring (work in progress) * do some refactoring (work in progress) * some cleanup * some cleanup * wp: fix validation * wip: fix validation * push some refactoring * do some refactoring * add useDebounce * add useReducer * remove autoValue * fix validation * update validation logic * revert getStopsForFixedMode * some updates * update EuiColorPaletteDisplay palette arg * push some logic * push some logic * update validation messages * push some updates * fix some logic * fix some cases * fix JES * fix CI * reset continuity * fix functional tests * fix issue with -infinite/+infinite * fix CI * push some updates * Update x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_reducer.tsx Co-authored-by: Marco Liberati * Update x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_validation.tsx Co-authored-by: Marco Liberati * fix some comments * make color ranges crud methods "immutable" * fix Max. value input size * fix PR comment * fix tests * Fix edit/min/max buttons behavior * Fix entering decimal values and max/min value behavior * Fix lint * Fix getNormalizedValueByRange for case when min == max * Fix table cell coloring * add warning messages * Move color ranges reducer upper to palette_configuration (#3) * Move color ranges reducer upper to palette_configuration * Remove from local state unnecessary params * Fix some cases * Fix lint * use one dataBounds type across palette configuration * cleanup * Fix some behavior * Fix checks * Some clean up * Some fixes * Some fixes * Fix validation * Fix CI * Add unit tests for color_ranges_crud util * Fix unit test * Add unit tests for color ranges utils.ts * Add allowEditMinMaxValues props and fix validation * Fix CI * Rename allowEditMinMaxValues to disableSwitchingContinuity * Add unit tests for color_ranges_validation * Add unit tests for updateRangeType and changeColorPalette functions * Add unit tests for color_ranges_extra_actions * Fix checks * Clean up code * Some fixes * Fix unit-tests * Fix comments * Some changes * Fix tests * Fix all comments * Fix Checks * Fix CI Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Alexey Antonov Co-authored-by: Marco Liberati --- .../components/heatmap_component.test.tsx | 2 +- .../public/components/heatmap_component.tsx | 4 +- src/plugins/charts/common/index.ts | 5 +- src/plugins/charts/common/palette.ts | 17 +- src/plugins/charts/common/static/index.ts | 2 +- .../charts/common/static/palette/index.ts | 15 + src/plugins/charts/common/types.ts | 2 + .../public/services/palettes/helpers.test.ts | 2 +- .../public/services/palettes/helpers.ts | 97 ++-- .../snapshots/baseline/partial_test_1.json | 2 +- .../snapshots/baseline/tagcloud_all_data.json | 2 +- .../baseline/tagcloud_empty_data.json | 2 +- .../snapshots/baseline/tagcloud_fontsize.json | 2 +- .../baseline/tagcloud_metric_data.json | 2 +- .../snapshots/baseline/tagcloud_options.json | 2 +- x-pack/plugins/lens/common/types.ts | 3 +- .../lens/public/assets/distribute_equally.tsx | 22 + x-pack/plugins/lens/public/assets/related.tsx | 15 + .../plugins/lens/public/assets/value_max.tsx | 21 + .../plugins/lens/public/assets/value_min.tsx | 15 + .../components/dimension_editor.tsx | 3 +- .../datatable_visualization/visualization.tsx | 14 +- .../dimension_editor.tsx | 38 +- .../public/heatmap_visualization/types.ts | 4 +- .../public/heatmap_visualization/utils.ts | 6 +- .../visualization.test.ts | 6 +- .../heatmap_visualization/visualization.tsx | 12 +- .../metric_visualization/dimension_editor.tsx | 10 +- .../metric_visualization/expression.test.tsx | 20 +- .../metric_visualization/expression.tsx | 25 +- .../metric_visualization/visualization.tsx | 8 +- .../color_ranges/color_ranges.test.tsx | 166 ++++++ .../coloring/color_ranges/color_ranges.tsx | 109 ++++ .../color_ranges/color_ranges_context.ts | 20 + .../color_ranges_extra_actions.tsx | 126 +++++ .../color_ranges/color_ranges_item.tsx | 238 +++++++++ .../color_ranges_item_buttons.tsx | 149 ++++++ .../color_ranges_validation.test.ts | 151 ++++++ .../color_ranges/color_ranges_validation.tsx | 154 ++++++ .../coloring/color_ranges/index.tsx | 10 + .../coloring/color_ranges/types.ts | 73 +++ .../utils/color_ranges_crud.test.ts | 123 +++++ .../color_ranges/utils/color_ranges_crud.ts | 115 +++++ .../utils/color_ranges_extra_actions.test.ts | 58 +++ .../utils/color_ranges_extra_actions.ts | 66 +++ .../coloring/color_ranges/utils/index.ts | 10 + .../coloring/color_ranges/utils/utils.test.ts | 148 ++++++ .../coloring/color_ranges/utils/utils.ts | 108 ++++ .../coloring/color_stops.test.tsx | 191 ------- .../coloring/color_stops.tsx | 310 ----------- .../shared_components/coloring/constants.ts | 4 +- .../shared_components/coloring/index.ts | 3 +- .../coloring/palette_configuration.test.tsx | 130 ++--- .../coloring/palette_configuration.tsx | 483 +++++------------- .../coloring/palette_configuration_reducer.ts | 161 ++++++ .../coloring/palette_panel_container.tsx | 6 +- .../coloring/palette_picker.tsx | 5 +- .../shared_components/coloring/types.ts | 42 ++ .../shared_components/coloring/utils.test.ts | 318 +++++++++++- .../shared_components/coloring/utils.ts | 326 ++++++++++-- .../visualizations/gauge/dimension_editor.tsx | 13 +- .../visualizations/gauge/visualization.tsx | 4 +- .../translations/translations/ja-JP.json | 13 - .../translations/translations/zh-CN.json | 13 - x-pack/test/functional/apps/lens/heatmap.ts | 22 +- x-pack/test/functional/apps/lens/metrics.ts | 4 +- x-pack/test/functional/apps/lens/table.ts | 6 +- 67 files changed, 3081 insertions(+), 1177 deletions(-) create mode 100644 src/plugins/charts/common/static/palette/index.ts create mode 100644 x-pack/plugins/lens/public/assets/distribute_equally.tsx create mode 100644 x-pack/plugins/lens/public/assets/related.tsx create mode 100644 x-pack/plugins/lens/public/assets/value_max.tsx create mode 100644 x-pack/plugins/lens/public/assets/value_min.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.test.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_context.ts create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_extra_actions.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_item.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_item_buttons.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_validation.test.ts create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_validation.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/index.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/types.ts create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_crud.test.ts create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_crud.ts create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_extra_actions.test.ts create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_extra_actions.ts create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/index.ts create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/utils.test.ts create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/utils.ts delete mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_stops.test.tsx delete mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_stops.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/palette_configuration_reducer.ts create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/types.ts diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx index b725f9eed3555..da548c3bd1fa3 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx @@ -156,7 +156,7 @@ describe('HeatmapComponent', function () { expect(component.find(Heatmap).prop('colorScale')).toEqual({ bands: [ { color: 'rgb(0, 0, 0)', end: 0, start: 0 }, - { color: 'rgb(112, 38, 231)', end: 150, start: 0 }, + { color: 'rgb(112, 38, 231)', end: 150.00001, start: 0 }, ], type: 'bands', }); diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx index f1fbbccc3c20f..6af0c36323d45 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx @@ -269,8 +269,10 @@ export const HeatmapComponent: FC = memo( ); // adds a very small number to the max value to make sure the max value will be included + const smattering = 0.00001; const endValue = - paletteParams && paletteParams.range === 'number' ? paletteParams.rangeMax : max + 0.00000001; + (paletteParams?.range === 'number' ? paletteParams.rangeMax : max) + smattering; + const overwriteColors = uiState?.get('vis.colors') ?? null; const bands = ranges.map((start, index, array) => { diff --git a/src/plugins/charts/common/index.ts b/src/plugins/charts/common/index.ts index 825fd74e24041..2b8f252f892a5 100644 --- a/src/plugins/charts/common/index.ts +++ b/src/plugins/charts/common/index.ts @@ -18,7 +18,6 @@ export type { export { defaultCustomColors, palette, systemPalette } from './palette'; export { paletteIds } from './constants'; - export type { ColorSchema, RawColorSchema, ColorMap } from './static'; export { ColorSchemas, @@ -31,6 +30,8 @@ export { LabelRotation, defaultCountLabel, MULTILAYER_TIME_AXIS_STYLE, + checkIsMinContinuity, + checkIsMaxContinuity, } from './static'; -export type { ColorSchemaParams, Labels, Style } from './types'; +export type { ColorSchemaParams, Labels, Style, PaletteContinuity } from './types'; diff --git a/src/plugins/charts/common/palette.ts b/src/plugins/charts/common/palette.ts index 8cd449fe99f99..55cd2f32e0aee 100644 --- a/src/plugins/charts/common/palette.ts +++ b/src/plugins/charts/common/palette.ts @@ -6,10 +6,13 @@ * Side Public License, v 1. */ -import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import type { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { i18n } from '@kbn/i18n'; import { last } from 'lodash'; import { paletteIds } from './constants'; +import { checkIsMaxContinuity, checkIsMinContinuity } from './static'; + +import type { PaletteContinuity } from './types'; export interface CustomPaletteArguments { color?: string[]; @@ -19,7 +22,7 @@ export interface CustomPaletteArguments { range?: 'number' | 'percent'; rangeMin?: number; rangeMax?: number; - continuity?: 'above' | 'below' | 'all' | 'none'; + continuity?: PaletteContinuity; } export interface CustomPaletteState { @@ -29,7 +32,7 @@ export interface CustomPaletteState { range: 'number' | 'percent'; rangeMin: number; rangeMax: number; - continuity?: 'above' | 'below' | 'all' | 'none'; + continuity?: PaletteContinuity; } export interface SystemPaletteArguments { @@ -169,8 +172,12 @@ export function palette(): ExpressionFunctionDefinition< range: range ?? 'percent', gradient, continuity, - rangeMin: calculateRange(rangeMin, stops[0], rangeMinDefault), - rangeMax: calculateRange(rangeMax, last(stops), rangeMaxDefault), + rangeMin: checkIsMinContinuity(continuity) + ? Number.NEGATIVE_INFINITY + : calculateRange(rangeMin, stops[0], rangeMinDefault), + rangeMax: checkIsMaxContinuity(continuity) + ? Number.POSITIVE_INFINITY + : calculateRange(rangeMax, last(stops), rangeMaxDefault), }, }; }, diff --git a/src/plugins/charts/common/static/index.ts b/src/plugins/charts/common/static/index.ts index 4a6b3ec2b52bb..67da5f8ca9e9b 100644 --- a/src/plugins/charts/common/static/index.ts +++ b/src/plugins/charts/common/static/index.ts @@ -17,5 +17,5 @@ export { } from './color_maps'; export { ColorMode, LabelRotation, defaultCountLabel } from './components'; - +export { checkIsMaxContinuity, checkIsMinContinuity } from './palette'; export * from './styles'; diff --git a/src/plugins/charts/common/static/palette/index.ts b/src/plugins/charts/common/static/palette/index.ts new file mode 100644 index 0000000000000..d28862b9a02e1 --- /dev/null +++ b/src/plugins/charts/common/static/palette/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PaletteContinuity } from '../../types'; + +export const checkIsMinContinuity = (continuity: PaletteContinuity | undefined) => + Boolean(continuity && ['below', 'all'].includes(continuity)); + +export const checkIsMaxContinuity = (continuity: PaletteContinuity | undefined) => + Boolean(continuity && ['above', 'all'].includes(continuity)); diff --git a/src/plugins/charts/common/types.ts b/src/plugins/charts/common/types.ts index 841494c2edb8a..ce1e1adc3218a 100644 --- a/src/plugins/charts/common/types.ts +++ b/src/plugins/charts/common/types.ts @@ -8,6 +8,8 @@ import { ColorSchemas, LabelRotation } from './static'; +export type PaletteContinuity = 'above' | 'below' | 'none' | 'all'; + export interface ColorSchemaParams { colorSchema: ColorSchemas; invertColors: boolean; diff --git a/src/plugins/charts/public/services/palettes/helpers.test.ts b/src/plugins/charts/public/services/palettes/helpers.test.ts index 90f5745570cc8..a8bf9b9df8e19 100644 --- a/src/plugins/charts/public/services/palettes/helpers.test.ts +++ b/src/plugins/charts/public/services/palettes/helpers.test.ts @@ -73,7 +73,7 @@ describe('workoutColorForValue', () => { { ...DEFAULT_PROPS, continuity: 'all', - rangeMax: 100, + rangeMax: Infinity, stops: [20, 40, 60, 80], }, { min: 0, max: 200 } diff --git a/src/plugins/charts/public/services/palettes/helpers.ts b/src/plugins/charts/public/services/palettes/helpers.ts index bd1f8350ba9f3..8d22b1ee42129 100644 --- a/src/plugins/charts/public/services/palettes/helpers.ts +++ b/src/plugins/charts/public/services/palettes/helpers.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { CustomPaletteState } from '../..'; +import { checkIsMinContinuity, checkIsMaxContinuity } from '../../../common'; +import type { CustomPaletteState } from '../..'; function findColorSegment( value: number, @@ -20,7 +21,11 @@ function findColorSegment( // what about values in range const index = colors.findIndex((c, i) => comparison(value, rangeMin + (1 + i) * step) <= 0); - return colors[index] || colors[0]; + // see comment below in function 'findColorsByStops' + return ( + colors[index] ?? + (value >= rangeMin + colors.length * step ? colors[colors.length - 1] : colors[0]) + ); } function findColorsByStops( @@ -30,25 +35,62 @@ function findColorsByStops( stops: number[] ) { const index = stops.findIndex((s) => comparison(value, s) < 0); - return colors[index] || colors[0]; + // as we now we can provide 'rangeMax' as end for last interval (iterval [lastStop, rangeMax]), + // value can be more that last stop but will be valid + // because of this we should provide for that value the last color. + // (For example, value = 100, last stop = 80, rangeMax = 120, before we was return the first color, + // but now we will return the last one) + return ( + colors[index] ?? (value >= stops[stops.length - 1] ? colors[colors.length - 1] : colors[0]) + ); } function getNormalizedValueByRange( value: number, - { range }: CustomPaletteState, + { range, rangeMin }: CustomPaletteState, minMax: { min: number; max: number } ) { let result = value; if (range === 'percent') { result = (100 * (value - minMax.min)) / (minMax.max - minMax.min); + + // for a range of 1 value the formulas above will divide by 0, so here's a safety guard + if (Number.isNaN(result)) { + return rangeMin; + } } - // for a range of 1 value the formulas above will divide by 0, so here's a safety guard - if (Number.isNaN(result)) { - return 1; - } + return result; } +const getNormalizedMaxRange = ( + { + stops, + colors, + rangeMax, + }: Pick, + isMaxContinuity: boolean, + [min, max]: [number, number] +) => { + if (isMaxContinuity) { + return Number.POSITIVE_INFINITY; + } + + return stops.length ? rangeMax : max - (max - min) / colors.length; +}; + +const getNormalizedMinRange = ( + { stops, rangeMin }: Pick, + isMinContinuity: boolean, + min: number +) => { + if (isMinContinuity) { + return Number.NEGATIVE_INFINITY; + } + + return stops.length ? rangeMin : min; +}; + /** * When stops are empty, it is assumed a predefined palette, so colors are distributed uniformly in the whole data range * When stops are passed, then rangeMin/rangeMax are used as reference for user defined limits: @@ -63,29 +105,30 @@ export function workoutColorForValue( return; } const { colors, stops, range = 'percent', continuity = 'above', rangeMax, rangeMin } = params; + + const isMinContinuity = checkIsMinContinuity(continuity); + const isMaxContinuity = checkIsMaxContinuity(continuity); // ranges can be absolute numbers or percentages // normalized the incoming value to the same format as range to make easier comparisons const normalizedValue = getNormalizedValueByRange(value, params, minMax); - const dataRangeArguments = range === 'percent' ? [0, 100] : [minMax.min, minMax.max]; - const comparisonFn = (v: number, threshold: number) => v - threshold; - // if steps are defined consider the specific rangeMax/Min as data boundaries - // as of max reduce its value by 1/colors.length for correct continuity checks - const maxRange = stops.length - ? rangeMax - : dataRangeArguments[1] - (dataRangeArguments[1] - dataRangeArguments[0]) / colors.length; - const minRange = stops.length ? rangeMin : dataRangeArguments[0]; + const [min, max]: [number, number] = range === 'percent' ? [0, 100] : [minMax.min, minMax.max]; - // in case of shorter rangers, extends the steps on the sides to cover the whole set - if (comparisonFn(normalizedValue, maxRange) > 0) { - if (continuity === 'above' || continuity === 'all') { - return colors[colors.length - 1]; + const minRange = getNormalizedMinRange({ stops, rangeMin }, isMinContinuity, min); + const maxRange = getNormalizedMaxRange({ stops, colors, rangeMax }, isMaxContinuity, [min, max]); + + const comparisonFn = (v: number, threshold: number) => v - threshold; + + if (comparisonFn(normalizedValue, minRange) < 0) { + if (isMinContinuity) { + return colors[0]; } return; } - if (comparisonFn(normalizedValue, minRange) < 0) { - if (continuity === 'below' || continuity === 'all') { - return colors[0]; + + if (comparisonFn(normalizedValue, maxRange) > 0) { + if (isMaxContinuity) { + return colors[colors.length - 1]; } return; } @@ -94,11 +137,5 @@ export function workoutColorForValue( return findColorsByStops(normalizedValue, comparisonFn, colors, stops); } - return findColorSegment( - normalizedValue, - comparisonFn, - colors, - dataRangeArguments[0], - dataRangeArguments[1] - ); + return findColorSegment(normalizedValue, comparisonFn, colors, min, max); } diff --git a/test/interpreter_functional/snapshots/baseline/partial_test_1.json b/test/interpreter_functional/snapshots/baseline/partial_test_1.json index 5b081f4d0713e..6bbd5ea72015d 100644 --- a/test/interpreter_functional/snapshots/baseline/partial_test_1.json +++ b/test/interpreter_functional/snapshots/baseline/partial_test_1.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"bucket":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":100,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"bucket":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json index 8f079b49ed98d..b1e76880dc912 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"bucket":{"accessor":1,"format":{"id":"string","params":{}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":100,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"bucket":{"accessor":1,"format":{"id":"string","params":{}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json index e0026b189949d..ce352d1f63c28 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[],"type":"datatable"},"visParams":{"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":100,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[],"type":"datatable"},"visParams":{"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json b/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json index 4eef2bcb1fc48..90cc06d2088c8 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"bucket":{"accessor":1,"format":{"id":"string","params":{}},"type":"vis_dimension"},"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":100,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"bucket":{"accessor":1,"format":{"id":"string","params":{}},"type":"vis_dimension"},"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json index 26ca82acd7563..7850768e9466c 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":100,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_options.json b/test/interpreter_functional/snapshots/baseline/tagcloud_options.json index d13cc180e1e7d..19b76ac66efcf 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_options.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_options.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"bucket":{"accessor":1,"format":{"id":"string","params":{}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":100,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"bucket":{"accessor":1,"format":{"id":"string","params":{}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/x-pack/plugins/lens/common/types.ts b/x-pack/plugins/lens/common/types.ts index 152eef4ebd3fe..0243aeef41c2d 100644 --- a/x-pack/plugins/lens/common/types.ts +++ b/x-pack/plugins/lens/common/types.ts @@ -11,6 +11,7 @@ import type { SerializedFieldFormat, } from '../../../../src/plugins/field_formats/common'; import type { Datatable } from '../../../../src/plugins/expressions/common'; +import type { PaletteContinuity } from '../../../../src/plugins/charts/common'; export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat; @@ -50,7 +51,7 @@ export interface CustomPaletteParams { name?: string; reverse?: boolean; rangeType?: 'number' | 'percent'; - continuity?: 'above' | 'below' | 'all' | 'none'; + continuity?: PaletteContinuity; progression?: 'fixed'; rangeMin?: number; rangeMax?: number; diff --git a/x-pack/plugins/lens/public/assets/distribute_equally.tsx b/x-pack/plugins/lens/public/assets/distribute_equally.tsx new file mode 100644 index 0000000000000..775ccaca5cdb6 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/distribute_equally.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiIconProps } from '@elastic/eui'; + +export const DistributeEquallyIcon = (props: Omit) => ( + + + +); diff --git a/x-pack/plugins/lens/public/assets/related.tsx b/x-pack/plugins/lens/public/assets/related.tsx new file mode 100644 index 0000000000000..221ef86ec74eb --- /dev/null +++ b/x-pack/plugins/lens/public/assets/related.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiIconProps } from '@elastic/eui'; + +export const RelatedIcon = (props: Omit) => ( + + + +); diff --git a/x-pack/plugins/lens/public/assets/value_max.tsx b/x-pack/plugins/lens/public/assets/value_max.tsx new file mode 100644 index 0000000000000..791822998bdda --- /dev/null +++ b/x-pack/plugins/lens/public/assets/value_max.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiIconProps } from '@elastic/eui'; + +export const ValueMaxIcon = (props: Omit) => ( + + + + + +); diff --git a/x-pack/plugins/lens/public/assets/value_min.tsx b/x-pack/plugins/lens/public/assets/value_min.tsx new file mode 100644 index 0000000000000..1bced92537740 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/value_min.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiIconProps } from '@elastic/eui'; + +export const ValueMinIcon = (props: Omit) => ( + + + +); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx index 6840f4f13450c..fc0fa9b5d8ac6 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx @@ -27,7 +27,6 @@ import { applyPaletteParams, defaultPaletteParams, FIXED_PROGRESSION, - getStopsForFixedMode, useDebouncedValue, PalettePanelContainer, findMinMaxByColumnId, @@ -352,7 +351,7 @@ export function TableDimensionEditor( color)} type={FIXED_PROGRESSION} onClick={() => { setIsPaletteOpen(!isPaletteOpen); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 108d01acd33a9..0115a8c5b39c7 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -22,7 +22,6 @@ import type { import { LensIconChartDatatable } from '../assets/chart_datatable'; import { TableDimensionEditor } from './components/dimension_editor'; import { CUSTOM_PALETTE } from '../shared_components/coloring/constants'; -import { getStopsForFixedMode } from '../shared_components'; import { LayerType, layerTypes } from '../../common'; import { getDefaultSummaryLabel, PagingState } from '../../common/expressions'; import type { ColumnState, SortingState } from '../../common/expressions'; @@ -241,9 +240,9 @@ export const getDatatableVisualization = ({ .filter((c) => !datasource!.getOperationForColumnId(c)?.isBucketed) .map((accessor) => { const columnConfig = columnMap[accessor]; - const hasColoring = Boolean( - columnConfig.colorMode !== 'none' && columnConfig.palette?.params?.stops - ); + const stops = columnConfig.palette?.params?.stops; + const hasColoring = Boolean(columnConfig.colorMode !== 'none' && stops); + return { columnId: accessor, triggerIcon: columnConfig.hidden @@ -251,12 +250,7 @@ export const getDatatableVisualization = ({ : hasColoring ? 'colorBy' : undefined, - palette: hasColoring - ? getStopsForFixedMode( - columnConfig.palette?.params?.stops || [], - columnConfig.palette?.params?.colorStops - ) - : undefined, + palette: hasColoring && stops ? stops.map(({ color }) => color) : undefined, }; }), supportsMoreColumns: true, diff --git a/x-pack/plugins/lens/public/heatmap_visualization/dimension_editor.tsx b/x-pack/plugins/lens/public/heatmap_visualization/dimension_editor.tsx index 8adcf3ef79122..78f10056ac60c 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/dimension_editor.tsx @@ -19,7 +19,6 @@ import type { VisualizationDimensionEditorProps } from '../types'; import { CustomizablePalette, FIXED_PROGRESSION, - getStopsForFixedMode, PalettePanelContainer, } from '../shared_components/'; import './dimension_editor.scss'; @@ -64,7 +63,7 @@ export function HeatmapDimensionEditor( color)} type={FIXED_PROGRESSION} onClick={() => { setIsPaletteOpen(!isPaletteOpen); @@ -93,23 +92,24 @@ export function HeatmapDimensionEditor( isOpen={isPaletteOpen} handleClose={() => setIsPaletteOpen(!isPaletteOpen)} > - { - // make sure to always have a list of stops - if (newPalette.params && !newPalette.params.stops) { - newPalette.params.stops = displayStops; - } - (newPalette as HeatmapVisualizationState['palette'])!.accessor = accessor; - setState({ - ...state, - palette: newPalette as HeatmapVisualizationState['palette'], - }); - }} - /> + {activePalette && ( + { + // make sure to always have a list of stops + if (newPalette.params && !newPalette.params.stops) { + newPalette.params.stops = displayStops; + } + (newPalette as HeatmapVisualizationState['palette'])!.accessor = accessor; + setState({ + ...state, + palette: newPalette as HeatmapVisualizationState['palette'], + }); + }} + /> + )} diff --git a/x-pack/plugins/lens/public/heatmap_visualization/types.ts b/x-pack/plugins/lens/public/heatmap_visualization/types.ts index 5f57c77992900..8c94dc7254d9c 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/types.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/types.ts @@ -19,7 +19,9 @@ export type HeatmapLayerState = HeatmapArguments & { shape: ChartShapes; }; +export type Palette = PaletteOutput & { accessor: string }; + export type HeatmapVisualizationState = HeatmapLayerState & { // need to store the current accessor to reset the color stops at accessor change - palette?: PaletteOutput & { accessor: string }; + palette?: Palette; }; diff --git a/x-pack/plugins/lens/public/heatmap_visualization/utils.ts b/x-pack/plugins/lens/public/heatmap_visualization/utils.ts index 3f860be646f35..da7f1141046c9 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/utils.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/utils.ts @@ -9,7 +9,7 @@ import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { Datatable } from 'src/plugins/expressions'; import { applyPaletteParams, findMinMaxByColumnId } from '../shared_components'; import { DEFAULT_PALETTE_NAME } from './constants'; -import type { HeatmapVisualizationState } from './types'; +import type { HeatmapVisualizationState, Palette } from './types'; export function getSafePaletteParams( paletteService: PaletteRegistry, @@ -18,9 +18,9 @@ export function getSafePaletteParams( activePalette?: HeatmapVisualizationState['palette'] ) { if (currentData == null || accessor == null) { - return { displayStops: [], activePalette: {} as HeatmapVisualizationState['palette'] }; + return { displayStops: [], activePalette }; } - const finalActivePalette: HeatmapVisualizationState['palette'] = activePalette ?? { + const finalActivePalette: Palette = activePalette ?? { type: 'palette', name: DEFAULT_PALETTE_NAME, accessor, diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts index 9a463efae6a2d..17ee15faf8f3b 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts @@ -153,10 +153,7 @@ describe('heatmap', () => { { columnId: 'v-accessor', triggerIcon: 'colorBy', - palette: [ - { color: 'blue', stop: 100 }, - { color: 'yellow', stop: 350 }, - ], + palette: ['blue', 'yellow'], }, ], filterOperations: isCellValueSupported, @@ -406,6 +403,7 @@ describe('heatmap', () => { ], }, ], + lastRangeIsRightOpen: [true], legend: [ { type: 'expression', diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx index 108e9b3ffb952..82305d293fe4d 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx @@ -29,7 +29,7 @@ import { LENS_HEATMAP_ID, } from './constants'; import { HeatmapToolbar } from './toolbar_component'; -import { CUSTOM_PALETTE, getStopsForFixedMode } from '../shared_components'; +import { CUSTOM_PALETTE } from '../shared_components'; import { HeatmapDimensionEditor } from './dimension_editor'; import { getSafePaletteParams } from './utils'; import type { CustomPaletteParams } from '../../common'; @@ -205,10 +205,7 @@ export const getHeatmapVisualization = ({ ? { columnId: state.valueAccessor, triggerIcon: 'colorBy', - palette: getStopsForFixedMode( - displayStops, - activePalette?.params?.colorStops - ), + palette: displayStops.map(({ color }) => color), } : { columnId: state.valueAccessor, @@ -317,6 +314,11 @@ export const getHeatmapVisualization = ({ xAccessor: [state.xAccessor ?? ''], yAccessor: [state.yAccessor ?? ''], valueAccessor: [state.valueAccessor ?? ''], + lastRangeIsRightOpen: [ + state.palette?.params?.continuity + ? ['above', 'all'].includes(state.palette.params.continuity) + : true, + ], palette: state.palette?.params ? [ paletteService diff --git a/x-pack/plugins/lens/public/metric_visualization/dimension_editor.tsx b/x-pack/plugins/lens/public/metric_visualization/dimension_editor.tsx index fd804ee5a82ad..77c6e909bc671 100644 --- a/x-pack/plugins/lens/public/metric_visualization/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/dimension_editor.tsx @@ -23,7 +23,6 @@ import { CustomizablePalette, CUSTOM_PALETTE, FIXED_PROGRESSION, - getStopsForFixedMode, PalettePanelContainer, } from '../shared_components'; import type { VisualizationDimensionEditorProps } from '../types'; @@ -165,14 +164,7 @@ export function MetricDimensionEditor( color) - } + palette={displayStops.map(({ color }) => color)} type={FIXED_PROGRESSION} onClick={togglePalette} /> diff --git a/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx b/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx index aee83fe4fcae9..3ec2f4c285c4e 100644 --- a/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx @@ -420,7 +420,7 @@ describe('metric_expression', () => { ); }); - test('it renders no color styling for numeric value if value is higher than rangeMax and continuity is "none"', () => { + test('it renders no color styling for numeric value if value is higher than rangeMax', () => { const { data, args } = sampleArgs(); data.tables.l1.rows[0].c = 500; @@ -432,7 +432,6 @@ describe('metric_expression', () => { gradient: false, range: 'number', colors: ['red', 'yellow', 'green'], - continuity: 'none', }; const instance = mount( @@ -453,7 +452,7 @@ describe('metric_expression', () => { ); }); - test('it renders no color styling for numeric value if value is lower than rangeMin and continuity is "none"', () => { + test('it renders no color styling for numeric value if value is lower than rangeMin', () => { const { data, args } = sampleArgs(); data.tables.l1.rows[0].c = -1; @@ -465,7 +464,6 @@ describe('metric_expression', () => { gradient: false, range: 'number', colors: ['red', 'yellow', 'green'], - continuity: 'none', }; const instance = mount( @@ -486,19 +484,18 @@ describe('metric_expression', () => { ); }); - test('it renders the color styling for numeric value if value is higher than rangeMax and continuity is "all"', () => { + test('it renders the correct color styling for numeric value if user select auto detect max value', () => { const { data, args } = sampleArgs(); data.tables.l1.rows[0].c = 500; args.colorMode = ColorMode.Labels; args.palette.params = { - rangeMin: 0, - rangeMax: 400, + rangeMin: 20, + rangeMax: Infinity, stops: [100, 200, 400], gradient: false, range: 'number', colors: ['red', 'yellow', 'green'], - continuity: 'all', }; const instance = mount( @@ -519,19 +516,18 @@ describe('metric_expression', () => { ); }); - test('it renders the color styling for numeric value if value is lower than rangeMin and continuity is "all"', () => { + test('it renders the correct color styling for numeric value if user select auto detect min value', () => { const { data, args } = sampleArgs(); data.tables.l1.rows[0].c = -1; args.colorMode = ColorMode.Labels; args.palette.params = { - rangeMin: 0, + rangeMin: -Infinity, rangeMax: 400, - stops: [100, 200, 400], + stops: [-Infinity, 200, 400], gradient: false, range: 'number', colors: ['red', 'yellow', 'green'], - continuity: 'all', }; const instance = mount( diff --git a/x-pack/plugins/lens/public/metric_visualization/expression.tsx b/x-pack/plugins/lens/public/metric_visualization/expression.tsx index 38bb92bb342ef..d84abcc0b1005 100644 --- a/x-pack/plugins/lens/public/metric_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/expression.tsx @@ -70,29 +70,28 @@ function getColorStyling( return {}; } - const { continuity = 'above', rangeMin, stops, colors } = palette.params; - const penultimateStop = stops[stops.length - 2]; + const { rangeMin, rangeMax, stops, colors } = palette.params; - if (continuity === 'none' && (value < rangeMin || value > penultimateStop)) { + if (value > rangeMax) { return {}; } - if (continuity === 'below' && value > penultimateStop) { - return {}; - } - if (continuity === 'above' && value < rangeMin) { + if (value < rangeMin) { return {}; } const cssProp = colorMode === ColorMode.Background ? 'backgroundColor' : 'color'; - const rawIndex = stops.findIndex((v) => v > value); + let rawIndex = stops.findIndex((v) => v > value); - let colorIndex = rawIndex; - if (['all', 'below'].includes(continuity) && value < rangeMin && colorIndex < 0) { - colorIndex = 0; + if (!isFinite(rangeMax) && value > stops[stops.length - 1]) { + rawIndex = stops.length - 1; } - if (['all', 'above'].includes(continuity) && value > penultimateStop && colorIndex < 0) { - colorIndex = stops.length - 1; + + // in this case first stop is -Infinity + if (!isFinite(rangeMin) && value < (isFinite(stops[0]) ? stops[0] : stops[1])) { + rawIndex = 0; } + const colorIndex = rawIndex; + const color = colors[colorIndex]; const styling = { [cssProp]: color, diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index 920c594952ed0..19d5a9c7e340a 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -19,7 +19,7 @@ import { LensIconChartMetric } from '../assets/chart_metric'; import { Visualization, OperationMetadata, DatasourcePublicAPI } from '../types'; import type { MetricConfig, MetricState } from '../../common/expressions'; import { layerTypes } from '../../common'; -import { CUSTOM_PALETTE, getStopsForFixedMode, shiftPalette } from '../shared_components'; +import { CUSTOM_PALETTE, shiftPalette } from '../shared_components'; import { MetricDimensionEditor } from './dimension_editor'; const toExpression = ( @@ -146,11 +146,7 @@ export const getMetricVisualization = ({ { columnId: props.state.accessor, triggerIcon: hasColoring ? 'colorBy' : undefined, - palette: hasColoring - ? props.state.palette?.params?.name === CUSTOM_PALETTE - ? getStopsForFixedMode(stops, props.state.palette?.params.colorStops) - : stops.map(({ color }) => color) - : undefined, + palette: hasColoring ? stops.map(({ color }) => color) : undefined, }, ] : [], diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.test.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.test.tsx new file mode 100644 index 0000000000000..872bff882fbff --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.test.tsx @@ -0,0 +1,166 @@ +/* + * 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 { mountWithIntl } from '@kbn/test/jest'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { ColorRanges, ColorRangesProps } from './color_ranges'; +import { ReactWrapper } from 'enzyme'; +import { PaletteRegistry } from 'src/plugins/charts/public'; +import { ColorRangesContext } from './color_ranges_context'; + +const extraActionSelectors = { + addColorRange: '[data-test-subj^="lnsPalettePanel_dynamicColoring_addColorRange"]', + reverseColors: '[data-test-subj^="lnsPalettePanel_dynamicColoring_reverseColors"]', + distributeEqually: '[data-test-subj="lnsPalettePanel_dynamicColoring_distributeEqually"]', +}; + +const pageObjects = { + getAddColorRangeButton: (component: ReactWrapper) => + component.find(extraActionSelectors.addColorRange).first(), + reverseColors: (component: ReactWrapper) => + component.find(extraActionSelectors.reverseColors).first(), + distributeEqually: (component: ReactWrapper) => + component.find(extraActionSelectors.distributeEqually).first(), +}; + +function renderColorRanges(props: ColorRangesProps) { + return mountWithIntl( + + + + ); +} + +describe('Color Ranges', () => { + let props: ColorRangesProps; + const dispatch = jest.fn(); + + beforeEach(() => { + dispatch.mockClear(); + props = { + colorRanges: [ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + ], + paletteConfiguration: { + rangeType: 'number', + continuity: 'none', + }, + showExtraActions: true, + dispatch, + }; + }); + + it('should display all the color ranges passed', () => { + const component = renderColorRanges(props); + + expect(component.find('ColorRangeItem')).toHaveLength(4); + }); + + it('should disable "add new" button if there is maxStops configured', () => { + props.colorRanges = [ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + { color: '#ccc', start: 80, end: 90 }, + { color: '#ccc', start: 90, end: 100 }, + ]; + const component = renderColorRanges({ ...props, paletteConfiguration: { maxSteps: 5 } }); + + expect(pageObjects.getAddColorRangeButton(component).prop('disabled')).toBe(true); + }); + + it('should add a new range with default color and reasonable distance from last one', () => { + const component = renderColorRanges(props); + + act(() => { + pageObjects.getAddColorRangeButton(component).simulate('click'); + }); + + component.update(); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'addColorRange', + payload: { dataBounds: { min: 0, max: 100 }, palettes: {} }, + }); + }); + + it('should sort ranges value on whole component blur', () => { + props.colorRanges = [ + { color: '#aaa', start: 65, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + ]; + const component = renderColorRanges(props); + const firstInput = component.find('ColorRangeItem').first().find('input').first(); + + act(() => { + firstInput.simulate('blur'); + }); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'sortColorRanges', + payload: { + dataBounds: { min: 0, max: 100 }, + palettes: {}, + }, + }); + }); + + it('should reverse colors when user click "reverse"', () => { + props.colorRanges = [ + { color: '#aaa', start: 10, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 90 }, + { color: '#ddd', start: 90, end: 130 }, + ]; + const component = renderColorRanges(props); + + act(() => { + pageObjects.reverseColors(component).simulate('click'); + }); + + component.update(); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'reversePalette', + payload: { + dataBounds: { min: 0, max: 100 }, + palettes: {}, + }, + }); + }); + + it('should distribute equally ranges when use click on "Distribute equally" button', () => { + props.colorRanges = [ + { color: '#aaa', start: 0, end: 2 }, + { color: '#bbb', start: 3, end: 4 }, + { color: '#ccc', start: 5, end: 6 }, + { color: '#ccc', start: 7, end: 8 }, + ]; + + const component = renderColorRanges(props); + + act(() => { + pageObjects.distributeEqually(component).simulate('click'); + }); + + component.update(); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'distributeEqually', + payload: { dataBounds: { min: 0, max: 100 }, palettes: {} }, + }); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.tsx new file mode 100644 index 0000000000000..76cab5ba743d3 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, useEffect, Dispatch, useContext } from 'react'; + +import { EuiFlexGroup, EuiTextColor, EuiFlexItem } from '@elastic/eui'; + +import { ColorRangesExtraActions } from './color_ranges_extra_actions'; +import { ColorRangeItem } from './color_ranges_item'; +import { + validateColorRanges, + getErrorMessages, + ColorRangeValidation, +} from './color_ranges_validation'; + +import type { CustomPaletteParamsConfig } from '../../../../common'; +import type { ColorRange } from './types'; +import type { PaletteConfigurationActions } from '../types'; + +import { defaultPaletteParams } from '../constants'; + +import { ColorRangesContext } from './color_ranges_context'; + +export interface ColorRangesProps { + colorRanges: ColorRange[]; + paletteConfiguration: CustomPaletteParamsConfig | undefined; + showExtraActions?: boolean; + dispatch: Dispatch; +} + +export function ColorRanges({ + colorRanges, + paletteConfiguration, + showExtraActions = true, + dispatch, +}: ColorRangesProps) { + const { dataBounds } = useContext(ColorRangesContext); + const [colorRangesValidity, setColorRangesValidity] = useState< + Record + >({}); + + const lastColorRange = colorRanges[colorRanges.length - 1]; + const errors = getErrorMessages(colorRangesValidity); + const continuity = paletteConfiguration?.continuity ?? defaultPaletteParams.continuity; + const rangeType = paletteConfiguration?.rangeType ?? defaultPaletteParams.rangeType; + + useEffect(() => { + setColorRangesValidity(validateColorRanges(colorRanges, dataBounds, rangeType)); + }, [colorRanges, rangeType, dataBounds]); + + return ( + + {colorRanges.map((colorRange, index) => ( + + + + ))} + {lastColorRange ? ( + + + + ) : null} + + {errors.map((error) => ( + {error} + ))} + + {showExtraActions ? ( + + = paletteConfiguration?.maxSteps) || + errors.length + )} + shouldDisableDistribute={Boolean(colorRanges.length === 1)} + shouldDisableReverse={Boolean(colorRanges.length === 1)} + /> + + ) : null} + + ); +} diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_context.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_context.ts new file mode 100644 index 0000000000000..368abf6a16701 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_context.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { PaletteRegistry } from 'src/plugins/charts/public'; +import type { DataBounds } from '../types'; + +interface ColorRangesContextType { + dataBounds: DataBounds; + palettes: PaletteRegistry; + disableSwitchingContinuity?: boolean; +} + +export const ColorRangesContext = React.createContext( + {} as ColorRangesContextType +); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_extra_actions.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_extra_actions.tsx new file mode 100644 index 0000000000000..7756922bfa883 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_extra_actions.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useCallback, Dispatch, useContext } from 'react'; +import { EuiFlexGroup, EuiButtonEmpty, EuiFlexItem } from '@elastic/eui'; + +import { DistributeEquallyIcon } from '../../../assets/distribute_equally'; +import { TooltipWrapper } from '../../index'; + +import type { ColorRangesActions } from './types'; +import { ColorRangesContext } from './color_ranges_context'; + +export interface ColorRangesExtraActionsProps { + dispatch: Dispatch; + shouldDisableAdd?: boolean; + shouldDisableReverse?: boolean; + shouldDisableDistribute?: boolean; +} + +export function ColorRangesExtraActions({ + dispatch, + shouldDisableAdd = false, + shouldDisableReverse = false, + shouldDisableDistribute = false, +}: ColorRangesExtraActionsProps) { + const { dataBounds, palettes } = useContext(ColorRangesContext); + const onAddColorRange = useCallback(() => { + dispatch({ + type: 'addColorRange', + payload: { dataBounds, palettes }, + }); + }, [dataBounds, dispatch, palettes]); + + const onReversePalette = useCallback(() => { + dispatch({ type: 'reversePalette', payload: { dataBounds, palettes } }); + }, [dispatch, dataBounds, palettes]); + + const onDistributeEqually = useCallback(() => { + dispatch({ type: 'distributeEqually', payload: { dataBounds, palettes } }); + }, [dataBounds, dispatch, palettes]); + + return ( + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_item.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_item.tsx new file mode 100644 index 0000000000000..a6d66a9177ad5 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_item.tsx @@ -0,0 +1,238 @@ +/* + * 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 useUpdateEffect from 'react-use/lib/useUpdateEffect'; +import React, { useState, useCallback, Dispatch, FocusEvent, useContext } from 'react'; + +import { + EuiFieldNumber, + EuiColorPicker, + EuiFlexItem, + EuiFlexGroup, + EuiIcon, + EuiColorPickerSwatch, + EuiButtonIcon, + EuiToolTip, + EuiFieldNumberProps, +} from '@elastic/eui'; + +import { RelatedIcon } from '../../../assets/related'; +import { isLastItem } from './utils'; +import { isValidColor } from '../utils'; +import { + ColorRangeDeleteButton, + ColorRangeAutoDetectButton, + ColorRangeEditButton, +} from './color_ranges_item_buttons'; + +import type { ColorRange, ColorRangeAccessor, ColorRangesActions } from './types'; +import { ColorRangesContext } from './color_ranges_context'; +import type { ColorRangeValidation } from './color_ranges_validation'; +import type { CustomPaletteParams } from '../../../../common'; +import { + PaletteContinuity, + checkIsMaxContinuity, + checkIsMinContinuity, +} from '../../../../../../../src/plugins/charts/common'; +import { getOutsideDataBoundsWarningMessage } from './color_ranges_validation'; + +export interface ColorRangesItemProps { + colorRange: ColorRange; + index: number; + colorRanges: ColorRange[]; + dispatch: Dispatch; + rangeType: CustomPaletteParams['rangeType']; + continuity: PaletteContinuity; + accessor: ColorRangeAccessor; + validation?: ColorRangeValidation; +} + +type ColorRangeItemMode = 'value' | 'auto' | 'edit'; + +const getMode = ( + index: ColorRangesItemProps['index'], + isLast: boolean, + continuity: PaletteContinuity +): ColorRangeItemMode => { + if (!isLast && index > 0) { + return 'value'; + } + return (isLast ? checkIsMaxContinuity : checkIsMinContinuity)(continuity) ? 'auto' : 'edit'; +}; + +const getPlaceholderForAutoMode = (isLast: boolean) => + isLast + ? i18n.translate('xpack.lens.dynamicColoring.customPalette.maxValuePlaceholder', { + defaultMessage: 'Max. value', + }) + : i18n.translate('xpack.lens.dynamicColoring.customPalette.minValuePlaceholder', { + defaultMessage: 'Min. value', + }); + +const getActionButton = (mode: ColorRangeItemMode) => { + if (mode === 'value') { + return ColorRangeDeleteButton; + } + return mode === 'edit' ? ColorRangeAutoDetectButton : ColorRangeEditButton; +}; + +const getAppend = ( + rangeType: CustomPaletteParams['rangeType'], + mode: ColorRangeItemMode, + validation?: ColorRangeValidation +) => { + const items: EuiFieldNumberProps['append'] = []; + + if (rangeType === 'percent') { + items.push('%'); + } + + if (mode !== 'auto' && validation?.warnings.length) { + items.push( + + + + ); + } + + return items; +}; + +export function ColorRangeItem({ + accessor, + index, + colorRange, + rangeType, + colorRanges, + validation, + continuity, + dispatch, +}: ColorRangesItemProps) { + const { dataBounds, palettes } = useContext(ColorRangesContext); + const [popoverInFocus, setPopoverInFocus] = useState(false); + const [localValue, setLocalValue] = useState(colorRange[accessor]); + const isLast = isLastItem(accessor); + const mode = getMode(index, isLast, continuity); + const isDisabled = mode === 'auto'; + const isColorValid = isValidColor(colorRange.color); + const ActionButton = getActionButton(mode); + const isValid = validation?.isValid ?? true; + + const onLeaveFocus = useCallback( + (e: FocusEvent) => { + const prevStartValue = colorRanges[index - 1]?.start ?? Number.NEGATIVE_INFINITY; + const nextStartValue = colorRanges[index + 1]?.start ?? Number.POSITIVE_INFINITY; + + const shouldSort = colorRange.start > nextStartValue || prevStartValue > colorRange.start; + const isFocusStillInContent = + (e.currentTarget as Node)?.contains(e.relatedTarget as Node) || popoverInFocus; + + if (shouldSort && !isFocusStillInContent) { + dispatch({ type: 'sortColorRanges', payload: { dataBounds, palettes } }); + } + }, + [colorRange.start, colorRanges, dispatch, index, popoverInFocus, dataBounds, palettes] + ); + + const onValueChange = useCallback( + ({ target: { value: targetValue } }) => { + setLocalValue(targetValue); + dispatch({ + type: 'updateValue', + payload: { index, value: targetValue, accessor, dataBounds, palettes }, + }); + }, + [dispatch, index, accessor, dataBounds, palettes] + ); + + const onUpdateColor = useCallback( + (color) => { + dispatch({ type: 'updateColor', payload: { index, color, dataBounds, palettes } }); + }, + [dispatch, index, dataBounds, palettes] + ); + + useUpdateEffect(() => { + if (!Number.isNaN(colorRange[accessor]) && colorRange[accessor] !== localValue) { + setLocalValue(colorRange[accessor]); + } + }, [localValue, colorRange, accessor]); + + const selectNewColorText = i18n.translate( + 'xpack.lens.dynamicColoring.customPalette.selectNewColor', + { + defaultMessage: 'Select a new color', + } + ); + + return ( + + + {!isLast ? ( + + ) : ( + + ) + } + secondaryInputDisplay="top" + color={colorRange.color} + onFocus={() => setPopoverInFocus(true)} + onBlur={() => { + setPopoverInFocus(false); + }} + isInvalid={!isColorValid} + /> + ) : ( + + )} + + + {isLast ? '\u2264' : '\u2265'}} + aria-label={i18n.translate('xpack.lens.dynamicColoring.customPalette.rangeAriaLabel', { + defaultMessage: 'Range {index}', + values: { + index: index + 1, + }, + })} + /> + + {ActionButton ? ( + + + + ) : null} + + ); +} diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_item_buttons.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_item_buttons.tsx new file mode 100644 index 0000000000000..3f289395f7b7d --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_item_buttons.tsx @@ -0,0 +1,149 @@ +/* + * 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, { Dispatch, useCallback, useContext } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { EuiButtonIcon } from '@elastic/eui'; + +import { ValueMaxIcon } from '../../../assets/value_max'; +import { ValueMinIcon } from '../../../assets/value_min'; +import { isLastItem } from './utils'; +import { TooltipWrapper } from '../../index'; + +import type { ColorRangesActions, ColorRange, ColorRangeAccessor } from './types'; +import { ColorRangesContext } from './color_ranges_context'; +import type { CustomPaletteParams } from '../../../../common'; +import type { PaletteContinuity } from '../../../../../../../src/plugins/charts/common'; + +export interface ColorRangesItemButtonProps { + index: number; + colorRanges: ColorRange[]; + rangeType: CustomPaletteParams['rangeType']; + continuity: PaletteContinuity; + dispatch: Dispatch; + accessor: ColorRangeAccessor; +} + +const switchContinuity = (isLast: boolean, continuity: PaletteContinuity) => { + switch (continuity) { + case 'none': + return isLast ? 'above' : 'below'; + case 'above': + return isLast ? 'none' : 'all'; + case 'below': + return isLast ? 'all' : 'none'; + case 'all': + return isLast ? 'below' : 'above'; + } +}; + +export function ColorRangeDeleteButton({ index, dispatch }: ColorRangesItemButtonProps) { + const { dataBounds, palettes } = useContext(ColorRangesContext); + const onExecuteAction = useCallback(() => { + dispatch({ type: 'deleteColorRange', payload: { index, dataBounds, palettes } }); + }, [dispatch, index, dataBounds, palettes]); + + const title = i18n.translate('xpack.lens.dynamicColoring.customPalette.deleteButtonAriaLabel', { + defaultMessage: 'Delete', + }); + + return ( + + ); +} + +export function ColorRangeEditButton({ + index, + continuity, + dispatch, + accessor, +}: ColorRangesItemButtonProps) { + const { dataBounds, palettes, disableSwitchingContinuity } = useContext(ColorRangesContext); + const isLast = isLastItem(accessor); + + const onExecuteAction = useCallback(() => { + const newContinuity = switchContinuity(isLast, continuity); + + dispatch({ + type: 'updateContinuity', + payload: { isLast, continuity: newContinuity, dataBounds, palettes }, + }); + }, [isLast, dispatch, continuity, dataBounds, palettes]); + + const title = i18n.translate('xpack.lens.dynamicColoring.customPalette.editButtonAriaLabel', { + defaultMessage: 'Edit', + }); + + return ( + + + + ); +} + +export function ColorRangeAutoDetectButton({ + continuity, + dispatch, + accessor, +}: ColorRangesItemButtonProps) { + const { dataBounds, palettes } = useContext(ColorRangesContext); + const isLast = isLastItem(accessor); + + const onExecuteAction = useCallback(() => { + const newContinuity = switchContinuity(isLast, continuity); + + dispatch({ + type: 'updateContinuity', + payload: { isLast, continuity: newContinuity, dataBounds, palettes }, + }); + }, [continuity, dataBounds, dispatch, isLast, palettes]); + + const title = isLast + ? i18n.translate('xpack.lens.dynamicColoring.customPalette.autoDetectMaximumAriaLabel', { + defaultMessage: 'Auto detect maximum value', + }) + : i18n.translate('xpack.lens.dynamicColoring.customPalette.autoDetectMinimumAriaLabel', { + defaultMessage: 'Auto detect minimum value', + }); + + return ( + + ); +} diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_validation.test.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_validation.test.ts new file mode 100644 index 0000000000000..a645d637bc6a5 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_validation.test.ts @@ -0,0 +1,151 @@ +/* + * 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 { validateColorRanges, isAllColorRangesValid } from './color_ranges_validation'; + +describe('Color ranges validation', () => { + describe('validateColorRanges', () => { + it('should return correct valid state for color ranges', () => { + const colorRanges = [ + { + start: 0, + end: 10, + color: '#aaa', + }, + { + start: 10, + end: 20, + color: '', + }, + { + start: 20, + end: 15, + color: '#aaa', + }, + ]; + const validation = validateColorRanges(colorRanges, { min: 0, max: 100 }, 'number'); + expect(validation['0']).toEqual({ + errors: [], + warnings: [], + isValid: true, + }); + expect(validation['1']).toEqual({ + errors: ['invalidColor'], + warnings: [], + isValid: false, + }); + expect(validation.last).toEqual({ + errors: ['greaterThanMaxValue'], + warnings: [], + isValid: false, + }); + }); + + it('should return correct warnings for color ranges', () => { + const colorRanges = [ + { + start: 0, + end: 10, + color: '#aaa', + }, + { + start: 10, + end: 20, + color: '#bbb', + }, + { + start: 20, + end: 35, + color: '#ccc', + }, + ]; + const validation = validateColorRanges(colorRanges, { min: 5, max: 30 }, 'number'); + expect(validation['0']).toEqual({ + errors: [], + warnings: ['lowerThanDataBounds'], + isValid: true, + }); + expect(validation['1']).toEqual({ + errors: [], + warnings: [], + isValid: true, + }); + expect(validation.last).toEqual({ + errors: [], + warnings: ['greaterThanDataBounds'], + isValid: true, + }); + }); + + it('should not return warnings for color ranges in number mode if we get fallback as data bounds', () => { + const colorRanges = [ + { + start: 0, + end: 10, + color: '#aaa', + }, + { + start: 10, + end: 20, + color: '#bbb', + }, + { + start: 20, + end: 35, + color: '#ccc', + }, + ]; + const validation = validateColorRanges( + colorRanges, + { min: 5, max: 30, fallback: true }, + 'number' + ); + expect(validation['0']).toEqual({ + errors: [], + warnings: [], + isValid: true, + }); + expect(validation['1']).toEqual({ + errors: [], + warnings: [], + isValid: true, + }); + expect(validation.last).toEqual({ + errors: [], + warnings: [], + isValid: true, + }); + }); + }); + + describe('isAllColorRangesValid', () => { + it('should return true if all color ranges is valid', () => { + const colorRanges = [ + { + start: 0, + end: 10, + color: '#aaa', + }, + { + start: 10, + end: 20, + color: '#bbb', + }, + { + start: 20, + end: 15, + color: '#ccc', + }, + ]; + let isValid = isAllColorRangesValid(colorRanges, { min: 5, max: 40 }, 'number'); + expect(isValid).toBeFalsy(); + colorRanges[colorRanges.length - 1].end = 30; + isValid = isAllColorRangesValid(colorRanges, { min: 5, max: 40 }, 'number'); + expect(isValid).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_validation.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_validation.tsx new file mode 100644 index 0000000000000..30cfe38066378 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_validation.tsx @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +import { getDataMinMax, isValidColor } from '../utils'; + +import type { ColorRange, ColorRangeAccessor } from './types'; +import type { DataBounds } from '../types'; + +import { CustomPaletteParams } from '../../../../common'; + +/** @internal **/ +type ColorRangeValidationErrors = 'invalidColor' | 'invalidValue' | 'greaterThanMaxValue'; + +/** @internal **/ +type ColorRangeValidationWarnings = 'lowerThanDataBounds' | 'greaterThanDataBounds'; + +/** @internal **/ +export interface ColorRangeValidation { + errors: ColorRangeValidationErrors[]; + warnings: ColorRangeValidationWarnings[]; + isValid: boolean; +} + +/** @internal **/ +export const getErrorMessages = (colorRangesValidity: Record) => { + return [ + ...new Set( + Object.values(colorRangesValidity) + .map((item) => item.errors) + .flat() + .map((item) => { + switch (item) { + case 'invalidColor': + case 'invalidValue': + return i18n.translate( + 'xpack.lens.dynamicColoring.customPalette.invalidValueOrColor', + { + defaultMessage: 'At least one color range contains the wrong value or color', + } + ); + case 'greaterThanMaxValue': + return i18n.translate('xpack.lens.dynamicColoring.customPalette.invalidMaxValue', { + defaultMessage: 'Maximum value should be greater than preceding values', + }); + default: + return ''; + } + }) + ), + ]; +}; + +export const getOutsideDataBoundsWarningMessage = (warnings: ColorRangeValidation['warnings']) => { + for (const warning of warnings) { + switch (warning) { + case 'lowerThanDataBounds': + return i18n.translate('xpack.lens.dynamicColoring.customPalette.lowerThanDataBounds', { + defaultMessage: 'This value is outside the minimum data bound', + }); + case 'greaterThanDataBounds': + return i18n.translate('xpack.lens.dynamicColoring.customPalette.greaterThanDataBounds', { + defaultMessage: 'This value is outside the maximum data bound', + }); + } + } +}; + +const checkForComplianceWithDataBounds = (value: number, minMax?: [number, number]) => { + const warnings: ColorRangeValidationWarnings[] = []; + if (minMax) { + const [min, max] = minMax; + + if (value < min) { + warnings.push('lowerThanDataBounds'); + } + if (value > max) { + warnings.push('greaterThanDataBounds'); + } + } + + return warnings; +}; + +/** @internal **/ +export const validateColorRange = ( + colorRange: ColorRange, + accessor: ColorRangeAccessor, + minMax?: [number, number] +) => { + const errors: ColorRangeValidationErrors[] = []; + let warnings: ColorRangeValidationWarnings[] = []; + + if (Number.isNaN(colorRange[accessor])) { + errors.push('invalidValue'); + } + + if (accessor === 'end') { + if (colorRange.start > colorRange.end) { + errors.push('greaterThanMaxValue'); + } + warnings = [...warnings, ...checkForComplianceWithDataBounds(colorRange.end, minMax)]; + } else { + if (!isValidColor(colorRange.color)) { + errors.push('invalidColor'); + } + warnings = [...warnings, ...checkForComplianceWithDataBounds(colorRange.start, minMax)]; + } + + return { + isValid: !errors.length, + errors, + warnings, + } as ColorRangeValidation; +}; + +export const validateColorRanges = ( + colorRanges: ColorRange[], + dataBounds: DataBounds, + rangeType: CustomPaletteParams['rangeType'] +): Record => { + let minMax: [number, number] | undefined; + + if ((dataBounds.fallback && rangeType === 'percent') || !dataBounds.fallback) { + const { min, max } = getDataMinMax(rangeType, dataBounds); + minMax = [min, max]; + } + + const validations = colorRanges.reduce>( + (acc, item, index) => ({ + ...acc, + [index]: validateColorRange(item, 'start', minMax), + }), + {} + ); + + return { + ...validations, + last: validateColorRange(colorRanges[colorRanges.length - 1], 'end', minMax), + }; +}; + +export const isAllColorRangesValid = ( + colorRanges: ColorRange[], + dataBounds: DataBounds, + rangeType: CustomPaletteParams['rangeType'] +) => { + return Object.values(validateColorRanges(colorRanges, dataBounds, rangeType)).every( + (colorRange) => colorRange.isValid + ); +}; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/index.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/index.tsx new file mode 100644 index 0000000000000..0b6f90de1d9d0 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/index.tsx @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ColorRanges } from './color_ranges'; +export { ColorRangesContext } from './color_ranges_context'; +export type { ColorRange, ColorRangesActions } from './types'; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/types.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/types.ts new file mode 100644 index 0000000000000..02e673b15462f --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/types.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { PaletteRegistry } from 'src/plugins/charts/public'; +import type { CustomPaletteParams } from '../../../../common'; +import type { PaletteContinuity } from '../../../../../../../src/plugins/charts/common'; +import type { DataBounds } from '../types'; + +export interface ColorRange { + color: string; + start: number; + end: number; +} + +/** @internal **/ +export interface ColorRangesState { + colorRanges: ColorRange[]; + rangeType: CustomPaletteParams['rangeType']; + continuity: PaletteContinuity; +} + +/** @internal **/ +interface BasicPayload { + dataBounds: DataBounds; + palettes?: PaletteRegistry; +} + +/** @internal **/ +export interface UpdateColorPayload extends BasicPayload { + index: number; + color: string; +} + +/** @internal **/ +export interface UpdateColorRangeValuePayload extends BasicPayload { + index: number; + value: string; + accessor: ColorRangeAccessor; +} + +/** @internal **/ +export interface DeleteColorRangePayload extends BasicPayload { + index: number; +} + +/** @internal **/ +export interface UpdateContinuityPayload extends BasicPayload { + isLast: boolean; + continuity: PaletteContinuity; +} + +/** @internal **/ +export type ColorRangesActions = + | { type: 'reversePalette'; payload: BasicPayload } + | { type: 'sortColorRanges'; payload: BasicPayload } + | { type: 'distributeEqually'; payload: BasicPayload } + | { type: 'updateContinuity'; payload: UpdateContinuityPayload } + | { type: 'deleteColorRange'; payload: DeleteColorRangePayload } + | { + type: 'addColorRange'; + payload: BasicPayload; + } + | { type: 'updateColor'; payload: UpdateColorPayload } + | { + type: 'updateValue'; + payload: UpdateColorRangeValuePayload; + }; + +/** @internal **/ +export type ColorRangeAccessor = 'start' | 'end'; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_crud.test.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_crud.test.ts new file mode 100644 index 0000000000000..837c66eeb1e5e --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_crud.test.ts @@ -0,0 +1,123 @@ +/* + * 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 { + addColorRange, + deleteColorRange, + updateColorRangeValue, + updateColorRangeColor, +} from './color_ranges_crud'; +import type { ColorRange } from '../types'; + +describe('addColorRange', () => { + let colorRanges: ColorRange[]; + beforeEach(() => { + colorRanges = [ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 81 }, + ]; + }); + + it('should add new color range with the corresponding interval', () => { + expect(addColorRange(colorRanges, 'number', { min: 0, max: 81 })).toEqual([ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + { color: '#ccc', start: 80, end: 81 }, + ]); + }); + + it('should add new color range with the interval equal 1 if new range out of max bound', () => { + colorRanges[colorRanges.length - 1].end = 80; + expect(addColorRange(colorRanges, 'number', { min: 0, max: 80 })).toEqual([ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 61 }, + { color: '#ccc', start: 61, end: 80 }, + ]); + }); +}); + +describe('deleteColorRange', () => { + let colorRanges: ColorRange[]; + beforeEach(() => { + colorRanges = [ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + ]; + }); + + it('delete the last range', () => { + expect(deleteColorRange(colorRanges.length - 1, colorRanges)).toEqual([ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 80 }, + ]); + }); + + it('delete the another range', () => { + expect(deleteColorRange(1, colorRanges)).toEqual([ + { color: '#aaa', start: 20, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + ]); + }); +}); + +describe('updateColorRangeValue', () => { + let colorRanges: ColorRange[]; + beforeEach(() => { + colorRanges = [ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + ]; + }); + + it('update the last end color range value', () => { + expect(updateColorRangeValue(colorRanges.length - 1, '90', 'end', colorRanges)).toEqual([ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 90 }, + ]); + }); + + it('update the first start color range value', () => { + expect(updateColorRangeValue(0, '10', 'start', colorRanges)).toEqual([ + { color: '#aaa', start: 10, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + ]); + }); + + it('update the color range value between the first and last color ranges', () => { + expect(updateColorRangeValue(1, '50', 'start', colorRanges)).toEqual([ + { color: '#aaa', start: 20, end: 50 }, + { color: '#bbb', start: 50, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + ]); + }); +}); + +describe('updateColorRangeColor', () => { + let colorRanges: ColorRange[]; + beforeEach(() => { + colorRanges = [ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + ]; + }); + + it('update color for color range', () => { + expect(updateColorRangeColor(0, '#ddd', colorRanges)).toEqual([ + { color: '#ddd', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + ]); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_crud.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_crud.ts new file mode 100644 index 0000000000000..6a2e92d284f01 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_crud.ts @@ -0,0 +1,115 @@ +/* + * 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 { getDataMinMax, roundValue } from '../../utils'; +import { calculateMaxStep } from './utils'; + +import type { ColorRange, ColorRangeAccessor } from '../types'; +import type { DataBounds } from '../../types'; +import type { CustomPaletteParamsConfig } from '../../../../../common'; + +/** + * Allows to update a ColorRange + * @private + */ +const updateColorRangeItem = ( + colorRanges: ColorRange[], + index: number, + payload: Partial +): ColorRange[] => { + const ranges = [...colorRanges]; + ranges[index] = { ...ranges[index], ...payload }; + return ranges; +}; + +/** + * Add new color range after the last item + * @internal + */ +export const addColorRange = ( + colorRanges: ColorRange[], + rangeType: CustomPaletteParamsConfig['rangeType'], + dataBounds: DataBounds +) => { + let newColorRanges = [...colorRanges]; + const lastIndex = newColorRanges.length - 1; + const lastStart = newColorRanges[lastIndex].start; + const lastEnd = newColorRanges[lastIndex].end; + const lastColor = newColorRanges[lastIndex].color; + + const { max: dataMax } = getDataMinMax(rangeType, dataBounds); + const max = Math.max(dataMax, lastEnd); + + const step = calculateMaxStep( + newColorRanges.map((item) => item.start), + max + ); + + let insertEnd = roundValue(Math.min(lastStart + step, max)); + + if (insertEnd === Number.NEGATIVE_INFINITY) { + insertEnd = 1; + } + + newColorRanges = updateColorRangeItem(newColorRanges, lastIndex, { end: insertEnd }); + newColorRanges.push({ + color: lastColor, + start: insertEnd, + end: lastEnd === insertEnd ? lastEnd + 1 : lastEnd, + }); + + return newColorRanges; +}; + +/** + * Delete ColorRange + * @internal + */ +export const deleteColorRange = (index: number, colorRanges: ColorRange[]) => { + const lastIndex = colorRanges.length - 1; + let ranges = colorRanges; + + if (index !== 0) { + if (index !== lastIndex) { + ranges = updateColorRangeItem(ranges, index - 1, { end: ranges[index + 1].start }); + } + if (index === lastIndex) { + ranges = updateColorRangeItem(ranges, index - 1, { end: colorRanges[index].end }); + } + } + return ranges.filter((item, i) => i !== index); +}; + +/** + * Update ColorRange value + * @internal + */ +export const updateColorRangeValue = ( + index: number, + value: string, + accessor: ColorRangeAccessor, + colorRanges: ColorRange[] +) => { + const parsedValue = value ? parseFloat(value) : Number.NaN; + let ranges = colorRanges; + + if (accessor === 'end') { + ranges = updateColorRangeItem(ranges, index, { end: parsedValue }); + } else { + ranges = updateColorRangeItem(ranges, index, { start: parsedValue }); + if (index > 0) { + ranges = updateColorRangeItem(ranges, index - 1, { end: parsedValue }); + } + } + return ranges; +}; + +/** + * Update ColorRange color + * @internal + */ +export const updateColorRangeColor = (index: number, color: string, colorRanges: ColorRange[]) => + updateColorRangeItem(colorRanges, index, { color }); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_extra_actions.test.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_extra_actions.test.ts new file mode 100644 index 0000000000000..14150022395c8 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_extra_actions.test.ts @@ -0,0 +1,58 @@ +/* + * 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 { distributeEqually, reversePalette } from './color_ranges_extra_actions'; +import type { ColorRange } from '../types'; + +describe('distributeEqually', () => { + let colorRanges: ColorRange[]; + beforeEach(() => { + colorRanges = [ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + { color: '#ddd', start: 80, end: 100 }, + ]; + }); + + it('should equally distribute the color ranges', () => { + expect(distributeEqually(colorRanges, 'number', 'none', { min: 0, max: 4000 })).toEqual([ + { color: '#aaa', start: 0, end: 1000 }, + { color: '#bbb', start: 1000, end: 2000 }, + { color: '#ccc', start: 2000, end: 3000 }, + { color: '#ddd', start: 3000, end: 4000 }, + ]); + }); + + it('should work correctly with continuity to both sides', () => { + expect(distributeEqually(colorRanges, 'percent', 'all', { min: 0, max: 5000 })).toEqual([ + { color: '#aaa', start: Number.NEGATIVE_INFINITY, end: 25 }, + { color: '#bbb', start: 25, end: 50 }, + { color: '#ccc', start: 50, end: 75 }, + { color: '#ddd', start: 75, end: Number.POSITIVE_INFINITY }, + ]); + }); +}); + +describe('reversePalette', () => { + let colorRanges: ColorRange[]; + beforeEach(() => { + colorRanges = [ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 81 }, + ]; + }); + + it('should return reversed color palette of given color range', () => { + expect(reversePalette(colorRanges)).toEqual([ + { color: '#ccc', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#aaa', start: 60, end: 81 }, + ]); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_extra_actions.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_extra_actions.ts new file mode 100644 index 0000000000000..b2477f1ad510a --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_extra_actions.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getDataMinMax, roundValue } from '../../utils'; + +import type { ColorRange } from '../types'; +import type { DataBounds } from '../../types'; +import type { CustomPaletteParamsConfig } from '../../../../../common'; +import { + PaletteContinuity, + checkIsMinContinuity, + checkIsMaxContinuity, +} from '../../../../../../../../src/plugins/charts/common'; + +/** + * Distribute equally + * @internal + */ +export const distributeEqually = ( + colorRanges: ColorRange[], + rangeType: CustomPaletteParamsConfig['rangeType'], + continuity: PaletteContinuity, + dataBounds: DataBounds +) => { + const items = colorRanges.length; + const lastIndex = colorRanges.length - 1; + const { min, max } = getDataMinMax(rangeType, dataBounds); + const step = roundValue((max - min) / items); + + const getValueForIndex = (index: number) => roundValue(min + step * index); + const getStartValue = (index: number) => { + if (index === 0) { + return checkIsMinContinuity(continuity) ? Number.NEGATIVE_INFINITY : roundValue(min); + } + return getValueForIndex(index); + }; + const getEndValue = (index: number) => { + if (index === lastIndex) { + return checkIsMaxContinuity(continuity) ? Number.POSITIVE_INFINITY : roundValue(max); + } + return getValueForIndex(index + 1); + }; + + return colorRanges.map((colorRange, index) => ({ + color: colorRange.color, + start: getStartValue(index), + end: getEndValue(index), + })); +}; + +/** + * Reverse Palette + * @internal + */ +export const reversePalette = (colorRanges: ColorRange[]) => + colorRanges + .map(({ color }, i) => ({ + color, + start: colorRanges[colorRanges.length - i - 1].start, + end: colorRanges[colorRanges.length - i - 1].end, + })) + .reverse(); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/index.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/index.ts new file mode 100644 index 0000000000000..e868198d8e406 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './utils'; +export * from './color_ranges_crud'; +export * from './color_ranges_extra_actions'; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/utils.test.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/utils.test.ts new file mode 100644 index 0000000000000..daebb02e44e46 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/utils.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { sortColorRanges, calculateMaxStep, toColorStops, getValueForContinuity } from './utils'; + +describe('utils', () => { + it('sortColorRanges', () => { + const colorRanges = [ + { color: '#aaa', start: 55, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + ]; + expect(sortColorRanges(colorRanges)).toEqual([ + { color: '#bbb', start: 40, end: 55 }, + { color: '#aaa', start: 55, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + ]); + }); + + it('calculateMaxStep', () => { + const stops = [20, 40, 60]; + expect(calculateMaxStep(stops, 90)).toEqual(20); + // should return 1 if the last stop with calculated interval more than max + expect(calculateMaxStep(stops, 75)).toEqual(1); + // should return 1 if we don't provide stops + expect(calculateMaxStep([], 75)).toEqual(1); + }); + + it('toColorStops', () => { + const colorRanges = [ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + ]; + const colorStops = [ + { + color: '#aaa', + stop: 20, + }, + { + color: '#bbb', + stop: 40, + }, + { + color: '#ccc', + stop: 60, + }, + ]; + + // if continuity is none then min should be the first range value + // and max should be the last range value + expect(toColorStops(colorRanges, 'none')).toEqual({ + min: 20, + max: 80, + colorStops, + }); + + colorStops[0].stop = Number.NEGATIVE_INFINITY; + // if continuity is below then min should be -Infinity + expect(toColorStops(colorRanges, 'below')).toEqual({ + min: Number.NEGATIVE_INFINITY, + max: 80, + colorStops, + }); + + colorStops[0].stop = 20; + // if continuity is above then max should be Infinity + expect(toColorStops(colorRanges, 'above')).toEqual({ + min: 20, + max: Number.POSITIVE_INFINITY, + colorStops, + }); + + colorStops[0].stop = Number.NEGATIVE_INFINITY; + // if continuity is all then max should be Infinity and min should be -Infinity + expect(toColorStops(colorRanges, 'all')).toEqual({ + min: Number.NEGATIVE_INFINITY, + max: Number.POSITIVE_INFINITY, + colorStops, + }); + }); + + describe('getValueForContinuity', () => { + it('should return Infinity if continuity is all or above and that last range', () => { + const colorRanges = [ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + ]; + expect( + getValueForContinuity(colorRanges, 'above', true, 'number', { min: 0, max: 100 }) + ).toEqual(Number.POSITIVE_INFINITY); + + expect( + getValueForContinuity(colorRanges, 'all', true, 'number', { min: 0, max: 100 }) + ).toEqual(Number.POSITIVE_INFINITY); + }); + + it('should return -Infinity if continuity is all or below and that first range', () => { + const colorRanges = [ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + ]; + expect( + getValueForContinuity(colorRanges, 'below', false, 'number', { min: 0, max: 100 }) + ).toEqual(Number.NEGATIVE_INFINITY); + + expect( + getValueForContinuity(colorRanges, 'all', false, 'number', { min: 0, max: 100 }) + ).toEqual(Number.NEGATIVE_INFINITY); + }); + + it('should return new max if continuity is none or below and that last range', () => { + const colorRanges = [ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + ]; + expect( + getValueForContinuity(colorRanges, 'below', true, 'number', { min: 0, max: 100 }) + ).toEqual(100); + + expect( + getValueForContinuity(colorRanges, 'none', true, 'number', { min: 0, max: 55 }) + ).toEqual(61); + }); + + it('should return new min if continuity is none or above and that first range', () => { + const colorRanges = [ + { color: '#aaa', start: 20, end: 40 }, + { color: '#bbb', start: 40, end: 60 }, + { color: '#ccc', start: 60, end: 80 }, + ]; + expect( + getValueForContinuity(colorRanges, 'above', false, 'number', { min: 0, max: 100 }) + ).toEqual(0); + + expect( + getValueForContinuity(colorRanges, 'none', false, 'number', { min: 45, max: 100 }) + ).toEqual(39); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/utils.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/utils.ts new file mode 100644 index 0000000000000..300df9b3b317b --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/utils.ts @@ -0,0 +1,108 @@ +/* + * 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 { roundValue, getDataMinMax } from '../../utils'; +import { + PaletteContinuity, + checkIsMaxContinuity, + checkIsMinContinuity, +} from '../../../../../../../../src/plugins/charts/common'; +import type { CustomPaletteParams } from '../../../../../common'; +import type { ColorRange, ColorRangeAccessor } from '../types'; +import type { DataBounds } from '../../types'; + +/** + * Check if item is last + * @internal + */ +export const isLastItem = (accessor: ColorRangeAccessor) => accessor === 'end'; + +/** + * Sort Color ranges array + * @internal + */ +export const sortColorRanges = (colorRanges: ColorRange[]) => { + const maxValue = colorRanges[colorRanges.length - 1].end; + + return [...colorRanges] + .sort(({ start: startA }, { start: startB }) => Number(startA) - Number(startB)) + .map((newColorRange, i, array) => ({ + color: newColorRange.color, + start: newColorRange.start, + end: i !== array.length - 1 ? array[i + 1].start : maxValue, + })); +}; + +/** + * Calculate max step + * @internal + */ +export const calculateMaxStep = (stops: number[], max: number) => { + let step = 1; + if (stops.length > 1) { + const last = stops[stops.length - 1]; + const last2step = stops[stops.length - 1] - stops[stops.length - 2]; + + if (last + last2step < max) { + step = last2step; + } + } + return roundValue(step); +}; + +/** + * Convert ColorRange to ColorStops + * @internal + */ + +export const toColorStops = (colorRanges: ColorRange[], continuity: PaletteContinuity) => { + const min = checkIsMinContinuity(continuity) ? Number.NEGATIVE_INFINITY : colorRanges[0].start; + const max = checkIsMaxContinuity(continuity) + ? Number.POSITIVE_INFINITY + : colorRanges[colorRanges.length - 1].end; + + return { + min, + max, + colorStops: colorRanges.map((colorRange, i) => ({ + color: colorRange.color, + stop: i === 0 ? min : colorRange.start, + })), + }; +}; + +/** + * Calculate right max or min value for new continuity + */ + +export const getValueForContinuity = ( + colorRanges: ColorRange[], + continuity: PaletteContinuity, + isLast: boolean, + rangeType: CustomPaletteParams['rangeType'], + dataBounds: DataBounds +) => { + const { max, min } = getDataMinMax(rangeType, dataBounds); + let value; + if (isLast) { + if (checkIsMaxContinuity(continuity)) { + value = Number.POSITIVE_INFINITY; + } else { + value = + colorRanges[colorRanges.length - 1].start > max + ? colorRanges[colorRanges.length - 1].start + 1 + : max; + } + } else { + if (checkIsMinContinuity(continuity)) { + value = Number.NEGATIVE_INFINITY; + } else { + value = colorRanges[0].end < min ? colorRanges[0].end - 1 : min; + } + } + + return value; +}; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_stops.test.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_stops.test.tsx deleted file mode 100644 index 5489c0cbd9693..0000000000000 --- a/x-pack/plugins/lens/public/shared_components/coloring/color_stops.test.tsx +++ /dev/null @@ -1,191 +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 { EuiColorPicker } from '@elastic/eui'; -import { mount } from 'enzyme'; -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { CustomStops, CustomStopsProps } from './color_stops'; - -// mocking random id generator function -jest.mock('@elastic/eui', () => { - const original = jest.requireActual('@elastic/eui'); - - return { - ...original, - htmlIdGenerator: (fn: unknown) => { - let counter = 0; - return () => counter++; - }, - }; -}); - -describe('Color Stops component', () => { - let props: CustomStopsProps; - beforeEach(() => { - props = { - colorStops: [ - { color: '#aaa', stop: 20 }, - { color: '#bbb', stop: 40 }, - { color: '#ccc', stop: 60 }, - ], - paletteConfiguration: {}, - dataBounds: { min: 0, max: 200 }, - onChange: jest.fn(), - 'data-test-prefix': 'my-test', - }; - }); - it('should display all the color stops passed', () => { - const component = mount(); - expect( - component.find('input[data-test-subj^="my-test_dynamicColoring_stop_value_"]') - ).toHaveLength(3); - }); - - it('should disable the delete buttons when there are 2 stops or less', () => { - // reduce to 2 stops - props.colorStops = props.colorStops.slice(0, 2); - const component = mount(); - expect( - component - .find('[data-test-subj="my-test_dynamicColoring_removeStop_0"]') - .first() - .prop('isDisabled') - ).toBe(true); - }); - - it('should disable "add new" button if there is maxStops configured', () => { - props.colorStops = [ - { color: '#aaa', stop: 20 }, - { color: '#bbb', stop: 40 }, - { color: '#ccc', stop: 60 }, - { color: '#ccc', stop: 80 }, - { color: '#ccc', stop: 90 }, - ]; - const component = mount(); - const componentWithMaxSteps = mount( - - ); - expect( - component - .find('[data-test-subj="my-test_dynamicColoring_addStop"]') - .first() - .prop('isDisabled') - ).toBe(false); - - expect( - componentWithMaxSteps - .find('[data-test-subj="my-test_dynamicColoring_addStop"]') - .first() - .prop('isDisabled') - ).toBe(true); - }); - - it('should add a new stop with default color and reasonable distance from last one', () => { - let component = mount(); - const addStopButton = component - .find('[data-test-subj="my-test_dynamicColoring_addStop"]') - .first(); - act(() => { - addStopButton.simulate('click'); - }); - component = component.update(); - - expect( - component.find('input[data-test-subj^="my-test_dynamicColoring_stop_value_"]') - ).toHaveLength(4); - expect( - component.find('input[data-test-subj="my-test_dynamicColoring_stop_value_3"]').prop('value') - ).toBe('80'); // 60-40 + 60 - expect( - component - // workaround for https://github.com/elastic/eui/issues/4792 - .find('[data-test-subj="my-test_dynamicColoring_stop_color_3"]') - .last() // pick the inner element - .childAt(0) - .prop('color') - ).toBe('#ccc'); // pick previous color - }); - - it('should restore previous color when abandoning the field with an empty color', () => { - let component = mount(); - expect( - component - .find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]') - .first() - .find(EuiColorPicker) - .first() - .prop('color') - ).toBe('#aaa'); - act(() => { - component - .find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]') - .first() - .find(EuiColorPicker) - .first() - .prop('onChange')!('', { - rgba: [NaN, NaN, NaN, NaN], - hex: '', - isValid: false, - }); - }); - component = component.update(); - expect( - component - .find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]') - .first() - .find(EuiColorPicker) - .first() - .prop('color') - ).toBe(''); - act(() => { - component - .find('[data-test-subj="my-test_dynamicColoring_stop_color_0"]') - .first() - .simulate('blur'); - }); - component = component.update(); - expect( - component - .find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]') - .first() - .find(EuiColorPicker) - .first() - .prop('color') - ).toBe('#aaa'); - }); - - it('should sort stops value on whole component blur', () => { - let component = mount(); - let firstStopValueInput = component.find( - '[data-test-subj="my-test_dynamicColoring_stop_value_0"] input[type="number"]' - ); - - act(() => { - firstStopValueInput.simulate('change', { target: { value: ' 90' } }); - }); - act(() => { - component - .find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]') - .first() - .simulate('blur'); - }); - component = component.update(); - - // retrieve again the input - firstStopValueInput = component.find( - '[data-test-subj="my-test_dynamicColoring_stop_value_0"] input[type="number"]' - ); - expect(firstStopValueInput.prop('value')).toBe('40'); - // the previous one move at the bottom - expect( - component - .find('[data-test-subj="my-test_dynamicColoring_stop_value_2"] input[type="number"]') - .prop('value') - ).toBe('90'); - }); -}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_stops.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_stops.tsx deleted file mode 100644 index 65f07351021b7..0000000000000 --- a/x-pack/plugins/lens/public/shared_components/coloring/color_stops.tsx +++ /dev/null @@ -1,310 +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, { useState, useCallback, useMemo } from 'react'; -import type { FocusEvent } from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiFieldNumber, - EuiColorPicker, - EuiButtonIcon, - EuiFlexItem, - EuiFlexGroup, - EuiButtonEmpty, - EuiSpacer, - EuiScreenReaderOnly, - htmlIdGenerator, -} from '@elastic/eui'; -import useUnmount from 'react-use/lib/useUnmount'; -import { DEFAULT_COLOR } from './constants'; -import { getDataMinMax, getStepValue, isValidColor } from './utils'; -import { TooltipWrapper, useDebouncedValue } from '../index'; -import type { ColorStop, CustomPaletteParamsConfig } from '../../../common'; - -const idGeneratorFn = htmlIdGenerator(); - -function areStopsValid(colorStops: Array<{ color: string; stop: string }>) { - return colorStops.every( - ({ color, stop }) => isValidColor(color) && !Number.isNaN(parseFloat(stop)) - ); -} - -function shouldSortStops(colorStops: Array<{ color: string; stop: string | number }>) { - return colorStops.some(({ stop }, i) => { - const numberStop = Number(stop); - const prevNumberStop = Number(colorStops[i - 1]?.stop ?? -Infinity); - return numberStop < prevNumberStop; - }); -} - -export interface CustomStopsProps { - colorStops: ColorStop[]; - onChange: (colorStops: ColorStop[]) => void; - dataBounds: { min: number; max: number }; - paletteConfiguration: CustomPaletteParamsConfig | undefined; - 'data-test-prefix': string; -} -export const CustomStops = ({ - colorStops, - onChange, - paletteConfiguration, - dataBounds, - ['data-test-prefix']: dataTestPrefix, -}: CustomStopsProps) => { - const onChangeWithValidation = useCallback( - (newColorStops: Array<{ color: string; stop: string }>) => { - const areStopsValuesValid = areStopsValid(newColorStops); - const shouldSort = shouldSortStops(newColorStops); - if (areStopsValuesValid && !shouldSort) { - onChange(newColorStops.map(({ color, stop }) => ({ color, stop: Number(stop) }))); - } - }, - [onChange] - ); - - const memoizedValues = useMemo(() => { - return colorStops.map(({ color, stop }, i) => ({ - color, - stop: String(stop), - id: idGeneratorFn(), - })); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [paletteConfiguration?.name, paletteConfiguration?.reverse, paletteConfiguration?.rangeType]); - - const { inputValue: localColorStops, handleInputChange: setLocalColorStops } = useDebouncedValue({ - onChange: onChangeWithValidation, - value: memoizedValues, - }); - const [sortedReason, setSortReason] = useState(''); - const shouldEnableDelete = localColorStops.length > 2; - const shouldDisableAdd = Boolean( - paletteConfiguration?.maxSteps && localColorStops.length >= paletteConfiguration?.maxSteps - ); - - const [popoverInFocus, setPopoverInFocus] = useState(false); - - // refresh on unmount: - // the onChange logic here is a bit different than the one above as it has to actively sort if required - useUnmount(() => { - const areStopsValuesValid = areStopsValid(localColorStops); - const shouldSort = shouldSortStops(localColorStops); - if (areStopsValuesValid && shouldSort) { - onChange( - localColorStops - .map(({ color, stop }) => ({ color, stop: Number(stop) })) - .sort(({ stop: stopA }, { stop: stopB }) => Number(stopA) - Number(stopB)) - ); - } - }); - - const rangeType = paletteConfiguration?.rangeType || 'percent'; - - return ( - <> - {sortedReason ? ( - -

- {i18n.translate('xpack.lens.dynamicColoring.customPalette.sortReason', { - defaultMessage: 'Color stops have been sorted due to new stop value {value}', - values: { - value: sortedReason, - }, - })} -

-
- ) : null} - - - {localColorStops.map(({ color, stop, id }, index) => { - const prevStopValue = Number(localColorStops[index - 1]?.stop ?? -Infinity); - const nextStopValue = Number(localColorStops[index + 1]?.stop ?? Infinity); - - return ( - ) => { - // sort the stops when the focus leaves the row container - const shouldSort = Number(stop) > nextStopValue || prevStopValue > Number(stop); - const isFocusStillInContent = - (e.currentTarget as Node)?.contains(e.relatedTarget as Node) || popoverInFocus; - const hasInvalidColor = !isValidColor(color); - if ((shouldSort && !isFocusStillInContent) || hasInvalidColor) { - // replace invalid color with previous valid one - const lastValidColor = hasInvalidColor ? colorStops[index].color : color; - const localColorStopsCopy = localColorStops.map((item, i) => - i === index ? { color: lastValidColor, stop, id } : item - ); - setLocalColorStops( - localColorStopsCopy.sort( - ({ stop: stopA }, { stop: stopB }) => Number(stopA) - Number(stopB) - ) - ); - setSortReason(stop); - } - }} - > - - - { - const newStopString = target.value.trim(); - const newColorStops = [...localColorStops]; - newColorStops[index] = { - color, - stop: newStopString, - id, - }; - setLocalColorStops(newColorStops); - }} - append={rangeType === 'percent' ? '%' : undefined} - aria-label={i18n.translate( - 'xpack.lens.dynamicColoring.customPalette.stopAriaLabel', - { - defaultMessage: 'Stop {index}', - values: { - index: index + 1, - }, - } - )} - /> - - - { - // make sure that the popover is closed - if (color === '' && !popoverInFocus) { - const newColorStops = [...localColorStops]; - newColorStops[index] = { color: colorStops[index].color, stop, id }; - setLocalColorStops(newColorStops); - } - }} - > - { - const newColorStops = [...localColorStops]; - newColorStops[index] = { color: newColor, stop, id }; - setLocalColorStops(newColorStops); - }} - secondaryInputDisplay="top" - color={color} - isInvalid={!isValidColor(color)} - showAlpha - compressed - onFocus={() => setPopoverInFocus(true)} - onBlur={() => { - setPopoverInFocus(false); - if (color === '') { - const newColorStops = [...localColorStops]; - newColorStops[index] = { color: colorStops[index].color, stop, id }; - setLocalColorStops(newColorStops); - } - }} - placeholder=" " - /> - - - - - { - const newColorStops = localColorStops.filter((_, i) => i !== index); - setLocalColorStops(newColorStops); - }} - data-test-subj={`${dataTestPrefix}_dynamicColoring_removeStop_${index}`} - isDisabled={!shouldEnableDelete} - /> - - - - - ); - })} - - - - - - { - const newColorStops = [...localColorStops]; - const length = newColorStops.length; - const { max } = getDataMinMax(rangeType, dataBounds); - const step = getStepValue( - colorStops, - newColorStops.map(({ color, stop }) => ({ color, stop: Number(stop) })), - max - ); - const prevColor = localColorStops[length - 1].color || DEFAULT_COLOR; - const newStop = step + Number(localColorStops[length - 1].stop); - newColorStops.push({ - color: prevColor, - stop: String(newStop), - id: idGeneratorFn(), - }); - setLocalColorStops(newColorStops); - }} - > - {i18n.translate('xpack.lens.dynamicColoring.customPalette.addColorStop', { - defaultMessage: 'Add color stop', - })} - - - - ); -}; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/constants.ts b/x-pack/plugins/lens/public/shared_components/coloring/constants.ts index fafa2cd613930..86b6379a2748f 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/constants.ts +++ b/x-pack/plugins/lens/public/shared_components/coloring/constants.ts @@ -11,15 +11,17 @@ export const DEFAULT_PALETTE_NAME = 'positive'; export const FIXED_PROGRESSION = 'fixed' as const; export const CUSTOM_PALETTE = 'custom'; export const DEFAULT_CONTINUITY = 'above'; +export const DEFAULT_RANGE_TYPE = 'percent'; export const DEFAULT_MIN_STOP = 0; export const DEFAULT_MAX_STOP = 100; export const DEFAULT_COLOR_STEPS = 5; export const DEFAULT_COLOR = '#6092C0'; // Same as EUI ColorStops default for new stops + export const defaultPaletteParams: RequiredPaletteParamTypes = { maxSteps: undefined, name: DEFAULT_PALETTE_NAME, reverse: false, - rangeType: 'percent', + rangeType: DEFAULT_RANGE_TYPE, rangeMin: DEFAULT_MIN_STOP, rangeMax: DEFAULT_MAX_STOP, progression: FIXED_PROGRESSION, diff --git a/x-pack/plugins/lens/public/shared_components/coloring/index.ts b/x-pack/plugins/lens/public/shared_components/coloring/index.ts index 7cbf79ac43b1e..93583f6148e09 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/index.ts +++ b/x-pack/plugins/lens/public/shared_components/coloring/index.ts @@ -7,6 +7,7 @@ export { CustomizablePalette } from './palette_configuration'; export { PalettePanelContainer } from './palette_panel_container'; -export { CustomStops } from './color_stops'; +export { ColorRanges } from './color_ranges'; + export * from './utils'; export * from './constants'; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx index 0840c19495eaa..a97f3d3f04112 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx @@ -12,10 +12,9 @@ import { chartPluginMock } from 'src/plugins/charts/public/mocks'; import type { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; import { ReactWrapper } from 'enzyme'; import type { CustomPaletteParams } from '../../../common'; -import { applyPaletteParams } from './utils'; import { CustomizablePalette } from './palette_configuration'; -import { CUSTOM_PALETTE } from './constants'; import { act } from 'react-dom/test-utils'; +import type { DataBounds } from './types'; // mocking random id generator function jest.mock('@elastic/eui', () => { @@ -30,39 +29,14 @@ jest.mock('@elastic/eui', () => { }; }); -describe('palette utilities', () => { - const paletteRegistry = chartPluginMock.createPaletteRegistry(); - describe('applyPaletteParams', () => { - it('should return a set of colors for a basic configuration', () => { - expect( - applyPaletteParams( - paletteRegistry, - { type: 'palette', name: 'positive' }, - { min: 0, max: 100 } - ) - ).toEqual([ - { color: 'blue', stop: 20 }, - { color: 'yellow', stop: 70 }, - ]); - }); +// mocking isAllColorRangesValid function +jest.mock('./color_ranges/color_ranges_validation', () => { + const original = jest.requireActual('./color_ranges/color_ranges_validation'); - it('should reverse the palette color stops correctly', () => { - expect( - applyPaletteParams( - paletteRegistry, - { - type: 'palette', - name: 'positive', - params: { reverse: true }, - }, - { min: 0, max: 100 } - ) - ).toEqual([ - { color: 'yellow', stop: 20 }, - { color: 'blue', stop: 70 }, - ]); - }); - }); + return { + ...original, + isAllColorRangesValid: () => true, + }; }); describe('palette panel', () => { @@ -71,7 +45,7 @@ describe('palette panel', () => { palettes: PaletteRegistry; activePalette: PaletteOutput; setPalette: (palette: PaletteOutput) => void; - dataBounds: { min: number; max: number }; + dataBounds: DataBounds; }; describe('palette picker', () => { @@ -82,6 +56,8 @@ describe('palette panel', () => { setPalette: jest.fn(), dataBounds: { min: 0, max: 100 }, }; + + jest.useFakeTimers(); }); function changePaletteIn(instance: ReactWrapper, newPaletteName: string) { @@ -113,7 +89,11 @@ describe('palette panel', () => { it('should set the colorStops and stops when selecting the Custom palette from the list', () => { const instance = mountWithIntl(); - changePaletteIn(instance, 'custom'); + act(() => { + changePaletteIn(instance, 'custom'); + }); + + jest.advanceTimersByTime(250); expect(props.setPalette).toHaveBeenCalledWith({ type: 'palette', @@ -135,7 +115,11 @@ describe('palette panel', () => { it('should restore the reverse initial state on transitioning', () => { const instance = mountWithIntl(); - changePaletteIn(instance, 'negative'); + act(() => { + changePaletteIn(instance, 'negative'); + }); + + jest.advanceTimersByTime(250); expect(props.setPalette).toHaveBeenCalledWith({ type: 'palette', @@ -150,69 +134,27 @@ describe('palette panel', () => { it('should rewrite the min/max range values on palette change', () => { const instance = mountWithIntl(); - changePaletteIn(instance, 'custom'); + act(() => { + changePaletteIn(instance, 'custom'); + }); + + jest.advanceTimersByTime(250); expect(props.setPalette).toHaveBeenCalledWith({ type: 'palette', name: 'custom', params: expect.objectContaining({ rangeMin: 0, - rangeMax: 50, + rangeMax: Number.POSITIVE_INFINITY, }), }); }); }); - describe('reverse option', () => { - beforeEach(() => { - props = { - activePalette: { type: 'palette', name: 'positive' }, - palettes: paletteRegistry, - setPalette: jest.fn(), - dataBounds: { min: 0, max: 100 }, - }; - }); - - function toggleReverse(instance: ReactWrapper, checked: boolean) { - return instance - .find('[data-test-subj="lnsPalettePanel_dynamicColoring_reverse"]') - .first() - .prop('onClick')!({} as React.MouseEvent); - } - - it('should reverse the colorStops on click', () => { - const instance = mountWithIntl(); - - toggleReverse(instance, true); - - expect(props.setPalette).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ - reverse: true, - }), - }) - ); - }); - - it('should transition a predefined palette to a custom one on reverse click', () => { - const instance = mountWithIntl(); - - toggleReverse(instance, true); - - expect(props.setPalette).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ - name: CUSTOM_PALETTE, - }), - }) - ); - }); - }); - describe('percentage / number modes', () => { beforeEach(() => { props = { - activePalette: { type: 'palette', name: 'positive' }, + activePalette: { type: 'palette', name: 'custom' }, palettes: paletteRegistry, setPalette: jest.fn(), dataBounds: { min: 5, max: 200 }, @@ -228,6 +170,8 @@ describe('palette panel', () => { .prop('onChange')!('number'); }); + jest.advanceTimersByTime(250); + act(() => { instance .find('[data-test-subj="lnsPalettePanel_dynamicColoring_custom_range_groups"]') @@ -235,13 +179,15 @@ describe('palette panel', () => { .prop('onChange')!('percent'); }); + jest.advanceTimersByTime(250); + expect(props.setPalette).toHaveBeenNthCalledWith( 1, expect.objectContaining({ params: expect.objectContaining({ rangeType: 'number', rangeMin: 5, - rangeMax: 102.5 /* (200 - (200-5)/ colors.length: 2) */, + rangeMax: Number.POSITIVE_INFINITY, }), }) ); @@ -252,7 +198,7 @@ describe('palette panel', () => { params: expect.objectContaining({ rangeType: 'percent', rangeMin: 0, - rangeMax: 50 /* 100 - (100-0)/ colors.length: 2 */, + rangeMax: Number.POSITIVE_INFINITY, }), }) ); @@ -282,7 +228,9 @@ describe('palette panel', () => { it('should be visible for predefined palettes', () => { const instance = mountWithIntl(); expect( - instance.find('[data-test-subj="lnsPalettePanel_dynamicColoring_custom_stops"]').exists() + instance + .find('[data-test-subj="lnsPalettePanel_dynamicColoring_custom_color_ranges"]') + .exists() ).toEqual(true); }); @@ -300,7 +248,9 @@ describe('palette panel', () => { /> ); expect( - instance.find('[data-test-subj="lnsPalettePanel_dynamicColoring_custom_stops"]').exists() + instance + .find('[data-test-subj="lnsPalettePanel_dynamicColoring_custom_color_ranges"]') + .exists() ).toEqual(true); }); }); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx index d1f1bc813deab..104b8e4319e40 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx @@ -5,378 +5,179 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { useReducer, useMemo } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; import type { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; -import { - EuiFormRow, - htmlIdGenerator, - EuiButtonGroup, - EuiFlexGroup, - EuiFlexItem, - EuiSuperSelect, - EuiIcon, - EuiIconTip, - EuiLink, - EuiText, -} from '@elastic/eui'; +import { EuiFormRow, htmlIdGenerator, EuiButtonGroup, EuiIconTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { PalettePicker } from './palette_picker'; +import type { DataBounds } from './types'; import './palette_configuration.scss'; -import { CustomStops } from './color_stops'; -import { defaultPaletteParams, CUSTOM_PALETTE, DEFAULT_COLOR_STEPS } from './constants'; import type { CustomPaletteParams, RequiredPaletteParamTypes } from '../../../common'; -import { - getColorStops, - getPaletteStops, - mergePaletteParams, - getDataMinMax, - remapStopsByNewInterval, - getSwitchToCustomParams, - reversePalette, - roundStopValues, -} from './utils'; -const idPrefix = htmlIdGenerator()(); - -const ContinuityOption: FC<{ iconType: string }> = ({ children, iconType }) => { - return ( - - - - - {children} - - ); -}; - -/** - * Some name conventions here: - * * `displayStops` => It's an additional transformation of `stops` into a [0, N] domain for the EUIPaletteDisplay component. - * * `stops` => final steps used to table coloring. It is a rightShift of the colorStops - * * `colorStops` => user's color stop inputs. Used to compute range min. - * - * When the user inputs the colorStops, they are designed to be the initial part of the color segment, - * so the next stops indicate where the previous stop ends. - * Both table coloring logic and EuiPaletteDisplay format implementation works differently than our current `colorStops`, - * by having the stop values at the end of each color segment rather than at the beginning: `stops` values are computed by a rightShift of `colorStops`. - * EuiPaletteDisplay has an additional requirement as it is always mapped against a domain [0, N]: from `stops` the `displayStops` are computed with - * some continuity enrichment and a remap against a [0, 100] domain to make the palette component work ok. - * - * These naming conventions would be useful to track the code flow in this feature as multiple transformations are happening - * for a single change. - */ +import { toColorRanges, getFallbackDataBounds } from './utils'; +import { defaultPaletteParams } from './constants'; +import { ColorRanges, ColorRangesContext } from './color_ranges'; +import { isAllColorRangesValid } from './color_ranges/color_ranges_validation'; +import { paletteConfigurationReducer } from './palette_configuration_reducer'; export function CustomizablePalette({ palettes, activePalette, setPalette, - dataBounds, - showContinuity = true, + dataBounds = getFallbackDataBounds(activePalette.params?.rangeType), showRangeTypeSelector = true, + disableSwitchingContinuity = false, }: { palettes: PaletteRegistry; - activePalette?: PaletteOutput; + activePalette: PaletteOutput; setPalette: (palette: PaletteOutput) => void; - dataBounds?: { min: number; max: number }; - showContinuity?: boolean; + dataBounds?: DataBounds; showRangeTypeSelector?: boolean; + disableSwitchingContinuity?: boolean; }) { - if (!dataBounds || !activePalette) { - return null; - } - const isCurrentPaletteCustom = activePalette.params?.name === CUSTOM_PALETTE; + const idPrefix = useMemo(() => htmlIdGenerator()(), []); + const colorRangesToShow = toColorRanges( + palettes, + activePalette.params?.colorStops || [], + activePalette, + dataBounds + ); + + const [localState, dispatch] = useReducer(paletteConfigurationReducer, { + activePalette, + colorRanges: colorRangesToShow, + }); - const colorStopsToShow = roundStopValues( - getColorStops(palettes, activePalette?.params?.colorStops || [], activePalette, dataBounds) + useDebounce( + () => { + const rangeType = + localState.activePalette?.params?.rangeType ?? defaultPaletteParams.rangeType; + if ( + (localState.activePalette !== activePalette || + colorRangesToShow !== localState.colorRanges) && + isAllColorRangesValid(localState.colorRanges, dataBounds, rangeType) + ) { + setPalette(localState.activePalette); + } + }, + 250, + [localState] ); return ( - <> -
+
+ + { + const isPaletteChanged = newPalette.name !== activePalette.name; + if (isPaletteChanged) { + dispatch({ + type: 'changeColorPalette', + payload: { palette: newPalette, dataBounds, palettes, disableSwitchingContinuity }, + }); + } + }} + showCustomPalette + showDynamicColorOnly + /> + + {showRangeTypeSelector && ( + {i18n.translate('xpack.lens.table.dynamicColoring.rangeType.label', { + defaultMessage: 'Value type', + })}{' '} + + + } display="rowCompressed" - fullWidth - label={i18n.translate('xpack.lens.palettePicker.label', { - defaultMessage: 'Color palette', - })} > - { - const isNewPaletteCustom = newPalette.name === CUSTOM_PALETTE; - const newParams: CustomPaletteParams = { - ...activePalette.params, - name: newPalette.name, - colorStops: undefined, - reverse: false, // restore the reverse flag - }; - - const newColorStops = getColorStops(palettes, [], activePalette, dataBounds); - if (isNewPaletteCustom) { - newParams.colorStops = newColorStops; - } - - newParams.stops = getPaletteStops(palettes, newParams, { - prevPalette: - isNewPaletteCustom || isCurrentPaletteCustom ? undefined : newPalette.name, - dataBounds, - mapFromMinValue: true, - }); - - newParams.rangeMin = newColorStops[0].stop; - newParams.rangeMax = newColorStops[newColorStops.length - 1].stop; + { + const newRangeType = id.replace( + idPrefix, + '' + ) as RequiredPaletteParamTypes['rangeType']; - setPalette({ - ...newPalette, - params: newParams, + dispatch({ + type: 'updateRangeType', + payload: { rangeType: newRangeType, dataBounds, palettes }, }); }} - showCustomPalette - showDynamicColorOnly + isFullWidth /> - {showContinuity && ( - - {i18n.translate('xpack.lens.table.dynamicColoring.continuity.label', { - defaultMessage: 'Color continuity', - })}{' '} - - - } - display="rowCompressed" - > - - {i18n.translate('xpack.lens.table.dynamicColoring.continuity.aboveLabel', { - defaultMessage: 'Above range', - })} - - ), - 'data-test-subj': 'continuity-above', - }, - { - value: 'below', - inputDisplay: ( - - {i18n.translate('xpack.lens.table.dynamicColoring.continuity.belowLabel', { - defaultMessage: 'Below range', - })} - - ), - 'data-test-subj': 'continuity-below', - }, - { - value: 'all', - inputDisplay: ( - - {i18n.translate('xpack.lens.table.dynamicColoring.continuity.allLabel', { - defaultMessage: 'Above and below range', - })} - - ), - 'data-test-subj': 'continuity-all', - }, - { - value: 'none', - inputDisplay: ( - - {i18n.translate('xpack.lens.table.dynamicColoring.continuity.noneLabel', { - defaultMessage: 'Within range', - })} - - ), - 'data-test-subj': 'continuity-none', - }, - ]} - valueOfSelected={activePalette.params?.continuity || defaultPaletteParams.continuity} - onChange={(continuity: Required['continuity']) => - setPalette( - mergePaletteParams(activePalette, { - continuity, - }) - ) - } - /> - - )} - {showRangeTypeSelector && ( - - {i18n.translate('xpack.lens.table.dynamicColoring.rangeType.label', { - defaultMessage: 'Value type', - })}{' '} - - - } - display="rowCompressed" - > - { - const newRangeType = id.replace( - idPrefix, - '' - ) as RequiredPaletteParamTypes['rangeType']; - - const params: CustomPaletteParams = { rangeType: newRangeType }; - const { min: newMin, max: newMax } = getDataMinMax(newRangeType, dataBounds); - const { min: oldMin, max: oldMax } = getDataMinMax( - activePalette.params?.rangeType, - dataBounds - ); - const newColorStops = remapStopsByNewInterval(colorStopsToShow, { - oldInterval: oldMax - oldMin, - newInterval: newMax - newMin, - newMin, - oldMin, - }); - if (isCurrentPaletteCustom) { - const stops = getPaletteStops( - palettes, - { ...activePalette.params, colorStops: newColorStops, ...params }, - { dataBounds } - ); - params.colorStops = newColorStops; - params.stops = stops; - } else { - params.stops = getPaletteStops( - palettes, - { ...activePalette.params, ...params }, - { prevPalette: activePalette.name, dataBounds } - ); - } - // why not use newMin/newMax here? - // That's because there's the concept of continuity to accomodate, where in some scenarios it has to - // take into account the stop value rather than the data value - params.rangeMin = newColorStops[0].stop; - params.rangeMax = newColorStops[newColorStops.length - 1].stop; - setPalette(mergePaletteParams(activePalette, params)); - }} - /> - - )} - - { - // when reversing a palette, the palette is automatically transitioned to a custom palette - const newParams = getSwitchToCustomParams( - palettes, - activePalette, - { - colorStops: reversePalette(colorStopsToShow), - steps: activePalette.params?.steps || DEFAULT_COLOR_STEPS, - reverse: !activePalette.params?.reverse, // Store the reverse state - rangeMin: colorStopsToShow[0]?.stop, - rangeMax: colorStopsToShow[colorStopsToShow.length - 1]?.stop, - }, - dataBounds - ); - setPalette(newParams); - }} - > - - - - - - {i18n.translate('xpack.lens.table.dynamicColoring.reverse.label', { - defaultMessage: 'Reverse colors', - })} - - - - - } + )} + + - { - const newParams = getSwitchToCustomParams( - palettes, - activePalette, - { - colorStops, - steps: activePalette.params?.steps || DEFAULT_COLOR_STEPS, - rangeMin: colorStops[0]?.stop, - rangeMax: colorStops[colorStops.length - 1]?.stop, - }, - dataBounds - ); - return setPalette(newParams); - }} + - -
- + + +
); } diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration_reducer.ts b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration_reducer.ts new file mode 100644 index 0000000000000..efdfc104ddfea --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration_reducer.ts @@ -0,0 +1,161 @@ +/* + * 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 type { Reducer } from 'react'; +import { + addColorRange, + deleteColorRange, + distributeEqually, + reversePalette, + sortColorRanges, + updateColorRangeColor, + updateColorRangeValue, + getValueForContinuity, +} from './color_ranges/utils'; +import { DEFAULT_CONTINUITY, DEFAULT_RANGE_TYPE } from './constants'; + +import { + mergePaletteParams, + updateRangeType, + changeColorPalette, + withUpdatingPalette, + withUpdatingColorRanges, +} from './utils'; + +import type { PaletteConfigurationState, PaletteConfigurationActions } from './types'; + +export const paletteConfigurationReducer: Reducer< + PaletteConfigurationState, + PaletteConfigurationActions +> = (state, action) => { + switch (action.type) { + case 'updateContinuity': { + const { continuity, isLast, dataBounds, palettes } = action.payload; + const rangeType = state.activePalette.params?.rangeType ?? DEFAULT_RANGE_TYPE; + + const value = getValueForContinuity( + state.colorRanges, + continuity, + isLast, + rangeType, + dataBounds + ); + + return withUpdatingPalette( + palettes!, + state.activePalette, + updateColorRangeValue( + isLast ? state.colorRanges.length - 1 : 0, + `${value}`, + isLast ? 'end' : 'start', + state.colorRanges + ), + dataBounds, + continuity + ); + } + case 'addColorRange': { + const { dataBounds, palettes } = action.payload; + return withUpdatingPalette( + palettes!, + state.activePalette, + addColorRange( + state.colorRanges, + state.activePalette.params?.rangeType ?? DEFAULT_RANGE_TYPE, + dataBounds + ), + dataBounds + ); + } + case 'reversePalette': { + const { dataBounds, palettes } = action.payload; + return withUpdatingPalette( + palettes!, + state.activePalette, + reversePalette(state.colorRanges), + dataBounds + ); + } + case 'distributeEqually': { + const { dataBounds, palettes } = action.payload; + return withUpdatingPalette( + palettes!, + state.activePalette, + distributeEqually( + state.colorRanges, + state.activePalette.params?.rangeType, + state.activePalette.params?.continuity ?? DEFAULT_CONTINUITY, + dataBounds + ), + dataBounds + ); + } + case 'updateColor': { + const { index, color, palettes, dataBounds } = action.payload; + return withUpdatingPalette( + palettes!, + state.activePalette, + updateColorRangeColor(index, color, state.colorRanges), + dataBounds + ); + } + case 'sortColorRanges': { + const { dataBounds, palettes } = action.payload; + return withUpdatingPalette( + palettes!, + state.activePalette, + sortColorRanges(state.colorRanges), + dataBounds + ); + } + case 'updateValue': { + const { index, value, accessor, dataBounds, palettes } = action.payload; + return withUpdatingPalette( + palettes!, + state.activePalette, + updateColorRangeValue(index, value, accessor, state.colorRanges), + dataBounds + ); + } + case 'deleteColorRange': { + const { index, dataBounds, palettes } = action.payload; + return withUpdatingPalette( + palettes!, + state.activePalette, + deleteColorRange(index, state.colorRanges), + dataBounds + ); + } + case 'updateRangeType': { + const { dataBounds, palettes, rangeType } = action.payload; + const paletteParams = updateRangeType( + rangeType, + state.activePalette, + dataBounds, + palettes, + state.colorRanges + ); + + const newPalette = mergePaletteParams(state.activePalette, paletteParams); + + return withUpdatingColorRanges(palettes, newPalette, dataBounds); + } + case 'changeColorPalette': { + const { dataBounds, palettes, palette, disableSwitchingContinuity } = action.payload; + const newPalette = changeColorPalette( + palette, + state.activePalette, + palettes, + dataBounds, + disableSwitchingContinuity + ); + return withUpdatingColorRanges(palettes, newPalette, dataBounds); + } + default: + throw new Error('wrong action'); + } +}; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_panel_container.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_panel_container.tsx index abcd714b3af97..cf01b60b1c42c 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_panel_container.tsx +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_panel_container.tsx @@ -25,13 +25,13 @@ import { export function PalettePanelContainer({ isOpen, handleClose, - children, siblingRef, + children, }: { isOpen: boolean; handleClose: () => void; - children: React.ReactElement | React.ReactElement[]; siblingRef: MutableRefObject; + children?: React.ReactElement | React.ReactElement[]; }) { const [focusTrapIsEnabled, setFocusTrapIsEnabled] = useState(false); @@ -91,7 +91,7 @@ export function PalettePanelContainer({ -
{children}
+ {children &&
{children}
} diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx index 19da4eef29969..5b3b514bcb23a 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx @@ -15,7 +15,6 @@ import { defaultPaletteParams, } from './constants'; import type { CustomPaletteParams } from '../../../common'; -import { getStopsForFixedMode } from './utils'; function getCustomPaletteConfig( palettes: PaletteRegistry, @@ -52,7 +51,9 @@ function getCustomPaletteConfig( title, type: FIXED_PROGRESSION, 'data-test-subj': `custom-palette`, - palette: getStopsForFixedMode(activePalette.params.stops, activePalette.params.colorStops), + palette: (activePalette.params.colorStops || activePalette.params.stops).map( + (colorStop) => colorStop.color + ), }; } diff --git a/x-pack/plugins/lens/public/shared_components/coloring/types.ts b/x-pack/plugins/lens/public/shared_components/coloring/types.ts new file mode 100644 index 0000000000000..00ffb12e70715 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/types.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; +import type { CustomPaletteParams } from '../../../common'; +import type { ColorRange, ColorRangesActions } from './color_ranges'; + +export interface PaletteConfigurationState { + activePalette: PaletteOutput; + colorRanges: ColorRange[]; +} + +/** @internal **/ +export interface DataBounds { + min: number; + max: number; + fallback?: boolean; +} + +/** @internal **/ +export interface UpdateRangeTypePayload { + rangeType: CustomPaletteParams['rangeType']; + palettes: PaletteRegistry; + dataBounds: DataBounds; +} + +/** @internal **/ +export interface ChangeColorPalettePayload { + palette: PaletteOutput; + palettes: PaletteRegistry; + dataBounds: DataBounds; + disableSwitchingContinuity: boolean; +} + +export type PaletteConfigurationActions = + | ColorRangesActions + | { type: 'updateRangeType'; payload: UpdateRangeTypePayload } + | { type: 'changeColorPalette'; payload: ChangeColorPalettePayload }; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts b/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts index 07d93ca5c40c6..8a01e2a12c2c5 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts +++ b/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts @@ -17,7 +17,8 @@ import { mergePaletteParams, remapStopsByNewInterval, reversePalette, - roundStopValues, + updateRangeType, + changeColorPalette, } from './utils'; describe('applyPaletteParams', () => { @@ -411,14 +412,6 @@ describe('isValidColor', () => { }); }); -describe('roundStopValues', () => { - it('should round very long values', () => { - expect(roundStopValues([{ color: 'red', stop: 0.1515 }])).toEqual([ - { color: 'red', stop: 0.15 }, - ]); - }); -}); - describe('getStepValue', () => { it('should compute the next step based on the last 2 stops', () => { expect( @@ -490,3 +483,310 @@ describe('getContrastColor', () => { expect(getContrastColor('rgba(255,255,255,0)', false)).toBe('#000000'); }); }); + +describe('updateRangeType', () => { + const paletteRegistry = chartPluginMock.createPaletteRegistry(); + const colorRanges = [ + { + start: 0, + end: 40, + color: 'green', + }, + { + start: 40, + end: 80, + color: 'blue', + }, + { + start: 80, + end: 100, + color: 'red', + }, + ]; + it('should correctly update palette params with new range type if continuity is none', () => { + const newPaletteParams = updateRangeType( + 'number', + { + type: 'palette', + name: 'custom', + params: { + continuity: 'none', + name: 'custom', + rangeType: 'percent', + rangeMax: 100, + rangeMin: 0, + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 40 }, + { color: 'red', stop: 80 }, + ], + }, + }, + { min: 0, max: 200 }, + paletteRegistry, + colorRanges + ); + expect(newPaletteParams).toEqual({ + rangeType: 'number', + rangeMin: 0, + rangeMax: 200, + colorStops: [ + { + color: 'green', + stop: 0, + }, + { + color: 'blue', + stop: 80, + }, + { + color: 'red', + stop: 160, + }, + ], + stops: [ + { + color: 'green', + stop: 80, + }, + { + color: 'blue', + stop: 160, + }, + { + color: 'red', + stop: 200, + }, + ], + }); + }); + + it('should correctly update palette params with new range type if continuity is all', () => { + const newPaletteParams = updateRangeType( + 'number', + { + type: 'palette', + name: 'custom', + params: { + continuity: 'all', + name: 'custom', + rangeType: 'percent', + rangeMax: 100, + rangeMin: 0, + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 40 }, + { color: 'red', stop: 80 }, + ], + }, + }, + { min: 0, max: 200 }, + paletteRegistry, + colorRanges + ); + expect(newPaletteParams).toEqual({ + rangeType: 'number', + rangeMin: Number.NEGATIVE_INFINITY, + rangeMax: Number.POSITIVE_INFINITY, + colorStops: [ + { + color: 'green', + stop: 0, + }, + { + color: 'blue', + stop: 80, + }, + { + color: 'red', + stop: 160, + }, + ], + stops: [ + { + color: 'green', + stop: 80, + }, + { + color: 'blue', + stop: 160, + }, + { + color: 'red', + stop: 200, + }, + ], + }); + }); + + it('should correctly update palette params with new range type if continuity is below', () => { + const newPaletteParams = updateRangeType( + 'number', + { + type: 'palette', + name: 'custom', + params: { + continuity: 'below', + name: 'custom', + rangeType: 'percent', + rangeMax: 100, + rangeMin: 0, + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 40 }, + { color: 'red', stop: 80 }, + ], + }, + }, + { min: 0, max: 200 }, + paletteRegistry, + colorRanges + ); + expect(newPaletteParams).toEqual({ + rangeType: 'number', + rangeMin: Number.NEGATIVE_INFINITY, + rangeMax: 200, + colorStops: [ + { + color: 'green', + stop: 0, + }, + { + color: 'blue', + stop: 80, + }, + { + color: 'red', + stop: 160, + }, + ], + stops: [ + { + color: 'green', + stop: 80, + }, + { + color: 'blue', + stop: 160, + }, + { + color: 'red', + stop: 200, + }, + ], + }); + }); + + it('should correctly update palette params with new range type if continuity is above', () => { + const newPaletteParams = updateRangeType( + 'number', + { + type: 'palette', + name: 'custom', + params: { + continuity: 'above', + name: 'custom', + rangeType: 'percent', + rangeMax: 100, + rangeMin: 0, + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 40 }, + { color: 'red', stop: 80 }, + ], + }, + }, + { min: 0, max: 200 }, + paletteRegistry, + colorRanges + ); + expect(newPaletteParams).toEqual({ + rangeType: 'number', + rangeMin: 0, + rangeMax: Number.POSITIVE_INFINITY, + colorStops: [ + { + color: 'green', + stop: 0, + }, + { + color: 'blue', + stop: 80, + }, + { + color: 'red', + stop: 160, + }, + ], + stops: [ + { + color: 'green', + stop: 80, + }, + { + color: 'blue', + stop: 160, + }, + { + color: 'red', + stop: 200, + }, + ], + }); + }); +}); + +describe('changeColorPalette', () => { + const paletteRegistry = chartPluginMock.createPaletteRegistry(); + + it('should correct update params for new palette', () => { + const newPaletteParams = changeColorPalette( + { + type: 'palette', + name: 'default', + }, + { + type: 'palette', + name: 'custom', + params: { + continuity: 'above', + name: 'custom', + rangeType: 'percent', + rangeMax: 100, + rangeMin: 0, + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 40 }, + { color: 'red', stop: 80 }, + ], + }, + }, + paletteRegistry, + { min: 0, max: 200 }, + false + ); + expect(newPaletteParams).toEqual({ + name: 'default', + type: 'palette', + params: { + rangeType: 'percent', + name: 'default', + continuity: 'above', + rangeMin: 0, + rangeMax: Number.POSITIVE_INFINITY, + reverse: false, + colorStops: undefined, + stops: [ + { + color: 'red', + stop: 0, + }, + { + color: 'black', + stop: 50, + }, + ], + }, + }); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/utils.ts b/x-pack/plugins/lens/public/shared_components/coloring/utils.ts index 010f6e99e39bc..16cb843f3dfb4 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/utils.ts +++ b/x-pack/plugins/lens/public/shared_components/coloring/utils.ts @@ -16,8 +16,16 @@ import { DEFAULT_COLOR_STEPS, DEFAULT_MAX_STOP, DEFAULT_MIN_STOP, + DEFAULT_CONTINUITY, } from './constants'; +import type { ColorRange } from './color_ranges'; +import { toColorStops, sortColorRanges } from './color_ranges/utils'; +import type { PaletteConfigurationState, DataBounds } from './types'; import type { CustomPaletteParams, ColorStop } from '../../../common'; +import { + checkIsMinContinuity, + checkIsMaxContinuity, +} from '../../../../../../src/plugins/charts/common'; /** * Some name conventions here: @@ -36,10 +44,171 @@ import type { CustomPaletteParams, ColorStop } from '../../../common'; * for a single change. */ +export function updateRangeType( + newRangeType: CustomPaletteParams['rangeType'], + activePalette: PaletteConfigurationState['activePalette'], + dataBounds: DataBounds, + palettes: PaletteRegistry, + colorRanges: PaletteConfigurationState['colorRanges'] +) { + const continuity = activePalette.params?.continuity ?? DEFAULT_CONTINUITY; + const params: CustomPaletteParams = { rangeType: newRangeType }; + const { min: newMin, max: newMax } = getDataMinMax(newRangeType, dataBounds); + const { min: oldMin, max: oldMax } = getDataMinMax(activePalette.params?.rangeType, dataBounds); + const newColorStops = getStopsFromColorRangesByNewInterval(colorRanges, { + oldInterval: oldMax - oldMin, + newInterval: newMax - newMin, + newMin, + oldMin, + }); + + if (activePalette.name === CUSTOM_PALETTE) { + const stops = getPaletteStops( + palettes, + { ...activePalette.params, colorStops: newColorStops, ...params }, + { dataBounds } + ); + params.colorStops = newColorStops; + params.stops = stops; + } else { + params.stops = getPaletteStops( + palettes, + { ...activePalette.params, ...params }, + { prevPalette: activePalette.name, dataBounds } + ); + } + + const lastStop = + activePalette.name === CUSTOM_PALETTE + ? newColorStops[newColorStops.length - 1].stop + : params.stops[params.stops.length - 1].stop; + + params.rangeMin = checkIsMinContinuity(continuity) + ? Number.NEGATIVE_INFINITY + : activePalette.name === CUSTOM_PALETTE + ? newColorStops[0].stop + : params.stops[0].stop; + + params.rangeMax = checkIsMaxContinuity(continuity) + ? Number.POSITIVE_INFINITY + : activePalette.params?.rangeMax + ? calculateStop(activePalette.params.rangeMax, newMin, oldMin, oldMax - oldMin, newMax - newMin) + : lastStop > newMax + ? lastStop + 1 + : newMax; + + return params; +} + +export function changeColorPalette( + newPalette: PaletteConfigurationState['activePalette'], + activePalette: PaletteConfigurationState['activePalette'], + palettes: PaletteRegistry, + dataBounds: DataBounds, + disableSwitchingContinuity: boolean +) { + const isNewPaletteCustom = newPalette.name === CUSTOM_PALETTE; + const newParams: CustomPaletteParams = { + ...activePalette.params, + name: newPalette.name, + colorStops: undefined, + continuity: disableSwitchingContinuity + ? activePalette.params?.continuity ?? DEFAULT_CONTINUITY + : DEFAULT_CONTINUITY, + reverse: false, // restore the reverse flag + }; + + // we should pass colorStops so that correct calculate new color stops (if there was before) for custom palette + const newColorStops = getColorStops( + palettes, + activePalette.params?.colorStops || [], + activePalette, + dataBounds + ); + + if (isNewPaletteCustom) { + newParams.colorStops = newColorStops; + } + + return { + ...newPalette, + params: { + ...newParams, + stops: getPaletteStops(palettes, newParams, { + prevPalette: + isNewPaletteCustom || activePalette.name === CUSTOM_PALETTE ? undefined : newPalette.name, + dataBounds, + mapFromMinValue: true, + }), + rangeMin: checkIsMinContinuity(newParams.continuity) + ? Number.NEGATIVE_INFINITY + : Math.min(dataBounds.min, newColorStops[0].stop), + rangeMax: checkIsMaxContinuity(newParams.continuity) + ? Number.POSITIVE_INFINITY + : Math.min(dataBounds.max, newColorStops[newColorStops.length - 1].stop), + }, + }; +} + +export function withUpdatingPalette( + palettes: PaletteRegistry, + activePalette: PaletteConfigurationState['activePalette'], + colorRanges: ColorRange[], + dataBounds: DataBounds, + continuity?: CustomPaletteParams['continuity'] +) { + const currentContinuity = continuity ?? activePalette.params?.continuity ?? DEFAULT_CONTINUITY; + let sortedColorRanges = colorRanges; + if ( + colorRanges.some((value, index) => + index !== colorRanges.length - 1 ? value.start > colorRanges[index + 1].start : false + ) + ) { + sortedColorRanges = sortColorRanges(colorRanges); + } + + const { max, colorStops } = toColorStops(sortedColorRanges, currentContinuity); + + const newPallete = getSwitchToCustomParams( + palettes, + activePalette!, + { + continuity: currentContinuity, + colorStops, + steps: activePalette!.params?.steps || DEFAULT_COLOR_STEPS, + reverse: activePalette!.params?.reverse, + rangeMin: colorStops[0]?.stop, + rangeMax: max, + }, + dataBounds! + ); + + return { + activePalette: newPallete, + colorRanges, + }; +} + +export function withUpdatingColorRanges( + palettes: PaletteRegistry, + activePalette: PaletteConfigurationState['activePalette'], + dataBounds: DataBounds +) { + return { + colorRanges: toColorRanges( + palettes, + activePalette.params?.colorStops || [], + activePalette, + dataBounds + ), + activePalette, + }; +} + export function applyPaletteParams>( palettes: PaletteRegistry, activePalette: T, - dataBounds: { min: number; max: number } + dataBounds: DataBounds ) { // make a copy of it as they have to be manipulated later on const displayStops = getPaletteStops(palettes, activePalette?.params || {}, { @@ -60,6 +229,7 @@ export function shiftPalette(stops: ColorStop[], max: number) { ...entry, stop: i + 1 < array.length ? array[i + 1].stop : max, })); + if (stops[stops.length - 1].stop === max) { // extends the range by a fair amount to make it work the extra case for the last stop === max const computedStep = getStepValue(stops, result, max) || 1; @@ -70,6 +240,17 @@ export function shiftPalette(stops: ColorStop[], max: number) { return result; } +/** @internal **/ +export function calculateStop( + stopValue: number, + newMin: number, + oldMin: number, + oldInterval: number, + newInterval: number +) { + return roundValue(newMin + ((stopValue - oldMin) * newInterval) / oldInterval); +} + // Utility to remap color stops within new domain export function remapStopsByNewInterval( controlStops: ColorStop[], @@ -83,18 +264,40 @@ export function remapStopsByNewInterval( return (controlStops || []).map(({ color, stop }) => { return { color, - stop: newMin + ((stop - oldMin) * newInterval) / oldInterval, + stop: calculateStop(stop, newMin, oldMin, oldInterval, newInterval), }; }); } -function getOverallMinMax( - params: CustomPaletteParams | undefined, - dataBounds: { min: number; max: number } +// Utility to remap color stops within new domain +export function getStopsFromColorRangesByNewInterval( + colorRanges: ColorRange[], + { + newInterval, + oldInterval, + newMin, + oldMin, + }: { newInterval: number; oldInterval: number; newMin: number; oldMin: number } ) { + return (colorRanges || []).map(({ color, start }) => { + let stop = calculateStop(start, newMin, oldMin, oldInterval, newInterval); + + if (oldInterval === 0) { + stop = newInterval + newMin; + } + + return { + color, + stop: roundValue(stop), + }; + }); +} + +function getOverallMinMax(params: CustomPaletteParams | undefined, dataBounds: DataBounds) { const { min: dataMin, max: dataMax } = getDataMinMax(params?.rangeType, dataBounds); - const minStopValue = params?.colorStops?.[0]?.stop ?? Infinity; - const maxStopValue = params?.colorStops?.[params.colorStops.length - 1]?.stop ?? -Infinity; + const minStopValue = params?.colorStops?.[0]?.stop ?? Number.POSITIVE_INFINITY; + const maxStopValue = + params?.colorStops?.[params.colorStops.length - 1]?.stop ?? Number.NEGATIVE_INFINITY; const overallMin = Math.min(dataMin, minStopValue); const overallMax = Math.max(dataMax, maxStopValue); return { min: overallMin, max: overallMax }; @@ -102,7 +305,7 @@ function getOverallMinMax( export function getDataMinMax( rangeType: CustomPaletteParams['rangeType'] | undefined, - dataBounds: { min: number; max: number } + dataBounds: DataBounds ) { const dataMin = rangeType === 'number' ? dataBounds.min : DEFAULT_MIN_STOP; const dataMax = rangeType === 'number' ? dataBounds.max : DEFAULT_MAX_STOP; @@ -123,7 +326,7 @@ export function getPaletteStops( defaultPaletteName, }: { prevPalette?: string; - dataBounds: { min: number; max: number }; + dataBounds: DataBounds; mapFromMinValue?: boolean; defaultPaletteName?: string; } @@ -145,9 +348,9 @@ export function getPaletteStops( ) .getCategoricalColors(steps, otherParams); - const newStopsMin = mapFromMinValue ? minValue : interval / steps; + const newStopsMin = mapFromMinValue || interval === 0 ? minValue : interval / steps; - const stops = remapStopsByNewInterval( + return remapStopsByNewInterval( colorStopsFromPredefined.map((color, index) => ({ color, stop: index })), { newInterval: interval, @@ -156,7 +359,6 @@ export function getPaletteStops( oldMin: 0, } ); - return stops; } export function reversePalette(paletteColorRepresentation: ColorStop[] = []) { @@ -198,12 +400,8 @@ export function isValidColor(colorString: string) { return colorString !== '' && /^#/.test(colorString) && isValidPonyfill(colorString); } -export function roundStopValues(colorStops: ColorStop[]) { - return colorStops.map(({ color, stop }) => { - // when rounding mind to not go in excess, rather use the floor function - const roundedStop = Number((Math.floor(stop * 100) / 100).toFixed(2)); - return { color, stop: roundedStop }; - }); +export function roundValue(value: number, fractionDigits: number = 2) { + return Number((Math.floor(value * 100) / 100).toFixed(fractionDigits)); } // very simple heuristic: pick last two stops and compute a new stop based on the same distance @@ -227,7 +425,7 @@ export function getSwitchToCustomParams( palettes: PaletteRegistry, activePalette: PaletteOutput, newParams: CustomPaletteParams, - dataBounds: { min: number; max: number } + dataBounds: DataBounds ) { // if it's already a custom palette just return the params if (activePalette?.params?.name === CUSTOM_PALETTE) { @@ -272,7 +470,7 @@ export function getColorStops( palettes: PaletteRegistry, colorStops: Required['stops'], activePalette: PaletteOutput, - dataBounds: { min: number; max: number } + dataBounds: DataBounds ) { // just forward the current stops if custom if (activePalette?.name === CUSTOM_PALETTE && colorStops?.length) { @@ -293,6 +491,47 @@ export function getColorStops( return freshColorStops; } +/** + * Both table coloring logic and EuiPaletteDisplay format implementation works differently than our current `colorStops`, + * by having the stop values at the end of each color segment rather than at the beginning: `stops` values are computed by a rightShift of `colorStops`. + * EuiPaletteDisplay has an additional requirement as it is always mapped against a domain [0, N]: from `stops` the `displayStops` are computed with + * some continuity enrichment and a remap against a [0, 100] domain to make the palette component work ok. + * + * These naming conventions would be useful to track the code flow in this feature as multiple transformations are happening + * for a single change. + */ +export function toColorRanges( + palettes: PaletteRegistry, + colorStops: CustomPaletteParams['colorStops'], + activePalette: PaletteOutput, + dataBounds: DataBounds +) { + const { + continuity = defaultPaletteParams.continuity, + rangeType = defaultPaletteParams.rangeType, + } = activePalette.params ?? {}; + const { min: dataMin, max: dataMax } = getDataMinMax(rangeType, dataBounds); + + return getColorStops(palettes, colorStops || [], activePalette, dataBounds).map( + (colorStop, index, array) => { + const isFirst = index === 0; + const isLast = index === array.length - 1; + + return { + color: colorStop.color, + start: + isFirst && checkIsMinContinuity(continuity) + ? Number.NEGATIVE_INFINITY + : colorStop.stop ?? activePalette.params?.rangeMin ?? dataMin, + end: + isLast && checkIsMaxContinuity(continuity) + ? Number.POSITIVE_INFINITY + : array[index + 1]?.stop ?? activePalette.params?.rangeMax ?? dataMax, + }; + } + ); +} + export function getContrastColor( color: string, isDarkTheme: boolean, @@ -312,27 +551,6 @@ export function getContrastColor( return isColorDark(...finalColor.rgb()) ? lightColor : darkColor; } -/** - * Same as stops, but remapped against a range 0-100 - */ -export function getStopsForFixedMode(stops: ColorStop[], colorStops?: ColorStop[]) { - const referenceStops = - colorStops || stops.map(({ color }, index) => ({ color, stop: 20 * index })); - const fallbackStops = stops; - - // what happens when user set two stops with the same value? we'll fallback to the display interval - const oldInterval = - referenceStops[referenceStops.length - 1].stop - referenceStops[0].stop || - fallbackStops[fallbackStops.length - 1].stop - fallbackStops[0].stop; - - return remapStopsByNewInterval(stops, { - newInterval: 100, - oldInterval, - newMin: 0, - oldMin: referenceStops[0].stop, - }); -} - function getId(id: string) { return id; } @@ -344,17 +562,35 @@ export function getNumericValue(rowValue: number | number[] | undefined) { return rowValue; } +export const getFallbackDataBounds = ( + rangeType: CustomPaletteParams['rangeType'] = 'percent' +): DataBounds => + rangeType === 'percent' + ? { + min: 0, + max: 100, + fallback: true, + } + : { + min: 1, + max: 1, + fallback: true, + }; + export const findMinMaxByColumnId = ( columnIds: string[], table: Datatable | undefined, getOriginalId: (id: string) => string = getId ) => { - const minMax: Record = {}; + const minMax: Record = {}; if (table != null) { for (const columnId of columnIds) { const originalId = getOriginalId(columnId); - minMax[originalId] = minMax[originalId] || { max: -Infinity, min: Infinity }; + minMax[originalId] = minMax[originalId] || { + max: Number.NEGATIVE_INFINITY, + min: Number.POSITIVE_INFINITY, + }; table.rows.forEach((row) => { const rowValue = row[columnId]; const numericValue = getNumericValue(rowValue); @@ -368,8 +604,8 @@ export const findMinMaxByColumnId = ( } }); // what happens when there's no data in the table? Fallback to a percent range - if (minMax[originalId].max === -Infinity) { - minMax[originalId] = { max: 100, min: 0, fallback: true }; + if (minMax[originalId].max === Number.NEGATIVE_INFINITY) { + minMax[originalId] = getFallbackDataBounds(); } } } diff --git a/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx b/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx index f5031242d268c..18084a8c3db51 100644 --- a/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx @@ -32,7 +32,6 @@ import { CustomizablePalette, CUSTOM_PALETTE, FIXED_PROGRESSION, - getStopsForFixedMode, PalettePanelContainer, TooltipWrapper, } from '../../shared_components/'; @@ -70,6 +69,7 @@ export function GaugeDimensionEditor( name: defaultPaletteParams.name, params: { ...defaultPaletteParams, + continuity: 'all', colorStops: undefined, stops: undefined, rangeMin: currentMinMax.min, @@ -141,14 +141,7 @@ export function GaugeDimensionEditor( color) - } + palette={displayStops.map(({ color }) => color)} type={FIXED_PROGRESSION} onClick={togglePalette} /> @@ -174,7 +167,7 @@ export function GaugeDimensionEditor( palettes={props.paletteService} activePalette={activePalette} dataBounds={currentMinMax} - showContinuity={false} + disableSwitchingContinuity={true} setPalette={(newPalette) => { // if the new palette is not custom, replace the rangeMin with the artificial one if ( diff --git a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx index 94a2ec2eaac1c..670f44e47270d 100644 --- a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx @@ -29,7 +29,7 @@ import type { DatasourcePublicAPI, OperationMetadata, Visualization } from '../. import { getSuggestions } from './suggestions'; import { GROUP_ID, LENS_GAUGE_ID, GaugeVisualizationState } from './constants'; import { GaugeToolbar } from './toolbar_component'; -import { applyPaletteParams, CUSTOM_PALETTE, getStopsForFixedMode } from '../../shared_components'; +import { applyPaletteParams, CUSTOM_PALETTE } from '../../shared_components'; import { GaugeDimensionEditor } from './dimension_editor'; import { CustomPaletteParams, layerTypes } from '../../../common'; import { generateId } from '../../id_generator'; @@ -223,7 +223,7 @@ export const getGaugeVisualization = ({ const currentMinMax = { min: getMinValue(row, state), max: getMaxValue(row, state) }; const displayStops = applyPaletteParams(paletteService, state?.palette, currentMinMax); - palette = getStopsForFixedMode(displayStops, state?.palette?.params?.colorStops); + palette = displayStops.map(({ color }) => color); } const invalidProps = checkInvalidConfiguration(row, state) || {}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2a18190d0b2a5..4ed57d7b8aaf7 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -224,12 +224,7 @@ "xpack.lens.dragDrop.keyboardInstructionsReorder": "スペースまたはEnterを押してドラッグを開始します。ドラッグするときには、上下矢印キーを使用すると、グループの項目を並べ替えます。左右矢印キーを使用すると、グループの外側でドロップ対象を選択します。もう一度スペースまたはEnterを押すと終了します。", "xpack.lens.dragDrop.shift": "Shift", "xpack.lens.dragDrop.swap": "入れ替える", - "xpack.lens.dynamicColoring.customPalette.addColorStop": "色経由点を追加", "xpack.lens.dynamicColoring.customPalette.deleteButtonAriaLabel": "削除", - "xpack.lens.dynamicColoring.customPalette.deleteButtonDisabled": "2つ以上の経由点が必要であるため、この色経由点を削除することはできません", - "xpack.lens.dynamicColoring.customPalette.deleteButtonLabel": "削除", - "xpack.lens.dynamicColoring.customPalette.sortReason": "新しい経由値{value}のため、色経由点が並べ替えられました", - "xpack.lens.dynamicColoring.customPalette.stopAriaLabel": "{index}を停止", "xpack.lens.editorFrame.buildExpressionError": "グラフの準備中に予期しないエラーが発生しました", "xpack.lens.editorFrame.colorIndicatorLabel": "このディメンションの色:{hex}", "xpack.lens.editorFrame.dataFailure": "データの読み込み中にエラーが発生しました。", @@ -712,20 +707,12 @@ "xpack.lens.table.columnVisibilityLabel": "列を非表示", "xpack.lens.table.defaultAriaLabel": "データ表ビジュアライゼーション", "xpack.lens.table.dynamicColoring.cell": "セル", - "xpack.lens.table.dynamicColoring.continuity.aboveLabel": "範囲の上", - "xpack.lens.table.dynamicColoring.continuity.allLabel": "範囲の上下", - "xpack.lens.table.dynamicColoring.continuity.belowLabel": "範囲の下", - "xpack.lens.table.dynamicColoring.continuity.label": "色の連続", - "xpack.lens.table.dynamicColoring.continuity.noneLabel": "範囲内", "xpack.lens.table.dynamicColoring.customPalette.colorStopsHelpPercentage": "割合値は使用可能なデータ値の全範囲に対して相対的です。", - "xpack.lens.table.dynamicColoring.customPalette.colorStopsLabel": "色経由点", - "xpack.lens.table.dynamicColoring.customPalette.continuityHelp": "最初の色経由点の前、最後の色経由点の後に色が表示される方法を指定します。", "xpack.lens.table.dynamicColoring.label": "値別の色", "xpack.lens.table.dynamicColoring.none": "なし", "xpack.lens.table.dynamicColoring.rangeType.label": "値型", "xpack.lens.table.dynamicColoring.rangeType.number": "数字", "xpack.lens.table.dynamicColoring.rangeType.percent": "割合(%)", - "xpack.lens.table.dynamicColoring.reverse.label": "色を反転", "xpack.lens.table.dynamicColoring.text": "テキスト", "xpack.lens.table.hide.hideLabel": "非表示", "xpack.lens.table.palettePanelContainer.back": "戻る", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index dfc4576371768..7129a804f921f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -228,12 +228,7 @@ "xpack.lens.dragDrop.keyboardInstructionsReorder": "按空格键或 enter 键开始拖动。拖动时,请使用上下箭头键重新排列组中的项目,使用左右箭头键在组之外选择拖动目标。再次按空格键或 enter 键结束操作。", "xpack.lens.dragDrop.shift": "Shift 键", "xpack.lens.dragDrop.swap": "交换", - "xpack.lens.dynamicColoring.customPalette.addColorStop": "添加颜色停止", "xpack.lens.dynamicColoring.customPalette.deleteButtonAriaLabel": "删除", - "xpack.lens.dynamicColoring.customPalette.deleteButtonDisabled": "此颜色停止无法删除,因为需要两个或更多停止", - "xpack.lens.dynamicColoring.customPalette.deleteButtonLabel": "删除", - "xpack.lens.dynamicColoring.customPalette.sortReason": "由于新停止值 {value},颜色停止已排序", - "xpack.lens.dynamicColoring.customPalette.stopAriaLabel": "停止 {index}", "xpack.lens.editorFrame.buildExpressionError": "准备图表时发生意外错误", "xpack.lens.editorFrame.colorIndicatorLabel": "此维度的颜色:{hex}", "xpack.lens.editorFrame.configurationFailureMoreErrors": " +{errors} 个{errors, plural, other {错误}}", @@ -724,20 +719,12 @@ "xpack.lens.table.columnVisibilityLabel": "隐藏列", "xpack.lens.table.defaultAriaLabel": "数据表可视化", "xpack.lens.table.dynamicColoring.cell": "单元格", - "xpack.lens.table.dynamicColoring.continuity.aboveLabel": "高于范围", - "xpack.lens.table.dynamicColoring.continuity.allLabel": "高于和低于范围", - "xpack.lens.table.dynamicColoring.continuity.belowLabel": "低于范围", - "xpack.lens.table.dynamicColoring.continuity.label": "颜色连续性", - "xpack.lens.table.dynamicColoring.continuity.noneLabel": "范围内", "xpack.lens.table.dynamicColoring.customPalette.colorStopsHelpPercentage": "百分比值是相对于全范围可用数据值的类型。", - "xpack.lens.table.dynamicColoring.customPalette.colorStopsLabel": "颜色停止", - "xpack.lens.table.dynamicColoring.customPalette.continuityHelp": "指定颜色在第一个颜色停止之前和最后一个颜色停止之后的出现方式。", "xpack.lens.table.dynamicColoring.label": "按值上色", "xpack.lens.table.dynamicColoring.none": "无", "xpack.lens.table.dynamicColoring.rangeType.label": "值类型", "xpack.lens.table.dynamicColoring.rangeType.number": "数字", "xpack.lens.table.dynamicColoring.rangeType.percent": "百分比", - "xpack.lens.table.dynamicColoring.reverse.label": "反转颜色", "xpack.lens.table.dynamicColoring.text": "文本", "xpack.lens.table.hide.hideLabel": "隐藏", "xpack.lens.table.palettePanelContainer.back": "返回", diff --git a/x-pack/test/functional/apps/lens/heatmap.ts b/x-pack/test/functional/apps/lens/heatmap.ts index e4f20d075541f..0318f544a4566 100644 --- a/x-pack/test/functional/apps/lens/heatmap.ts +++ b/x-pack/test/functional/apps/lens/heatmap.ts @@ -73,7 +73,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.openDimensionEditor('lnsHeatmap_cellPanel > lns-dimensionTrigger'); await PageObjects.lens.openPalettePanel('lnsHeatmap'); await retry.try(async () => { - await testSubjects.setValue('lnsPalettePanel_dynamicColoring_stop_value_0', '10', { + await testSubjects.setValue('lnsPalettePanel_dynamicColoring_range_value_0', '10', { clearWithKeyboard: true, typeCharByChar: true, }); @@ -108,16 +108,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // assert legend has changed expect(debugState.legend!.items).to.eql([ - { key: '7,126 - 8,529.22', name: '7,126 - 8,529.22', color: '#6092c0' }, - { key: '8,529.22 - 11,335.66', name: '8,529.22 - 11,335.66', color: '#a8bfda' }, - { key: '11,335.66 - 14,142.11', name: '11,335.66 - 14,142.11', color: '#ebeff5' }, - { key: '14,142.11 - 16,948.55', name: '14,142.11 - 16,948.55', color: '#ecb385' }, - { key: '≥ 16,948.55', name: '≥ 16,948.55', color: '#e7664c' }, + { key: '7,125.99 - 8,529.2', name: '7,125.99 - 8,529.2', color: '#6092c0' }, + { key: '8,529.2 - 11,335.66', name: '8,529.2 - 11,335.66', color: '#a8bfda' }, + { key: '11,335.66 - 14,142.1', name: '11,335.66 - 14,142.1', color: '#ebeff5' }, + { key: '14,142.1 - 16,948.55', name: '14,142.1 - 16,948.55', color: '#ecb385' }, + { + color: '#e7664c', + key: '≥ 16,948.55', + name: '≥ 16,948.55', + }, ]); }); it('should reflect stop changes when in number to the chart', async () => { - await testSubjects.setValue('lnsPalettePanel_dynamicColoring_stop_value_0', '0', { + await testSubjects.setValue('lnsPalettePanel_dynamicColoring_range_value_0', '0', { clearWithKeyboard: true, }); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -130,8 +134,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // assert legend has changed expect(debugState.legend!.items).to.eql([ - { key: '0 - 8,529.21', name: '0 - 8,529.21', color: '#6092c0' }, - { key: '8,529.21 - 11,335.66', name: '8,529.21 - 11,335.66', color: '#a8bfda' }, + { key: '0 - 8,529.2', name: '0 - 8,529.2', color: '#6092c0' }, + { key: '8,529.2 - 11,335.66', name: '8,529.2 - 11,335.66', color: '#a8bfda' }, { key: '11,335.66 - 14,142.1', name: '11,335.66 - 14,142.1', color: '#ebeff5' }, { key: '14,142.1 - 16,948.55', name: '14,142.1 - 16,948.55', color: '#ecb385' }, { key: '≥ 16,948.55', name: '≥ 16,948.55', color: '#e7664c' }, diff --git a/x-pack/test/functional/apps/lens/metrics.ts b/x-pack/test/functional/apps/lens/metrics.ts index 79f37df60cccf..19f463e3569d8 100644 --- a/x-pack/test/functional/apps/lens/metrics.ts +++ b/x-pack/test/functional/apps/lens/metrics.ts @@ -34,7 +34,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should change the color of the metric when tweaking the values in the panel', async () => { await PageObjects.lens.openPalettePanel('lnsMetric'); await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.setValue('lnsPalettePanel_dynamicColoring_stop_value_1', '21000', { + await testSubjects.setValue('lnsPalettePanel_dynamicColoring_range_value_1', '21000', { clearWithKeyboard: true, }); await PageObjects.lens.waitForVisualization(); @@ -43,7 +43,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should change the color when reverting the palette', async () => { - await testSubjects.click('lnsPalettePanel_dynamicColoring_reverse'); + await testSubjects.click('lnsPalettePanel_dynamicColoring_reverseColors'); await PageObjects.lens.waitForVisualization(); const styleObj = await PageObjects.lens.getMetricStyle(); expect(styleObj.color).to.be('rgb(204, 86, 66)'); diff --git a/x-pack/test/functional/apps/lens/table.ts b/x-pack/test/functional/apps/lens/table.ts index 94bc5e8b266ea..2070eb047ef61 100644 --- a/x-pack/test/functional/apps/lens/table.ts +++ b/x-pack/test/functional/apps/lens/table.ts @@ -144,11 +144,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.changePaletteTo('temperature'); await testSubjects.click('lnsPalettePanel_dynamicColoring_rangeType_groups_percent'); // now tweak the value - await testSubjects.setValue('lnsPalettePanel_dynamicColoring_stop_value_0', '30', { + await testSubjects.setValue('lnsPalettePanel_dynamicColoring_range_value_0', '30', { clearWithKeyboard: true, }); // when clicking on another row will trigger a sorting + update - await testSubjects.click('lnsPalettePanel_dynamicColoring_stop_value_1'); + await testSubjects.click('lnsPalettePanel_dynamicColoring_range_value_1'); await PageObjects.header.waitUntilLoadingHasFinished(); // pick a cell without color as is below the range const styleObj = await PageObjects.lens.getDatatableCellStyle(3, 3); @@ -158,7 +158,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should allow the user to reverse the palette', async () => { - await testSubjects.click('lnsPalettePanel_dynamicColoring_reverse'); + await testSubjects.click('lnsPalettePanel_dynamicColoring_reverseColors'); await PageObjects.header.waitUntilLoadingHasFinished(); const styleObj = await PageObjects.lens.getDatatableCellStyle(1, 1); expect(styleObj['background-color']).to.be('rgb(168, 191, 218)'); From 24f04252848635b9def4c0c756575cd58b637024 Mon Sep 17 00:00:00 2001 From: Kevin Lacabane Date: Tue, 25 Jan 2022 15:29:18 +0100 Subject: [PATCH 07/46] [Stack monitoring] remove support for monitoring.cluster_alerts.allowedSpaces (#123229) * remove allowed space setting * set allowedSpaces as unused setting * mock unused function in deprecation tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- api_docs/monitoring.json | 4 ++-- docs/settings/monitoring-settings.asciidoc | 3 --- x-pack/plugins/monitoring/server/config.test.ts | 3 --- x-pack/plugins/monitoring/server/config.ts | 1 - x-pack/plugins/monitoring/server/deprecations.test.js | 3 ++- x-pack/plugins/monitoring/server/deprecations.ts | 3 +++ .../monitoring/server/routes/api/v1/alerts/enable.ts | 11 ----------- 7 files changed, 7 insertions(+), 21 deletions(-) diff --git a/api_docs/monitoring.json b/api_docs/monitoring.json index 044aebf1c415a..5283f208c7969 100644 --- a/api_docs/monitoring.json +++ b/api_docs/monitoring.json @@ -149,7 +149,7 @@ "signature": [ "{ ui: { elasticsearch: ", "MonitoringElasticsearchConfig", - "; enabled: boolean; container: Readonly<{} & { logstash: Readonly<{} & { enabled: boolean; }>; apm: Readonly<{} & { enabled: boolean; }>; elasticsearch: Readonly<{} & { enabled: boolean; }>; }>; logs: Readonly<{} & { index: string; }>; metricbeat: Readonly<{} & { index: string; }>; debug_mode: boolean; debug_log_path: string; ccs: Readonly<{} & { enabled: boolean; }>; max_bucket_size: number; min_interval_seconds: number; show_license_expiration: boolean; }; tests: Readonly<{} & { cloud_detector: Readonly<{} & { enabled: boolean; }>; }>; kibana: Readonly<{} & { collection: Readonly<{} & { interval: number; enabled: boolean; }>; }>; agent: Readonly<{} & { interval: string; }>; licensing: Readonly<{} & { api_polling_frequency: moment.Duration; }>; cluster_alerts: Readonly<{} & { enabled: boolean; allowedSpaces: string[]; email_notifications: Readonly<{} & { enabled: boolean; email_address: string; }>; }>; }" + "; enabled: boolean; container: Readonly<{} & { logstash: Readonly<{} & { enabled: boolean; }>; apm: Readonly<{} & { enabled: boolean; }>; elasticsearch: Readonly<{} & { enabled: boolean; }>; }>; logs: Readonly<{} & { index: string; }>; metricbeat: Readonly<{} & { index: string; }>; debug_mode: boolean; debug_log_path: string; ccs: Readonly<{} & { enabled: boolean; }>; max_bucket_size: number; min_interval_seconds: number; show_license_expiration: boolean; }; tests: Readonly<{} & { cloud_detector: Readonly<{} & { enabled: boolean; }>; }>; kibana: Readonly<{} & { collection: Readonly<{} & { interval: number; enabled: boolean; }>; }>; agent: Readonly<{} & { interval: string; }>; licensing: Readonly<{} & { api_polling_frequency: moment.Duration; }>; cluster_alerts: Readonly<{} & { enabled: boolean; email_notifications: Readonly<{} & { enabled: boolean; email_address: string; }>; }>; }" ], "path": "x-pack/plugins/monitoring/server/config.ts", "deprecated": false, @@ -195,4 +195,4 @@ "misc": [], "objects": [] } -} \ No newline at end of file +} diff --git a/docs/settings/monitoring-settings.asciidoc b/docs/settings/monitoring-settings.asciidoc index 8bc98a028b8f6..d8bc26b7b3987 100644 --- a/docs/settings/monitoring-settings.asciidoc +++ b/docs/settings/monitoring-settings.asciidoc @@ -72,9 +72,6 @@ For more information, see | `monitoring.ui.elasticsearch.ssl` | Shares the same configuration as <>. These settings configure encrypted communication between {kib} and the monitoring cluster. -| `monitoring.cluster_alerts.allowedSpaces` {ess-icon} - | Specifies the spaces where cluster Stack Monitoring alerts can be created. You must specify all spaces where you want to generate alerts, including the default space. Defaults to `[ "default" ]`. - |=== [float] diff --git a/x-pack/plugins/monitoring/server/config.test.ts b/x-pack/plugins/monitoring/server/config.test.ts index 22e7b74368ebf..c97e60e67ac4e 100644 --- a/x-pack/plugins/monitoring/server/config.test.ts +++ b/x-pack/plugins/monitoring/server/config.test.ts @@ -31,9 +31,6 @@ describe('config schema', () => { "interval": "10s", }, "cluster_alerts": Object { - "allowedSpaces": Array [ - "default", - ], "email_notifications": Object { "email_address": "", "enabled": true, diff --git a/x-pack/plugins/monitoring/server/config.ts b/x-pack/plugins/monitoring/server/config.ts index 3facfd97319f2..9b6bf4c393b44 100644 --- a/x-pack/plugins/monitoring/server/config.ts +++ b/x-pack/plugins/monitoring/server/config.ts @@ -55,7 +55,6 @@ export const configSchema = schema.object({ }), }), cluster_alerts: schema.object({ - allowedSpaces: schema.arrayOf(schema.string(), { defaultValue: ['default'] }), enabled: schema.boolean({ defaultValue: true }), email_notifications: schema.object({ enabled: schema.boolean({ defaultValue: true }), diff --git a/x-pack/plugins/monitoring/server/deprecations.test.js b/x-pack/plugins/monitoring/server/deprecations.test.js index 9216132fd6119..f843976ab51d8 100644 --- a/x-pack/plugins/monitoring/server/deprecations.test.js +++ b/x-pack/plugins/monitoring/server/deprecations.test.js @@ -13,10 +13,11 @@ describe('monitoring plugin deprecations', function () { const deprecate = jest.fn(() => jest.fn()); const rename = jest.fn(() => jest.fn()); const renameFromRoot = jest.fn(() => jest.fn()); + const unused = jest.fn(() => jest.fn()); const fromPath = 'monitoring'; beforeAll(function () { - const deprecations = deprecationsModule({ deprecate, rename, renameFromRoot }); + const deprecations = deprecationsModule({ deprecate, rename, renameFromRoot, unused }); transformDeprecations = (settings, fromPath, addDeprecation = noop) => { deprecations.forEach((deprecation) => deprecation(settings, fromPath, addDeprecation)); }; diff --git a/x-pack/plugins/monitoring/server/deprecations.ts b/x-pack/plugins/monitoring/server/deprecations.ts index 14c3b9cdc6474..af5ea19499681 100644 --- a/x-pack/plugins/monitoring/server/deprecations.ts +++ b/x-pack/plugins/monitoring/server/deprecations.ts @@ -20,6 +20,7 @@ import { CLUSTER_ALERTS_ADDRESS_CONFIG_KEY } from '../common/constants'; export const deprecations = ({ rename, renameFromRoot, + unused, }: ConfigDeprecationFactory): ConfigDeprecation[] => { return [ // This order matters. The "blanket rename" needs to happen at the end @@ -76,6 +77,8 @@ export const deprecations = ({ level: 'warning', }), + unused('cluster_alerts.allowedSpaces', { level: 'warning' }), + // TODO: Add deprecations for "monitoring.ui.elasticsearch.username: elastic" and "monitoring.ui.elasticsearch.username: kibana". // TODO: Add deprecations for using "monitoring.ui.elasticsearch.ssl.certificate" without "monitoring.ui.elasticsearch.ssl.key", and // vice versa. diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts index 7185d399b3534..a441cad27b394 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts @@ -24,17 +24,6 @@ export function enableAlertsRoute(server: LegacyServer, npRoute: RouteDependenci }, async (context, request, response) => { try { - // Check to ensure the space is listed in monitoring.cluster_alerts.allowedSpaces - const config = server.config(); - const allowedSpaces = - config.get('monitoring.cluster_alerts.allowedSpaces') || ([] as string[]); - if (!allowedSpaces.includes(context.infra.spaceId)) { - server.log.info( - `Skipping alert creation for "${context.infra.spaceId}" space; add space ID to 'monitoring.cluster_alerts.allowedSpaces' in your kibana.yml` - ); - return response.ok({ body: undefined }); - } - const alerts = AlertsFactory.getAll(); if (alerts.length) { const { isSufficientlySecure, hasPermanentEncryptionKey } = npRoute.alerting From 3aa72f862da97d50ab6d825d30b192f1c009cedf Mon Sep 17 00:00:00 2001 From: Dmitrii Shevchenko Date: Tue, 25 Jan 2022 15:34:09 +0100 Subject: [PATCH 08/46] [Security Solution][Detections] In-memory rules table implementation (#119611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Addresses: https://github.com/elastic/kibana/issues/119601** ## Summary With this implementation, we load detection rules into the in-memory cache on the initial page load. This change has several notable effects visible to users: - Table pagination is now almost instant as we don't need to make an additional HTTP request to render the table. - Table filters get applied instantly when the cache is hot as we use filter parameters as the cache key. If a user has already filtered rules by a specific parameter, we will first display the rules using cached data and re-fetch for new data in the background. - Table sorting and filtration also happen without visible delays as we don't need to make an additional HTTP request for that. - Faster navigation between pages once rules data is loaded. We do not re-fetch all rules if a user leaves the rules management page and then returns. - Introduced an adjustable threshold that represents the maximum number of rules for which the in-memory implementation is enabled (by default 3000). Screenshot 2022-01-20 at 16 47 45 - Added an indication of when the advanced sorting capabilities were enabled or disabled: Screenshot 2022-01-20 at 16 49 23Screenshot 2022-01-20 at 16 52 29 - Added sorting by all rules table columns. - Removed Idle modal and the `idleTimeout` UI setting. Added a saved object migration to remove the unused setting. Notable changes from a technical standpoint: - Automatic query cancellation; Removed all AbortSignal-related manual logic. - Re-fetching by a timer logic has been removed in favor of react-query built-in implementation. - Removed manual logic of keeping `lastUpdated` date up to date along with `isLoading` and `isRefreshing` flags. It also has been delegated to react-query. - Refetch behavior slightly changed. We re-fetch table data only when the browser tab is active and do not re-fetch in the background. Additionally, we re-fetch when the browser tab becomes active. ## Scalability Risks It's worth noting performance deterioration with an increasing number of parallel requests: **20 rules per request (200 requests, 50 in parallel)** ``` Summary: Total: 10.4503 secs Slowest: 1.6089 secs Fastest: 0.3398 secs Average: 1.0381 secs Requests/sec: 19.1383 ``` **100 rules per request (200 requests, 50 in parallel)** ``` Summary: Total: 14.0456 secs Slowest: 1.9323 secs Fastest: 0.9952 secs Average: 1.3991 secs Requests/sec: 14.2393 ``` **500 rules per request (200 requests, 50 in parallel)** ``` Summary: Total: 32.6509 secs Slowest: 4.8964 secs Fastest: 0.5494 secs Average: 3.2379 secs Requests/sec: 6.1254 ``` **1000 rules per request (200 requests, 50 in parallel)** ``` Summary: Total: 47.8000 secs Slowest: 6.1776 secs Fastest: 1.2028 secs Average: 4.7547 secs Requests/sec: 4.1841 ``` ### JSON response parsing time Time spent by Kibana parsing Elasticsearch response JSON.   | 20 rules | 100 rules | 500 rules | 2500 rules -- | -- | -- | -- | -- mean, ms | 9.920000 | 22.540000 | 62.380000 | 195.260000 max, ms | 11.000000 | 29.200000 | 76.300000 | 232.800000 --- .../saved_objects/migrations.test.ts | 26 + .../ui_settings/saved_objects/migrations.ts | 10 + .../security_solution/common/constants.ts | 6 +- .../detection_rules/custom_query_rule.spec.ts | 39 +- .../detection_rules/override.spec.ts | 4 +- .../detection_rules/prebuilt_rules.spec.ts | 175 +++---- .../detection_rules/sorting.spec.ts | 60 +-- .../exceptions/exceptions_table.spec.ts | 3 +- .../cypress/screens/alerts_detection_rules.ts | 4 - .../cypress/tasks/alerts_detection_rules.ts | 27 -- .../common/components/header_page/index.tsx | 1 - .../common/components/header_page/title.tsx | 7 +- .../common/components/header_page/types.ts | 4 +- .../common/lib/kibana/kibana_react.mock.ts | 2 - .../index.test.tsx | 20 - .../detection_engine_header_page/index.tsx | 18 - .../rules/rule_actions_overflow/index.tsx | 8 +- .../rules/rule_switch/index.test.tsx | 16 +- .../components/rules/rule_switch/index.tsx | 19 +- .../detection_engine/rules/__mocks__/api.ts | 18 +- .../detection_engine/rules/api.test.ts | 50 +- .../containers/detection_engine/rules/api.ts | 11 +- .../detection_engine/rules/index.ts | 1 - .../__mocks__/rules_table_context.tsx | 68 +++ .../rules/rules_table/index.ts | 11 - .../rules/rules_table/rules_table_context.tsx | 326 +++++++++++++ .../rules/rules_table/rules_table_facade.ts | 92 ---- .../rules_table/rules_table_reducer.test.ts | 313 ------------ .../rules/rules_table/rules_table_reducer.ts | 159 ------- .../rules/rules_table/use_find_rules.ts | 76 +++ .../rules/rules_table/use_rules.test.tsx | 230 --------- .../rules/rules_table/use_rules.tsx | 95 ---- .../rules/rules_table/use_rules_table.ts | 127 ----- .../rules/rules_table/utils.ts | 120 +++++ .../detection_engine/rules/types.ts | 27 +- .../detection_engine/rules/utils.test.ts | 2 - .../detection_engine/detection_engine.tsx | 14 +- .../detection_engine/rules/all/actions.tsx | 46 +- .../rules/all/batch_actions.tsx | 245 ---------- .../rules/all/columns.test.tsx | 75 --- .../detection_engine/rules/all/columns.tsx | 445 ------------------ .../exceptions/use_all_exception_lists.tsx | 1 - .../detection_engine/rules/all/index.test.tsx | 204 +------- .../detection_engine/rules/all/index.tsx | 18 +- .../rules/all/rules_table_actions.test.tsx | 76 +++ .../rules/all/rules_table_actions.tsx | 99 ++++ .../rules/all/rules_tables.tsx | 420 ++++------------- .../rules/all/use_bulk_actions.tsx | 266 +++++++++++ .../rules/all/use_columns.tsx | 408 ++++++++++++++++ .../rules/all/use_has_actions_privileges.ts | 20 + .../rules/all/use_has_ml_permissions.ts | 17 + .../rules/all/utility_bar.tsx | 2 +- .../detection_engine/rules/create/index.tsx | 4 +- .../detection_engine/rules/details/index.tsx | 6 +- .../detection_engine/rules/edit/index.tsx | 4 +- .../detection_engine/rules/index.test.tsx | 1 + .../pages/detection_engine/rules/index.tsx | 144 +++--- .../rules/rules_page_header.tsx | 39 ++ .../detection_engine/rules/translations.ts | 30 ++ .../security_solution/server/ui_settings.ts | 45 +- 60 files changed, 2063 insertions(+), 2741 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/detections/components/detection_engine_header_page/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/components/detection_engine_header_page/index.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/__mocks__/rules_table_context.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/index.ts create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_context.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_facade.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.test.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_find_rules.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_rules.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_rules.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_rules_table.ts create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/utils.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/batch_actions.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_bulk_actions.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_has_actions_privileges.ts create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_has_ml_permissions.ts create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/rules_page_header.tsx diff --git a/src/core/server/ui_settings/saved_objects/migrations.test.ts b/src/core/server/ui_settings/saved_objects/migrations.test.ts index 333315c0c9a2f..8f430679a1ead 100644 --- a/src/core/server/ui_settings/saved_objects/migrations.test.ts +++ b/src/core/server/ui_settings/saved_objects/migrations.test.ts @@ -283,4 +283,30 @@ describe('ui_settings 8.1.0 migrations', () => { geo_point: { id: 'geo_point', params: { transform: 'wkt' } }, }); }); + + test('removes idleTimeout option from rulesTableRefresh', () => { + const initialRulesTableRefresh = { + on: true, + value: 60000, + idleTimeout: 2700000, + }; + + const doc = { + type: 'config', + id: '8.0.0', + attributes: { + buildNum: 9007199254740991, + 'securitySolution:rulesTableRefresh': JSON.stringify(initialRulesTableRefresh), + }, + references: [], + updated_at: '2022-01-19T11:26:54.645Z', + migrationVersion: {}, + }; + const migrated = migration(doc); + expect(migrated.attributes.buildNum).toBe(9007199254740991); + expect(JSON.parse(migrated.attributes['securitySolution:rulesTableRefresh'])).toEqual({ + on: true, + value: 60000, + }); + }); }); diff --git a/src/core/server/ui_settings/saved_objects/migrations.ts b/src/core/server/ui_settings/saved_objects/migrations.ts index e2600927a00a5..cb61a10ea1a59 100644 --- a/src/core/server/ui_settings/saved_objects/migrations.ts +++ b/src/core/server/ui_settings/saved_objects/migrations.ts @@ -118,6 +118,16 @@ export const migrations = { ...acc, 'format:defaultTypeMap': JSON.stringify(updated, null, 2), }; + } else if (key === 'securitySolution:rulesTableRefresh') { + const initial = JSON.parse(doc.attributes[key]); + const updated = { + on: initial.on, + value: initial.value, + }; + return { + ...acc, + [key]: JSON.stringify(updated, null, 2), + }; } else { return { ...acc, diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 6e408bfb0822a..23bf41c3d91ee 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -64,7 +64,7 @@ export const NO_ALERT_INDEX = 'no-alert-index-049FC71A-4C2C-446F-9901-37XMC5024C export const ENDPOINT_METADATA_INDEX = 'metrics-endpoint.metadata-*' as const; export const DEFAULT_RULE_REFRESH_INTERVAL_ON = true as const; export const DEFAULT_RULE_REFRESH_INTERVAL_VALUE = 60000 as const; // ms -export const DEFAULT_RULE_REFRESH_IDLE_VALUE = 2700000 as const; // ms +export const DEFAULT_RULES_TABLE_IN_MEMORY_THRESHOLD = 3000; // rules export const DEFAULT_RULE_NOTIFICATION_QUERY_SIZE = 100 as const; export const SECURITY_FEATURE_ID = 'Security' as const; export const DEFAULT_SPACE_ID = 'default' as const; @@ -177,6 +177,10 @@ export const ENABLE_NEWS_FEED_SETTING = 'securitySolution:enableNewsFeed' as con /** This Kibana Advanced Setting sets the auto refresh interval for the detections all rules table */ export const DEFAULT_RULES_TABLE_REFRESH_SETTING = 'securitySolution:rulesTableRefresh' as const; +/** This Kibana Advanced Setting sets the threshold number of rules for which in-memory implementation is enabled */ +export const RULES_TABLE_ADVANCED_FILTERING_THRESHOLD = + 'securitySolution:advancedFilteringMaxRules' as const; + /** This Kibana Advanced Setting specifies the URL of the News feed widget */ export const NEWS_FEED_URL_SETTING = 'securitySolution:newsFeedUrl' as const; diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts index 643ce05ec47bd..060bc8ade9219 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts @@ -162,15 +162,11 @@ describe('Custom detection rules creation', () => { changeRowsPerPageTo100(); - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); - }); + cy.get(RULES_TABLE).find(RULES_ROW).should('have.length', expectedNumberOfRules); filterByCustomRules(); - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should('eql', 1); - }); + cy.get(RULES_TABLE).find(RULES_ROW).should('have.length', 1); cy.get(RULE_NAME).should('have.text', this.rule.name); cy.get(RISK_SCORE).should('have.text', this.rule.riskScore); cy.get(SEVERITY).should('have.text', this.rule.severity); @@ -214,7 +210,9 @@ describe('Custom detection rules creation', () => { waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); - cy.get(NUMBER_OF_ALERTS).should(($count) => expect(+$count.text().split(' ')[0]).to.be.gte(1)); + cy.get(NUMBER_OF_ALERTS) + .invoke('text') + .should('match', /^[1-9].+$/); // Any number of alerts cy.get(ALERT_GRID_CELL).contains(this.rule.name); }); }); @@ -245,12 +243,9 @@ describe('Custom detection rules deletion and edition', () => { deleteFirstRule(); waitForRulesTableToBeRefreshed(); - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should( - 'eql', - expectedNumberOfRulesAfterDeletion - ); - }); + cy.get(RULES_TABLE) + .find(RULES_ROW) + .should('have.length', expectedNumberOfRulesAfterDeletion); cy.get(SHOWING_RULES_TEXT).should( 'have.text', `Showing ${expectedNumberOfRulesAfterDeletion} rules` @@ -275,12 +270,9 @@ describe('Custom detection rules deletion and edition', () => { deleteSelectedRules(); waitForRulesTableToBeRefreshed(); - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should( - 'eql', - expectedNumberOfRulesAfterDeletion - ); - }); + cy.get(RULES_TABLE) + .find(RULES_ROW) + .should('have.length', expectedNumberOfRulesAfterDeletion); cy.get(SHOWING_RULES_TEXT).should( 'have.text', `Showing ${expectedNumberOfRulesAfterDeletion} rule` @@ -306,12 +298,9 @@ describe('Custom detection rules deletion and edition', () => { cy.waitFor('@deleteRule').then(() => { cy.get(RULES_TABLE).should('exist'); - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should( - 'eql', - expectedNumberOfRulesAfterDeletion - ); - }); + cy.get(RULES_TABLE) + .find(RULES_ROW) + .should('have.length', expectedNumberOfRulesAfterDeletion); cy.get(SHOWING_RULES_TEXT).should( 'have.text', `Showing ${expectedNumberOfRulesAfterDeletion} rules` diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts index 8b67fb198495a..dc22c5103e8d7 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts @@ -186,7 +186,9 @@ describe('Detection rules, override', () => { waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); - cy.get(NUMBER_OF_ALERTS).should(($count) => expect(+$count.text().split(' ')[0]).to.be.gte(1)); + cy.get(NUMBER_OF_ALERTS) + .invoke('text') + .should('match', /^[1-9].+$/); // Any number of alerts cy.get(ALERT_GRID_CELL).contains('auditbeat'); cy.get(ALERT_GRID_CELL).contains('critical'); cy.get(ALERT_GRID_CELL).contains('80'); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts index 38f5eec836bd6..1a6b1dff7d078 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts @@ -84,111 +84,116 @@ describe('Actions with prebuilt rules', () => { cy.get(ELASTIC_RULES_BTN).should('have.text', expectedElasticRulesBtnText); }); - it('Allows to activate/deactivate all rules at once', () => { - selectAllRules(); - activateSelectedRules(); - waitForRuleToChangeStatus(); - cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true'); - - selectAllRules(); - deactivateSelectedRules(); - waitForRuleToChangeStatus(); - cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'false'); - }); - - it('Allows to activate all rules on a page and deactivate single one at monitoring table', () => { - cy.get(RULES_MONIROTING_TABLE).click(); - cy.get(SELECT_ALL_RULES_ON_PAGE_CHECKBOX).click(); - activateSelectedRules(); - waitForRuleToChangeStatus(); - cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true'); - - selectNumberOfRules(1); - cy.get(RULE_SWITCH).first().click(); - waitForRuleToChangeStatus(); - cy.get(RULE_SWITCH).first().should('have.attr', 'aria-checked', 'false'); - }); + context('Rules table', () => { + it('Allows to activate/deactivate all rules at once', () => { + selectAllRules(); + activateSelectedRules(); + waitForRuleToChangeStatus(); + cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true'); + + selectAllRules(); + deactivateSelectedRules(); + waitForRuleToChangeStatus(); + cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'false'); + }); - it('Allows to delete all rules at once', () => { - selectAllRules(); - deleteSelectedRules(); - confirmRulesDelete(); - cy.get(RULES_EMPTY_PROMPT).should('be.visible'); - }); + it('Allows to delete all rules at once', () => { + selectAllRules(); + deleteSelectedRules(); + confirmRulesDelete(); + cy.get(RULES_EMPTY_PROMPT).should('be.visible'); + }); - it('Does not allow to delete one rule when more than one is selected', () => { - changeRowsPerPageTo100(); + it('Does not allow to delete one rule when more than one is selected', () => { + changeRowsPerPageTo100(); - const numberOfRulesToBeSelected = 2; - selectNumberOfRules(numberOfRulesToBeSelected); + const numberOfRulesToBeSelected = 2; + selectNumberOfRules(numberOfRulesToBeSelected); - cy.get(COLLAPSED_ACTION_BTN).each((collapsedItemActionBtn) => { - cy.wrap(collapsedItemActionBtn).should('have.attr', 'disabled'); + cy.get(COLLAPSED_ACTION_BTN).each((collapsedItemActionBtn) => { + cy.wrap(collapsedItemActionBtn).should('have.attr', 'disabled'); + }); }); - }); - it('Deletes and recovers one rule', () => { - changeRowsPerPageTo100(); + it('Deletes and recovers one rule', () => { + changeRowsPerPageTo100(); - const expectedNumberOfRulesAfterDeletion = totalNumberOfPrebuiltRules - 1; - const expectedNumberOfRulesAfterRecovering = totalNumberOfPrebuiltRules; + const expectedNumberOfRulesAfterDeletion = totalNumberOfPrebuiltRules - 1; + const expectedNumberOfRulesAfterRecovering = totalNumberOfPrebuiltRules; - deleteFirstRule(); - cy.reload(); - changeRowsPerPageTo100(); + deleteFirstRule(); + cy.reload(); + changeRowsPerPageTo100(); - cy.get(ELASTIC_RULES_BTN).should( - 'have.text', - `Elastic rules (${expectedNumberOfRulesAfterDeletion})` - ); - cy.get(RELOAD_PREBUILT_RULES_BTN).should('exist'); - cy.get(RELOAD_PREBUILT_RULES_BTN).should('have.text', 'Install 1 Elastic prebuilt rule '); + cy.get(ELASTIC_RULES_BTN).should( + 'have.text', + `Elastic rules (${expectedNumberOfRulesAfterDeletion})` + ); + cy.get(RELOAD_PREBUILT_RULES_BTN).should('exist'); + cy.get(RELOAD_PREBUILT_RULES_BTN).should('have.text', 'Install 1 Elastic prebuilt rule '); - reloadDeletedRules(); + reloadDeletedRules(); - cy.get(RELOAD_PREBUILT_RULES_BTN).should('not.exist'); + cy.get(RELOAD_PREBUILT_RULES_BTN).should('not.exist'); - cy.reload(); - changeRowsPerPageTo100(); + cy.reload(); + changeRowsPerPageTo100(); - cy.get(ELASTIC_RULES_BTN).should( - 'have.text', - `Elastic rules (${expectedNumberOfRulesAfterRecovering})` - ); - }); + cy.get(ELASTIC_RULES_BTN).should( + 'have.text', + `Elastic rules (${expectedNumberOfRulesAfterRecovering})` + ); + }); + + it('Deletes and recovers more than one rule', () => { + changeRowsPerPageTo100(); - it('Deletes and recovers more than one rule', () => { - changeRowsPerPageTo100(); + const numberOfRulesToBeSelected = 2; + const expectedNumberOfRulesAfterDeletion = totalNumberOfPrebuiltRules - 2; + const expectedNumberOfRulesAfterRecovering = totalNumberOfPrebuiltRules; - const numberOfRulesToBeSelected = 2; - const expectedNumberOfRulesAfterDeletion = totalNumberOfPrebuiltRules - 2; - const expectedNumberOfRulesAfterRecovering = totalNumberOfPrebuiltRules; + selectNumberOfRules(numberOfRulesToBeSelected); + deleteSelectedRules(); + cy.reload(); + changeRowsPerPageTo100(); - selectNumberOfRules(numberOfRulesToBeSelected); - deleteSelectedRules(); - cy.reload(); - changeRowsPerPageTo100(); + cy.get(RELOAD_PREBUILT_RULES_BTN).should('exist'); + cy.get(RELOAD_PREBUILT_RULES_BTN).should( + 'have.text', + `Install ${numberOfRulesToBeSelected} Elastic prebuilt rules ` + ); + cy.get(ELASTIC_RULES_BTN).should( + 'have.text', + `Elastic rules (${expectedNumberOfRulesAfterDeletion})` + ); - cy.get(RELOAD_PREBUILT_RULES_BTN).should('exist'); - cy.get(RELOAD_PREBUILT_RULES_BTN).should( - 'have.text', - `Install ${numberOfRulesToBeSelected} Elastic prebuilt rules ` - ); - cy.get(ELASTIC_RULES_BTN).should( - 'have.text', - `Elastic rules (${expectedNumberOfRulesAfterDeletion})` - ); + reloadDeletedRules(); - reloadDeletedRules(); + cy.get(RELOAD_PREBUILT_RULES_BTN).should('not.exist'); - cy.get(RELOAD_PREBUILT_RULES_BTN).should('not.exist'); + cy.reload(); + changeRowsPerPageTo100(); - cy.reload(); - changeRowsPerPageTo100(); + cy.get(ELASTIC_RULES_BTN).should( + 'have.text', + `Elastic rules (${expectedNumberOfRulesAfterRecovering})` + ); + }); + }); - cy.get(ELASTIC_RULES_BTN).should( - 'have.text', - `Elastic rules (${expectedNumberOfRulesAfterRecovering})` - ); + context('Rule monitoring table', () => { + it('Allows to activate/deactivate all rules at once', () => { + cy.get(RULES_MONIROTING_TABLE).click(); + + cy.get(SELECT_ALL_RULES_ON_PAGE_CHECKBOX).click(); + activateSelectedRules(); + waitForRuleToChangeStatus(); + cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true'); + + selectAllRules(); + deactivateSelectedRules(); + waitForRuleToChangeStatus(); + cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'false'); + }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts index 92f9e8180d50c..7e2408657abeb 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts @@ -10,7 +10,6 @@ import { RULE_NAME, RULE_SWITCH, SECOND_RULE, - RULE_AUTO_REFRESH_IDLE_MODAL, FOURTH_RULE, RULES_TABLE, pageSelector, @@ -24,11 +23,8 @@ import { import { activateRule, changeRowsPerPageTo, - checkAllRulesIdleModal, checkAutoRefresh, - dismissAllRulesIdleModal, goToPage, - resetAllRulesIdleModalTimeout, sortByActivatedRules, waitForRulesTableToBeLoaded, waitForRuleToChangeStatus, @@ -63,36 +59,18 @@ describe('Alerts detection rules', () => { goToManageAlertsDetectionRules(); waitForRulesTableToBeLoaded(); - cy.get(RULE_NAME) - .eq(SECOND_RULE) - .invoke('text') - .then((secondInitialRuleName) => { - activateRule(SECOND_RULE); - waitForRuleToChangeStatus(); - cy.get(RULE_NAME) - .eq(FOURTH_RULE) - .invoke('text') - .then((fourthInitialRuleName) => { - activateRule(FOURTH_RULE); - waitForRuleToChangeStatus(); - sortByActivatedRules(); - cy.get(RULE_NAME) - .eq(FIRST_RULE) - .invoke('text') - .then((firstRuleName) => { - cy.get(RULE_NAME) - .eq(SECOND_RULE) - .invoke('text') - .then((secondRuleName) => { - const expectedRulesNames = `${firstRuleName} ${secondRuleName}`; - cy.wrap(expectedRulesNames).should('include', secondInitialRuleName); - cy.wrap(expectedRulesNames).should('include', fourthInitialRuleName); - }); - }); - cy.get(RULE_SWITCH).eq(FIRST_RULE).should('have.attr', 'role', 'switch'); - cy.get(RULE_SWITCH).eq(SECOND_RULE).should('have.attr', 'role', 'switch'); - }); - }); + activateRule(SECOND_RULE); + waitForRuleToChangeStatus(); + activateRule(FOURTH_RULE); + waitForRuleToChangeStatus(); + + cy.get(RULE_SWITCH).eq(SECOND_RULE).should('have.attr', 'role', 'switch'); + cy.get(RULE_SWITCH).eq(FOURTH_RULE).should('have.attr', 'role', 'switch'); + + sortByActivatedRules(); + + cy.get(RULE_SWITCH).eq(FIRST_RULE).should('have.attr', 'role', 'switch'); + cy.get(RULE_SWITCH).eq(SECOND_RULE).should('have.attr', 'role', 'switch'); }); it('Pagination updates page number and results', () => { @@ -149,19 +127,5 @@ describe('Alerts detection rules', () => { // mock 1 minute passing to make sure refresh // is conducted checkAutoRefresh(DEFAULT_RULE_REFRESH_INTERVAL_VALUE, 'be.visible'); - - // mock 45 minutes passing to check that idle modal shows - // and refreshing is paused - checkAllRulesIdleModal('be.visible'); - checkAutoRefresh(DEFAULT_RULE_REFRESH_INTERVAL_VALUE, 'not.exist'); - - // clicking on modal to continue, should resume refreshing - dismissAllRulesIdleModal(); - checkAutoRefresh(DEFAULT_RULE_REFRESH_INTERVAL_VALUE, 'be.visible'); - - // if mouse movement detected, idle modal should not - // show after 45 min - resetAllRulesIdleModalTimeout(); - cy.get(RULE_AUTO_REFRESH_IDLE_MODAL).should('not.exist'); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts index c8b6f73912acf..6fc9ba0f0c933 100644 --- a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts @@ -16,7 +16,7 @@ import { RULE_STATUS } from '../../screens/create_new_rule'; import { goToManageAlertsDetectionRules, waitForAlertsIndexToBeCreated } from '../../tasks/alerts'; import { createCustomRule } from '../../tasks/api_calls/rules'; -import { goToRuleDetails, waitForRulesTableToBeLoaded } from '../../tasks/alerts_detection_rules'; +import { goToRuleDetails } from '../../tasks/alerts_detection_rules'; import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange, @@ -65,7 +65,6 @@ describe('Exceptions Table', () => { createExceptionList(getExceptionList(), getExceptionList().list_id).as('exceptionListResponse'); goBackToAllRulesTable(); - waitForRulesTableToBeLoaded(); }); after(() => { diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts index a194fe6406f22..eaede8ff5642f 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts @@ -72,10 +72,6 @@ export const SHOWING_RULES_TEXT = '[data-test-subj="showingRules"]'; export const SORT_RULES_BTN = '[data-test-subj="tableHeaderSortButton"]'; -export const RULE_AUTO_REFRESH_IDLE_MODAL = '[data-test-subj="allRulesIdleModal"]'; - -export const RULE_AUTO_REFRESH_IDLE_MODAL_CONTINUE = '[data-test-subj="allRulesIdleModal"] button'; - export const PAGINATION_POPOVER_BTN = '[data-test-subj="tablePaginationPopoverButton"]'; export const rowsPerPageSelector = (count: number) => diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index a000607d0a803..377abd53b1af8 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -27,8 +27,6 @@ import { SORT_RULES_BTN, EXPORT_ACTION_BTN, EDIT_RULE_ACTION_BTN, - RULE_AUTO_REFRESH_IDLE_MODAL, - RULE_AUTO_REFRESH_IDLE_MODAL_CONTINUE, rowsPerPageSelector, pageSelector, DUPLICATE_RULE_ACTION_BTN, @@ -144,7 +142,6 @@ export const exportFirstRule = () => { export const filterByCustomRules = () => { cy.get(CUSTOM_RULES_BTN).click({ force: true }); - waitForRulesTableToBeRefreshed(); }; export const goToCreateNewRule = () => { @@ -196,9 +193,7 @@ export const confirmRulesDelete = () => { export const sortByActivatedRules = () => { cy.get(SORT_RULES_BTN).contains('Activated').click({ force: true }); - waitForRulesTableToBeRefreshed(); cy.get(SORT_RULES_BTN).contains('Activated').click({ force: true }); - waitForRulesTableToBeRefreshed(); }; export const waitForRulesTableToBeLoaded = () => { @@ -229,32 +224,11 @@ export const checkAutoRefresh = (ms: number, condition: string) => { cy.get(RULES_TABLE_AUTOREFRESH_INDICATOR).should(condition); }; -export const dismissAllRulesIdleModal = () => { - cy.get(RULE_AUTO_REFRESH_IDLE_MODAL_CONTINUE) - .eq(1) - .should('exist') - .click({ force: true, multiple: true }); - cy.get(RULE_AUTO_REFRESH_IDLE_MODAL).should('not.exist'); -}; - -export const checkAllRulesIdleModal = (condition: string) => { - cy.tick(2700000); - cy.get(RULE_AUTO_REFRESH_IDLE_MODAL).should(condition); -}; - -export const resetAllRulesIdleModalTimeout = () => { - cy.tick(2000000); - cy.window().trigger('mousemove', { force: true }); - cy.tick(700000); -}; - export const changeRowsPerPageTo = (rowsCount: number) => { cy.get(PAGINATION_POPOVER_BTN).click({ force: true }); cy.get(rowsPerPageSelector(rowsCount)) .pipe(($el) => $el.trigger('click')) .should('not.be.visible'); - - waitForRulesTableToBeRefreshed(); }; export const changeRowsPerPageTo100 = () => { @@ -264,7 +238,6 @@ export const changeRowsPerPageTo100 = () => { export const goToPage = (pageNumber: number) => { cy.get(RULES_TABLE_REFRESH_INDICATOR).should('not.exist'); cy.get(pageSelector(pageNumber)).last().click({ force: true }); - waitForRulesTableToBeRefreshed(); }; export const importRules = (rulesFile: string) => { diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx index 2647827c0d1b0..4a1cab1ca1752 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx @@ -106,7 +106,6 @@ const HeaderPageComponent: React.FC = ({ subtitle2, title, titleNode, - ...rest }) => ( <> diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx index c52fd7bc34e82..cff64b870824f 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx @@ -22,12 +22,13 @@ StyledEuiBetaBadge.displayName = 'StyledEuiBetaBadge'; const Badge = styled(EuiBadge)` letter-spacing: 0; margin-left: 10px; -` as unknown as typeof EuiBadge; +`; Badge.displayName = 'Badge'; const Header = styled.h1` - display: flex; - align-items: center; + display: grid; + grid-gap: 12px; + grid-auto-flow: column; `; Header.displayName = 'Header'; diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/types.ts b/x-pack/plugins/security_solution/public/common/components/header_page/types.ts index f099144eeb4be..230f9bb9d86a0 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/header_page/types.ts @@ -16,7 +16,7 @@ export interface DraggableArguments { export interface BadgeOptions { beta?: boolean; - text: string; - tooltip?: string; + text: React.ReactNode; + tooltip?: React.ReactNode; color?: EuiBadgeProps['color']; } diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index b98618ac76412..622ec5bac487d 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -32,7 +32,6 @@ import { DEFAULT_RULES_TABLE_REFRESH_SETTING, DEFAULT_RULE_REFRESH_INTERVAL_ON, DEFAULT_RULE_REFRESH_INTERVAL_VALUE, - DEFAULT_RULE_REFRESH_IDLE_VALUE, DEFAULT_TRANSFORMS, } from '../../../../common/constants'; import { StartServices } from '../../../types'; @@ -61,7 +60,6 @@ const mockUiSettings: Record = { [DEFAULT_RULES_TABLE_REFRESH_SETTING]: { on: DEFAULT_RULE_REFRESH_INTERVAL_ON, value: DEFAULT_RULE_REFRESH_INTERVAL_VALUE, - idleTimeout: DEFAULT_RULE_REFRESH_IDLE_VALUE, }, [DEFAULT_TRANSFORMS]: { enabled: false, diff --git a/x-pack/plugins/security_solution/public/detections/components/detection_engine_header_page/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/detection_engine_header_page/index.test.tsx deleted file mode 100644 index acaffa5d796f2..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/detection_engine_header_page/index.test.tsx +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { shallow } from 'enzyme'; - -import '../../../common/mock/match_media'; -import { DetectionEngineHeaderPage } from './index'; - -describe('detection_engine_header_page', () => { - it('renders correctly', () => { - const wrapper = shallow(); - - expect(wrapper.find('[title="Title"]')).toBeTruthy(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/components/detection_engine_header_page/index.tsx b/x-pack/plugins/security_solution/public/detections/components/detection_engine_header_page/index.tsx deleted file mode 100644 index 44f27b690fbc7..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/detection_engine_header_page/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { HeaderPage, HeaderPageProps } from '../../../common/components/header_page'; - -const DetectionEngineHeaderPageComponent: React.FC = (props) => ( - -); - -export const DetectionEngineHeaderPage = React.memo(DetectionEngineHeaderPageComponent); - -DetectionEngineHeaderPage.displayName = 'DetectionEngineHeaderPage'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx index eac1c2800955f..c97ae9d7d7756 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx @@ -82,8 +82,8 @@ const RuleActionsOverflowComponent = ({ const createdRules = await duplicateRulesAction( [rule], [rule.id], - noop, - dispatchToaster + dispatchToaster, + noop ); if (createdRules?.length) { editRuleAction(createdRules[0].id, navigateToApp); @@ -104,7 +104,7 @@ const RuleActionsOverflowComponent = ({ data-test-subj="rules-details-export-rule" onClick={async () => { closePopover(); - await exportRulesAction([rule.rule_id], noop, dispatchToaster); + await exportRulesAction([rule.rule_id], dispatchToaster, noop); }} > {i18nActions.EXPORT_RULE} @@ -116,7 +116,7 @@ const RuleActionsOverflowComponent = ({ data-test-subj="rules-details-delete-rule" onClick={async () => { closePopover(); - await deleteRulesAction([rule.id], noop, dispatchToaster, onRuleDeletedCallback); + await deleteRulesAction([rule.id], dispatchToaster, noop, onRuleDeletedCallback); }} > {i18nActions.DELETE_RULE} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx index fc91c26148c17..15f30e2c92c3a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx @@ -15,15 +15,19 @@ import { RuleSwitchComponent } from './index'; import { getRulesSchemaMock } from '../../../../../common/detection_engine/schemas/response/rules_schema.mocks'; import { RulesSchema } from '../../../../../common/detection_engine/schemas/response/rules_schema'; import { useStateToaster, displayErrorToast } from '../../../../common/components/toasters'; +import { useRulesTableContextOptional } from '../../../containers/detection_engine/rules/rules_table/rules_table_context'; +import { useRulesTableContextMock } from '../../../containers/detection_engine/rules/rules_table/__mocks__/rules_table_context'; jest.mock('../../../../common/components/toasters'); jest.mock('../../../containers/detection_engine/rules'); +jest.mock('../../../containers/detection_engine/rules/rules_table/rules_table_context'); jest.mock('../../../pages/detection_engine/rules/all/actions'); describe('RuleSwitch', () => { beforeEach(() => { (useStateToaster as jest.Mock).mockImplementation(() => [[], jest.fn()]); (enableRules as jest.Mock).mockResolvedValue([getRulesSchemaMock()]); + (useRulesTableContextOptional as jest.Mock).mockReturnValue(null); }); afterEach(() => { @@ -120,15 +124,11 @@ describe('RuleSwitch', () => { }); }); - test('it invokes "enableRulesAction" if dispatch is passed through', async () => { + test('it invokes "enableRulesAction" if in rules table context', async () => { + (useRulesTableContextOptional as jest.Mock).mockReturnValue(useRulesTableContextMock.create()); + const wrapper = mount( - + ); wrapper.find('[data-test-subj="ruleSwitch"]').at(2).simulate('click'); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.tsx index dd836a04f8263..409a4badc4d22 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.tsx @@ -17,10 +17,11 @@ import styled from 'styled-components'; import React, { useMemo, useCallback, useState, useEffect } from 'react'; import * as i18n from '../../../pages/detection_engine/rules/translations'; -import { enableRules, RulesTableAction } from '../../../containers/detection_engine/rules'; +import { enableRules } from '../../../containers/detection_engine/rules'; import { enableRulesAction } from '../../../pages/detection_engine/rules/all/actions'; import { useStateToaster, displayErrorToast } from '../../../../common/components/toasters'; import { bucketRulesResponse } from '../../../pages/detection_engine/rules/all/helpers'; +import { useRulesTableContextOptional } from '../../../containers/detection_engine/rules/rules_table/rules_table_context'; const StaticSwitch = styled(EuiSwitch)` .euiSwitch__thumb, @@ -32,7 +33,6 @@ const StaticSwitch = styled(EuiSwitch)` StaticSwitch.displayName = 'StaticSwitch'; export interface RuleSwitchProps { - dispatch?: React.Dispatch; id: string; enabled: boolean; isDisabled?: boolean; @@ -45,7 +45,6 @@ export interface RuleSwitchProps { * Basic switch component for displaying loader when enabled/disabled */ export const RuleSwitchComponent = ({ - dispatch, id, isDisabled, isLoading, @@ -56,12 +55,19 @@ export const RuleSwitchComponent = ({ const [myIsLoading, setMyIsLoading] = useState(false); const [myEnabled, setMyEnabled] = useState(enabled ?? false); const [, dispatchToaster] = useStateToaster(); + const rulesTableContext = useRulesTableContextOptional(); const onRuleStateChange = useCallback( async (event: EuiSwitchEvent) => { setMyIsLoading(true); - if (dispatch != null) { - await enableRulesAction([id], event.target.checked, dispatch, dispatchToaster); + if (rulesTableContext != null) { + await enableRulesAction( + [id], + event.target.checked, + dispatchToaster, + rulesTableContext.actions.setLoadingRules, + rulesTableContext.actions.updateRules + ); } else { const enabling = event.target.checked; const title = enabling @@ -96,8 +102,7 @@ export const RuleSwitchComponent = ({ } setMyIsLoading(false); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [dispatch, id] + [dispatchToaster, id, onChange, rulesTableContext] ); useEffect(() => { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts index 6f01001b3f957..0922c4b3b78b7 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts @@ -57,22 +57,8 @@ export const fetchRuleById = jest.fn( async ({ id, signal }: FetchRuleProps): Promise => savedRuleMock ); -export const fetchRules = async ({ - filterOptions = { - filter: '', - sortField: 'enabled', - sortOrder: 'desc', - showCustomRules: false, - showElasticRules: false, - tags: [], - }, - pagination = { - page: 1, - perPage: 20, - total: 0, - }, - signal, -}: FetchRulesProps): Promise => Promise.resolve(rulesMock); +export const fetchRules = async (_: FetchRulesProps): Promise => + Promise.resolve(rulesMock); export const fetchRuleExecutionEvents = async ({ ruleId, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts index 3591d49b66b40..167911c92f22b 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts @@ -131,12 +131,14 @@ describe('Detections Rules API', () => { await fetchRules({ filterOptions: { filter: 'hello world', - sortField: 'enabled', - sortOrder: 'desc', showCustomRules: false, showElasticRules: false, tags: [], }, + sortingOptions: { + field: 'enabled', + order: 'desc', + }, signal: abortCtrl.signal, }); @@ -157,12 +159,14 @@ describe('Detections Rules API', () => { await fetchRules({ filterOptions: { filter: '', - sortField: 'enabled', - sortOrder: 'desc', showCustomRules: true, showElasticRules: false, tags: [], }, + sortingOptions: { + field: 'enabled', + order: 'desc', + }, signal: abortCtrl.signal, }); @@ -183,12 +187,14 @@ describe('Detections Rules API', () => { await fetchRules({ filterOptions: { filter: '', - sortField: 'enabled', - sortOrder: 'desc', showCustomRules: false, showElasticRules: true, tags: [], }, + sortingOptions: { + field: 'enabled', + order: 'desc', + }, signal: abortCtrl.signal, }); @@ -209,12 +215,14 @@ describe('Detections Rules API', () => { await fetchRules({ filterOptions: { filter: '', - sortField: 'enabled', - sortOrder: 'desc', showCustomRules: false, showElasticRules: false, tags: ['hello', 'world'], }, + sortingOptions: { + field: 'enabled', + order: 'desc', + }, signal: abortCtrl.signal, }); @@ -235,12 +243,14 @@ describe('Detections Rules API', () => { await fetchRules({ filterOptions: { filter: '', - sortField: 'updated_at', - sortOrder: 'desc', showCustomRules: false, showElasticRules: false, tags: ['hello', 'world'], }, + sortingOptions: { + field: 'updated_at', + order: 'desc', + }, signal: abortCtrl.signal, }); @@ -261,8 +271,6 @@ describe('Detections Rules API', () => { await fetchRules({ filterOptions: { filter: 'ruleName', - sortField: 'enabled', - sortOrder: 'desc', showCustomRules: true, showElasticRules: true, tags: ['('], @@ -284,12 +292,14 @@ describe('Detections Rules API', () => { await fetchRules({ filterOptions: { filter: '"test"', - sortField: 'enabled', - sortOrder: 'desc', showCustomRules: true, showElasticRules: true, tags: [], }, + sortingOptions: { + field: 'enabled', + order: 'desc', + }, signal: abortCtrl.signal, }); const [ @@ -307,12 +317,14 @@ describe('Detections Rules API', () => { await fetchRules({ filterOptions: { filter: '"test"', - sortField: 'enabled', - sortOrder: 'desc', showCustomRules: true, showElasticRules: true, tags: ['"test"'], }, + sortingOptions: { + field: 'enabled', + order: 'desc', + }, signal: abortCtrl.signal, }); const [ @@ -330,12 +342,14 @@ describe('Detections Rules API', () => { await fetchRules({ filterOptions: { filter: 'ruleName', - sortField: 'enabled', - sortOrder: 'desc', showCustomRules: true, showElasticRules: true, tags: ['hello', 'world'], }, + sortingOptions: { + field: 'enabled', + order: 'desc', + }, signal: abortCtrl.signal, }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_find', { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts index 6ff9b2a288452..ad51ec009acbf 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts @@ -127,16 +127,17 @@ export const previewRule = async ({ rule, signal }: PreviewRulesProps): Promise< export const fetchRules = async ({ filterOptions = { filter: '', - sortField: 'enabled', - sortOrder: 'desc', showCustomRules: false, showElasticRules: false, tags: [], }, + sortingOptions = { + field: 'enabled', + order: 'desc', + }, pagination = { page: 1, perPage: 20, - total: 0, }, signal, }: FetchRulesProps): Promise => { @@ -150,8 +151,8 @@ export const fetchRules = async ({ const query = { page: pagination.page, per_page: pagination.perPage, - sort_field: getFieldNameForSortField(filterOptions.sortField), - sort_order: filterOptions.sortOrder, + sort_field: getFieldNameForSortField(sortingOptions.field), + sort_order: sortingOptions.order, ...(filterString !== '' ? { filter: filterString } : {}), }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/index.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/index.ts index 435a1e6ef073b..8f6dbccd1ee57 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/index.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/index.ts @@ -10,6 +10,5 @@ export * from './use_update_rule'; export * from './use_create_rule'; export * from './types'; export * from './use_rule'; -export * from './rules_table'; export * from './use_pre_packaged_rules'; export * from './use_rule_execution_events'; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/__mocks__/rules_table_context.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/__mocks__/rules_table_context.tsx new file mode 100644 index 0000000000000..a207d4a0ee9bd --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/__mocks__/rules_table_context.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { RulesTableContextType } from '../rules_table_context'; + +export const useRulesTableContextMock = { + create: (): jest.Mocked => ({ + state: { + rules: [], + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + filterOptions: { + filter: '', + tags: [], + showCustomRules: false, + showElasticRules: false, + }, + sortingOptions: { + field: 'enabled', + order: 'desc', + }, + isActionInProgress: false, + isAllSelected: false, + isFetched: true, + isFetching: false, + isInMemorySorting: false, + isLoading: false, + isRefetching: false, + isRefreshOn: true, + lastUpdated: Date.now(), + loadingRuleIds: [], + loadingRulesAction: null, + selectedRuleIds: [], + }, + actions: { + reFetchRules: jest.fn(), + setFilterOptions: jest.fn(), + setIsAllSelected: jest.fn(), + setIsRefreshOn: jest.fn(), + setLoadingRules: jest.fn(), + setPage: jest.fn(), + setPerPage: jest.fn(), + setSelectedRuleIds: jest.fn(), + setSortingOptions: jest.fn(), + updateRules: jest.fn(), + }, + }), +}; + +export const useRulesTableContext = jest + .fn, []>() + .mockImplementation(useRulesTableContextMock.create); + +export const useRulesTableContextOptional = jest + .fn, []>() + .mockImplementation(useRulesTableContextMock.create); + +export const RulesTableContextProvider = jest + .fn() + .mockImplementation(({ children }: { children: React.ReactNode }) => <>{children}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/index.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/index.ts deleted file mode 100644 index a05349fa4fa3a..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export * from './rules_table_facade'; -export * from './rules_table_reducer'; -export * from './use_rules'; -export * from './use_rules_table'; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_context.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_context.tsx new file mode 100644 index 0000000000000..41aee63ea5c65 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_context.tsx @@ -0,0 +1,326 @@ +/* + * 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, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useQueryClient } from 'react-query'; +import { + DEFAULT_RULES_TABLE_REFRESH_SETTING, + RULES_TABLE_ADVANCED_FILTERING_THRESHOLD, +} from '../../../../../../common/constants'; +import { invariant } from '../../../../../../common/utils/invariant'; +import { useUiSetting$ } from '../../../../../common/lib/kibana'; +import { FilterOptions, PaginationOptions, Rule, SortingOptions } from '../types'; +import { getFindRulesQueryKey, useFindRules } from './use_find_rules'; +import { getRulesComparator, getRulesPredicate, mergeRules } from './utils'; + +export interface RulesTableState { + /** + * Rules to display (sorted and paginated in case of in-memory) + */ + rules: Rule[]; + /** + * Currently selected table filter + */ + filterOptions: FilterOptions; + /** + * Is true whenever a rule action is in progress, such as delete, duplicate, export, or load. + */ + isActionInProgress: boolean; + /** + * Is true whenever all table rules are selected (with respect to the currently selected filters) + */ + isAllSelected: boolean; + /** + * Will be true if the query has been fetched. + */ + isFetched: boolean; + /** + * Is true whenever a request is in-flight, which includes initial loading as well as background refetches. + */ + isFetching: boolean; + /** + * Is true when we store and sort all rules in-memory. Is null when the total number of rules is not known yet. + */ + isInMemorySorting: null | boolean; + /** + * Is true then there is no cached data and the query is currently fetching. + */ + isLoading: boolean; + /** + * Is true whenever a background refetch is in-flight, which does not include initial loading + */ + isRefetching: boolean; + /** + * Indicates whether we should refetch table data in the background + */ + isRefreshOn: boolean; + /** + * The timestamp for when the rules were successfully fetched + */ + lastUpdated: number; + /** + * IDs of rules the current table action (enable, disable, delete, etc.) is affecting + */ + loadingRuleIds: string[]; + /** + * Indicates which rule action (enable, disable, delete, etc.) is currently in progress + */ + loadingRulesAction: LoadingRuleAction; + /** + * Currently selected page and number of rows per page + */ + pagination: PaginationOptions; + /** + * IDs of rules selected by a user + */ + selectedRuleIds: string[]; + /** + * Currently selected table sorting + */ + sortingOptions: SortingOptions; +} + +const initialFilterOptions: FilterOptions = { + filter: '', + tags: [], + showCustomRules: false, + showElasticRules: false, +}; + +const initialSortingOptions: SortingOptions = { + field: 'enabled', + order: 'desc', +}; + +export type LoadingRuleAction = + | 'delete' + | 'disable' + | 'duplicate' + | 'enable' + | 'export' + | 'load' + | 'edit' + | null; + +interface LoadingRules { + ids: string[]; + action: LoadingRuleAction; +} + +export interface RulesTableActions { + reFetchRules: ReturnType['refetch']; + setFilterOptions: React.Dispatch>; + setIsAllSelected: React.Dispatch>; + setIsRefreshOn: React.Dispatch>; + setLoadingRules: React.Dispatch>; + setPage: React.Dispatch>; + setPerPage: React.Dispatch>; + setSelectedRuleIds: React.Dispatch>; + setSortingOptions: React.Dispatch>; + updateRules: (newRules: Rule[]) => void; +} + +export interface RulesTableContextType { + state: RulesTableState; + actions: RulesTableActions; +} + +const RulesTableContext = createContext(null); + +interface RulesTableContextProviderProps { + children: React.ReactNode; + totalRules: number | null; + refetchPrePackagedRulesStatus: () => Promise; +} + +const DEFAULT_RULES_PER_PAGE = 20; + +export const RulesTableContextProvider = ({ + children, + totalRules, + refetchPrePackagedRulesStatus, +}: RulesTableContextProviderProps) => { + const [autoRefreshSettings] = useUiSetting$<{ + on: boolean; + value: number; + idleTimeout: number; + }>(DEFAULT_RULES_TABLE_REFRESH_SETTING); + + const [advancedFilteringThreshold] = useUiSetting$( + RULES_TABLE_ADVANCED_FILTERING_THRESHOLD + ); + + const hasTotalRules = totalRules != null; + const isInMemorySorting = hasTotalRules ? totalRules < advancedFilteringThreshold : null; + + const [filterOptions, setFilterOptions] = useState(initialFilterOptions); + const [sortingOptions, setSortingOptions] = useState(initialSortingOptions); + const [isAllSelected, setIsAllSelected] = useState(false); + const [isRefreshOn, setIsRefreshOn] = useState(autoRefreshSettings.on); + const [loadingRules, setLoadingRules] = useState({ ids: [], action: null }); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(DEFAULT_RULES_PER_PAGE); + const [selectedRuleIds, setSelectedRuleIds] = useState([]); + + const isActionInProgress = useMemo(() => { + if (loadingRules.ids.length > 0) { + return !(loadingRules.action === 'disable' || loadingRules.action === 'enable'); + } + return false; + }, [loadingRules.action, loadingRules.ids.length]); + + const pagination = useMemo(() => ({ page, perPage }), [page, perPage]); + + // Fetch rules + const { + data: { rules, total } = { rules: [], total: 0 }, + refetch, + dataUpdatedAt, + isFetched, + isFetching, + isLoading, + isRefetching, + } = useFindRules({ + enabled: hasTotalRules, + isInMemorySorting, + filterOptions, + sortingOptions, + pagination, + refetchInterval: isRefreshOn && !isActionInProgress && autoRefreshSettings.value, + }); + + useEffect(() => { + // Synchronize re-fetching of rules and pre-packaged rule statuses + if (isFetched && isRefetching) { + refetchPrePackagedRulesStatus(); + } + }, [isFetched, isRefetching, refetchPrePackagedRulesStatus]); + + // Filter rules + const filteredRules = isInMemorySorting ? rules.filter(getRulesPredicate(filterOptions)) : rules; + + // Paginate and sort rules + const rulesToDisplay = isInMemorySorting + ? filteredRules + .sort(getRulesComparator(sortingOptions)) + .slice((page - 1) * perPage, page * perPage) + : filteredRules; + + const queryClient = useQueryClient(); + /** + * Use this method to update rules data cached by react-query. + * It is useful when we receive new rules back from a mutation query (bulk edit, etc.); + * we can merge those rules with the existing cache to avoid an extra roundtrip to re-fetch updated rules. + */ + const updateRules = useCallback( + (newRules: Rule[]) => { + queryClient.setQueryData['data']>( + getFindRulesQueryKey({ isInMemorySorting, filterOptions, sortingOptions, pagination }), + (currentData) => ({ + rules: mergeRules(currentData?.rules || [], newRules), + total: currentData?.total || 0, + }) + ); + + /** + * Unset loading state for all new rules + */ + const newRuleIds = newRules.map((r) => r.id); + const newLoadingRuleIds = loadingRules.ids.filter((id) => !newRuleIds.includes(id)); + setLoadingRules({ + ids: newLoadingRuleIds, + action: newLoadingRuleIds.length === 0 ? null : loadingRules.action, + }); + }, + [ + filterOptions, + isInMemorySorting, + loadingRules.action, + loadingRules.ids, + pagination, + queryClient, + sortingOptions, + ] + ); + + const providerValue = useMemo( + () => ({ + state: { + rules: rulesToDisplay, + pagination: { + page, + perPage, + total: isInMemorySorting ? filteredRules.length : total, + }, + filterOptions, + isActionInProgress, + isAllSelected, + isFetched, + isFetching, + isInMemorySorting, + isLoading, + isRefetching, + isRefreshOn, + lastUpdated: dataUpdatedAt, + loadingRuleIds: loadingRules.ids, + loadingRulesAction: loadingRules.action, + selectedRuleIds, + sortingOptions, + }, + actions: { + reFetchRules: refetch, + setFilterOptions, + setIsAllSelected, + setIsRefreshOn, + setLoadingRules, + setPage, + setPerPage, + setSelectedRuleIds, + setSortingOptions, + updateRules, + }, + }), + [ + dataUpdatedAt, + filterOptions, + filteredRules.length, + isActionInProgress, + isAllSelected, + isFetched, + isFetching, + isInMemorySorting, + isLoading, + isRefetching, + isRefreshOn, + loadingRules.action, + loadingRules.ids, + page, + perPage, + refetch, + rulesToDisplay, + selectedRuleIds, + sortingOptions, + total, + updateRules, + ] + ); + + return {children}; +}; + +export const useRulesTableContext = (): RulesTableContextType => { + const rulesTableContext = useContext(RulesTableContext); + invariant( + rulesTableContext, + 'useRulesTableContext should be used inside RulesTableContextProvider' + ); + + return rulesTableContext; +}; + +export const useRulesTableContextOptional = (): RulesTableContextType | null => + useContext(RulesTableContext); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_facade.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_facade.ts deleted file mode 100644 index e9fec425d467e..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_facade.ts +++ /dev/null @@ -1,92 +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 type { Dispatch } from 'react'; -import { Rule, FilterOptions, PaginationOptions } from '../types'; -import { RulesTableAction, LoadingRuleAction } from './rules_table_reducer'; - -export interface RulesTableFacade { - setRules(newRules: Rule[], newPagination: Partial): void; - updateRules(rules: Rule[]): void; - updateOptions(filter: Partial, pagination: Partial): void; - actionStarted(actionType: LoadingRuleAction, ruleIds: string[]): void; - actionStopped(): void; - setShowIdleModal(show: boolean): void; - setLastRefreshDate(): void; - setAutoRefreshOn(on: boolean): void; - setIsRefreshing(isRefreshing: boolean): void; -} - -export const createRulesTableFacade = (dispatch: Dispatch): RulesTableFacade => { - return { - setRules: (newRules: Rule[], newPagination: Partial) => { - dispatch({ - type: 'setRules', - rules: newRules, - pagination: newPagination, - }); - }, - - updateRules: (rules: Rule[]) => { - dispatch({ - type: 'updateRules', - rules, - }); - }, - - updateOptions: (filter: Partial, pagination: Partial) => { - dispatch({ - type: 'updateFilterOptions', - filterOptions: filter, - pagination, - }); - }, - - actionStarted: (actionType: LoadingRuleAction, ruleIds: string[]) => { - dispatch({ - type: 'loadingRuleIds', - actionType, - ids: ruleIds, - }); - }, - - actionStopped: () => { - dispatch({ - type: 'loadingRuleIds', - actionType: null, - ids: [], - }); - }, - - setShowIdleModal: (show: boolean) => { - dispatch({ - type: 'setShowIdleModal', - show, - }); - }, - - setLastRefreshDate: () => { - dispatch({ - type: 'setLastRefreshDate', - }); - }, - - setAutoRefreshOn: (on: boolean) => { - dispatch({ - type: 'setAutoRefreshOn', - on, - }); - }, - - setIsRefreshing: (isRefreshing: boolean) => { - dispatch({ - type: 'setIsRefreshing', - isRefreshing, - }); - }, - }; -}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.test.ts deleted file mode 100644 index cba9611071976..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.test.ts +++ /dev/null @@ -1,313 +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 { mockRule } from '../../../../pages/detection_engine/rules/all/__mocks__/mock'; -import { FilterOptions, PaginationOptions } from '../types'; -import { RulesTableState, rulesTableReducer } from './rules_table_reducer'; - -const initialState: RulesTableState = { - rules: [], - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - filterOptions: { - filter: '', - sortField: 'enabled', - sortOrder: 'desc', - tags: [], - showCustomRules: false, - showElasticRules: false, - }, - isAllSelected: false, - loadingRulesAction: null, - loadingRuleIds: [], - selectedRuleIds: [], - lastUpdated: 0, - isRefreshOn: false, - isRefreshing: false, - showIdleModal: false, -}; - -describe('allRulesReducer', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest - .spyOn(global.Date, 'now') - .mockImplementationOnce(() => new Date('2020-10-31T11:01:58.135Z').valueOf()); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('#loadingRuleIds', () => { - it('should update state with rule ids with a pending action', () => { - const { loadingRuleIds, loadingRulesAction } = rulesTableReducer(initialState, { - type: 'loadingRuleIds', - ids: ['123', '456'], - actionType: 'enable', - }); - - expect(loadingRuleIds).toEqual(['123', '456']); - expect(loadingRulesAction).toEqual('enable'); - }); - - it('should update loadingIds to empty array if action is null', () => { - const { loadingRuleIds, loadingRulesAction } = rulesTableReducer(initialState, { - type: 'loadingRuleIds', - ids: ['123', '456'], - actionType: null, - }); - - expect(loadingRuleIds).toEqual([]); - expect(loadingRulesAction).toBeNull(); - }); - - it('should append rule ids to any existing loading ids', () => { - const { loadingRuleIds, loadingRulesAction } = rulesTableReducer( - { ...initialState, loadingRuleIds: ['abc'] }, - { - type: 'loadingRuleIds', - ids: ['123', '456'], - actionType: 'duplicate', - } - ); - - expect(loadingRuleIds).toEqual(['abc', '123', '456']); - expect(loadingRulesAction).toEqual('duplicate'); - }); - }); - - describe('#selectedRuleIds', () => { - it('should update state with selected rule ids', () => { - const { selectedRuleIds } = rulesTableReducer(initialState, { - type: 'selectedRuleIds', - ids: ['123', '456'], - }); - - expect(selectedRuleIds).toEqual(['123', '456']); - }); - }); - - describe('#setRules', () => { - it('should update rules and reset loading/selected rule ids', () => { - const { selectedRuleIds, loadingRuleIds, loadingRulesAction, pagination, rules } = - rulesTableReducer(initialState, { - type: 'setRules', - rules: [mockRule('someRuleId')], - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - }); - - expect(rules).toEqual([mockRule('someRuleId')]); - expect(selectedRuleIds).toEqual([]); - expect(loadingRuleIds).toEqual([]); - expect(loadingRulesAction).toBeNull(); - expect(pagination).toEqual({ - page: 1, - perPage: 20, - total: 0, - }); - }); - }); - - describe('#updateRules', () => { - it('should return existing and new rules', () => { - const existingRule = { ...mockRule('123'), rule_id: 'rule-123' }; - const { rules, loadingRulesAction } = rulesTableReducer( - { ...initialState, rules: [existingRule] }, - { - type: 'updateRules', - rules: [mockRule('someRuleId')], - } - ); - - expect(rules).toEqual([existingRule, mockRule('someRuleId')]); - expect(loadingRulesAction).toBeNull(); - }); - - it('should return updated rule', () => { - const updatedRule = { ...mockRule('someRuleId'), description: 'updated rule' }; - const { rules, loadingRulesAction } = rulesTableReducer( - { ...initialState, rules: [mockRule('someRuleId')] }, - { - type: 'updateRules', - rules: [updatedRule], - } - ); - - expect(rules).toEqual([updatedRule]); - expect(loadingRulesAction).toBeNull(); - }); - - it('should return updated existing loading rule ids', () => { - const existingRule = { ...mockRule('someRuleId'), id: '123', rule_id: 'rule-123' }; - const { loadingRuleIds, loadingRulesAction } = rulesTableReducer( - { - ...initialState, - rules: [existingRule], - loadingRuleIds: ['123'], - loadingRulesAction: 'enable', - }, - { - type: 'updateRules', - rules: [mockRule('someRuleId')], - } - ); - - expect(loadingRuleIds).toEqual(['123']); - expect(loadingRulesAction).toEqual('enable'); - }); - }); - - describe('#updateFilterOptions', () => { - it('should return existing and new rules', () => { - const paginationMock: PaginationOptions = { - page: 1, - perPage: 20, - total: 0, - }; - const filterMock: FilterOptions = { - filter: 'host.name:*', - sortField: 'enabled', - sortOrder: 'desc', - tags: [], - showCustomRules: false, - showElasticRules: false, - }; - const { filterOptions, pagination } = rulesTableReducer(initialState, { - type: 'updateFilterOptions', - filterOptions: filterMock, - pagination: paginationMock, - }); - - expect(filterOptions).toEqual(filterMock); - expect(pagination).toEqual(paginationMock); - }); - }); - - describe('#failure', () => { - it('should reset rules value to empty array', () => { - const { rules } = rulesTableReducer(initialState, { - type: 'failure', - }); - - expect(rules).toEqual([]); - }); - }); - - describe('#setLastRefreshDate', () => { - it('should update last refresh date with current date', () => { - const { lastUpdated } = rulesTableReducer(initialState, { - type: 'setLastRefreshDate', - }); - - expect(lastUpdated).toEqual(1604142118135); - }); - }); - - describe('#setShowIdleModal', () => { - it('should hide idle modal and restart refresh if "show" is false', () => { - const { showIdleModal, isRefreshOn } = rulesTableReducer(initialState, { - type: 'setShowIdleModal', - show: false, - }); - - expect(showIdleModal).toBeFalsy(); - expect(isRefreshOn).toBeTruthy(); - }); - - it('should show idle modal and pause refresh if "show" is true', () => { - const { showIdleModal, isRefreshOn } = rulesTableReducer(initialState, { - type: 'setShowIdleModal', - show: true, - }); - - expect(showIdleModal).toBeTruthy(); - expect(isRefreshOn).toBeFalsy(); - }); - }); - - describe('#setAutoRefreshOn', () => { - it('should pause auto refresh if "paused" is true', () => { - const { isRefreshOn } = rulesTableReducer(initialState, { - type: 'setAutoRefreshOn', - on: true, - }); - - expect(isRefreshOn).toBeTruthy(); - }); - - it('should resume auto refresh if "paused" is false', () => { - const { isRefreshOn } = rulesTableReducer(initialState, { - type: 'setAutoRefreshOn', - on: false, - }); - - expect(isRefreshOn).toBeFalsy(); - }); - }); - - describe('#selectAllRules', () => { - it('should select all rules', () => { - const state = rulesTableReducer( - { - ...initialState, - rules: [mockRule('1'), mockRule('2'), mockRule('3')], - }, - { - type: 'setIsAllSelected', - isAllSelected: true, - } - ); - - expect(state.isAllSelected).toBe(true); - expect(state.selectedRuleIds).toEqual(['1', '2', '3']); - }); - - it('should deselect all rules', () => { - const state = rulesTableReducer( - { - ...initialState, - rules: [mockRule('1'), mockRule('2'), mockRule('3')], - isAllSelected: true, - selectedRuleIds: ['1', '2', '3'], - }, - { - type: 'setIsAllSelected', - isAllSelected: false, - } - ); - - expect(state.isAllSelected).toBe(false); - expect(state.selectedRuleIds).toEqual([]); - }); - - it('should unset "isAllSelected" on selected rules modification', () => { - const state = rulesTableReducer( - { - ...initialState, - rules: [mockRule('1'), mockRule('2'), mockRule('3')], - isAllSelected: true, - selectedRuleIds: ['1', '2', '3'], - }, - { - type: 'selectedRuleIds', - ids: ['1', '2'], - } - ); - - expect(state.isAllSelected).toBe(false); - expect(state.selectedRuleIds).toEqual(['1', '2']); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts deleted file mode 100644 index 2cc022ca7412c..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts +++ /dev/null @@ -1,159 +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 { FilterOptions, PaginationOptions, Rule } from '../types'; - -export type LoadingRuleAction = - | 'load' - | 'duplicate' - | 'enable' - | 'disable' - | 'export' - | 'delete' - | 'edit' - | null; - -export interface RulesTableState { - rules: Rule[]; - pagination: PaginationOptions; - filterOptions: FilterOptions; - loadingRulesAction: LoadingRuleAction; - loadingRuleIds: string[]; - selectedRuleIds: string[]; - lastUpdated: number; - isRefreshOn: boolean; - isRefreshing: boolean; - showIdleModal: boolean; - isAllSelected: boolean; -} - -export type RulesTableAction = - | { type: 'setRules'; rules: Rule[]; pagination: Partial } - | { type: 'updateRules'; rules: Rule[] } - | { - type: 'updateFilterOptions'; - filterOptions: Partial; - pagination: Partial; - } - | { type: 'loadingRuleIds'; ids: string[]; actionType: LoadingRuleAction } - | { type: 'selectedRuleIds'; ids: string[] } - | { type: 'setLastRefreshDate' } - | { type: 'setAutoRefreshOn'; on: boolean } - | { type: 'setIsRefreshing'; isRefreshing: boolean } - | { type: 'setIsAllSelected'; isAllSelected: boolean } - | { type: 'setShowIdleModal'; show: boolean } - | { type: 'failure' }; - -export const rulesTableReducer = ( - state: RulesTableState, - action: RulesTableAction -): RulesTableState => { - switch (action.type) { - case 'setRules': { - return { - ...state, - rules: action.rules, - selectedRuleIds: state.isAllSelected ? action.rules.map(({ id }) => id) : [], - loadingRuleIds: [], - loadingRulesAction: null, - pagination: { - ...state.pagination, - ...action.pagination, - }, - }; - } - case 'updateRules': { - const ruleIds = state.rules.map((r) => r.id); - const updatedRules = action.rules.reduce((rules, updatedRule) => { - let newRules = rules; - if (ruleIds.includes(updatedRule.id)) { - newRules = newRules.map((r) => (updatedRule.id === r.id ? updatedRule : r)); - } else { - newRules = [...newRules, updatedRule]; - } - return newRules; - }, state.rules); - const updatedRuleIds = action.rules.map((r) => r.id); - const newLoadingRuleIds = state.loadingRuleIds.filter((id) => !updatedRuleIds.includes(id)); - return { - ...state, - rules: updatedRules, - loadingRuleIds: newLoadingRuleIds, - loadingRulesAction: newLoadingRuleIds.length === 0 ? null : state.loadingRulesAction, - }; - } - case 'updateFilterOptions': { - return { - ...state, - filterOptions: { - ...state.filterOptions, - ...action.filterOptions, - }, - pagination: { - ...state.pagination, - ...action.pagination, - }, - }; - } - case 'loadingRuleIds': { - return { - ...state, - loadingRuleIds: action.actionType == null ? [] : [...state.loadingRuleIds, ...action.ids], - loadingRulesAction: action.actionType, - }; - } - case 'selectedRuleIds': { - return { - ...state, - isAllSelected: false, - selectedRuleIds: action.ids, - }; - } - case 'setLastRefreshDate': { - return { - ...state, - lastUpdated: Date.now(), - }; - } - case 'setAutoRefreshOn': { - return { - ...state, - isRefreshOn: action.on, - }; - } - case 'setIsRefreshing': { - return { - ...state, - isRefreshing: action.isRefreshing, - }; - } - case 'setIsAllSelected': { - const { isAllSelected } = action; - return { - ...state, - isAllSelected, - selectedRuleIds: isAllSelected ? state.rules.map(({ id }) => id) : [], - }; - } - case 'setShowIdleModal': { - return { - ...state, - showIdleModal: action.show, - isRefreshOn: !action.show, - }; - } - case 'failure': { - return { - ...state, - rules: [], - }; - } - default: { - return state; - } - } -}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_find_rules.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_find_rules.ts new file mode 100644 index 0000000000000..ca93c21b934ca --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_find_rules.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from 'react-query'; +import { useAppToasts } from '../../../../../common/hooks/use_app_toasts'; +import { fetchRules } from '../api'; +import * as i18n from '../translations'; +import { FilterOptions, PaginationOptions, SortingOptions } from '../types'; + +interface UseFindRulesArgs { + enabled: boolean; + isInMemorySorting: null | boolean; + filterOptions: FilterOptions; + sortingOptions: SortingOptions; + pagination: Pick; + refetchInterval: number | false; +} + +const MAX_RULES_PER_PAGE = 10000; + +export const useFindRules = ({ + enabled, + pagination, + filterOptions, + sortingOptions, + isInMemorySorting, + refetchInterval, +}: UseFindRulesArgs) => { + const { addError } = useAppToasts(); + + return useQuery( + getFindRulesQueryKey({ pagination, filterOptions, sortingOptions, isInMemorySorting }), + async ({ signal }) => { + const { page, perPage } = pagination; + + const response = await fetchRules({ + signal, + pagination: isInMemorySorting + ? { page: 1, perPage: MAX_RULES_PER_PAGE } + : { page, perPage }, + filterOptions: isInMemorySorting ? undefined : filterOptions, + sortingOptions: isInMemorySorting ? undefined : sortingOptions, + }); + + return { + rules: response.data, + total: response.total, + }; + }, + { + enabled, + refetchInterval, + refetchIntervalInBackground: false, + keepPreviousData: true, // Use this option so that the state doesn't jump between "success" and "loading" on page change + staleTime: Infinity, + onError: (error: Error) => addError(error, { title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE }), + } + ); +}; + +export const getFindRulesQueryKey = ({ + isInMemorySorting, + filterOptions, + sortingOptions, + pagination, +}: Pick< + UseFindRulesArgs, + 'isInMemorySorting' | 'filterOptions' | 'sortingOptions' | 'pagination' +>) => + isInMemorySorting + ? ['findAllRules'] // For the in-memory implementation we fetch data only once and cache it, thus the key is constant and do not depend on input arguments + : ['findAllRules', filterOptions, sortingOptions, pagination]; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_rules.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_rules.test.tsx deleted file mode 100644 index 6a527ca00f525..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_rules.test.tsx +++ /dev/null @@ -1,230 +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 { renderHook, act } from '@testing-library/react-hooks'; -import { useRules, UseRules, ReturnRules } from './use_rules'; -import * as api from '../api'; -import { useAppToastsMock } from '../../../../../common/hooks/use_app_toasts.mock'; -import { useAppToasts } from '../../../../../common/hooks/use_app_toasts'; - -jest.mock('../api'); -jest.mock('../../../../../common/hooks/use_app_toasts'); - -describe('useRules', () => { - let appToastsMock: jest.Mocked>; - - beforeEach(() => { - jest.resetAllMocks(); - appToastsMock = useAppToastsMock.create(); - (useAppToasts as jest.Mock).mockReturnValue(appToastsMock); - }); - test('init', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook((props) => - useRules({ - pagination: { - page: 1, - perPage: 10, - total: 100, - }, - filterOptions: { - filter: '', - sortField: 'created_at', - sortOrder: 'desc', - tags: [], - showCustomRules: false, - showElasticRules: false, - }, - }) - ); - await waitForNextUpdate(); - expect(result.current).toEqual([true, null, result.current[2]]); - }); - }); - - test('fetch rules', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useRules({ - pagination: { - page: 1, - perPage: 10, - total: 100, - }, - filterOptions: { - filter: '', - sortField: 'created_at', - sortOrder: 'desc', - tags: [], - showCustomRules: false, - showElasticRules: false, - }, - }) - ); - await waitForNextUpdate(); - await waitForNextUpdate(); - expect(result.current).toEqual([ - false, - { - data: [ - { - actions: [], - author: [], - created_at: '2020-02-14T19:49:28.178Z', - created_by: 'elastic', - description: - 'Elastic Endpoint detected Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.', - enabled: false, - false_positives: [], - filters: [], - from: 'now-660s', - id: '80c59768-8e1f-400e-908e-7b25c4ce29c3', - immutable: true, - index: ['endgame-*'], - interval: '10m', - language: 'kuery', - max_signals: 100, - name: 'Credential Dumping - Detected - Elastic Endpoint', - output_index: '.siem-signals-default', - query: - 'event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection', - references: [], - risk_score: 73, - risk_score_mapping: [], - rule_id: '571afc56-5ed9-465d-a2a9-045f099f6e7e', - severity: 'high', - severity_mapping: [], - tags: ['Elastic', 'Endpoint'], - threat: [], - throttle: null, - to: 'now', - type: 'query', - updated_at: '2020-02-14T19:49:28.320Z', - updated_by: 'elastic', - version: 1, - }, - { - actions: [], - author: [], - created_at: '2020-02-14T19:49:28.189Z', - created_by: 'elastic', - description: - 'Elastic Endpoint detected an Adversary Behavior. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.', - enabled: false, - false_positives: [], - filters: [], - from: 'now-660s', - id: '2e846086-bd64-4dbc-9c56-42b46b5b2c8c', - immutable: true, - index: ['endgame-*'], - interval: '10m', - language: 'kuery', - max_signals: 100, - name: 'Adversary Behavior - Detected - Elastic Endpoint', - output_index: '.siem-signals-default', - query: - 'event.kind:alert and event.module:endgame and event.action:rules_engine_event', - references: [], - risk_score: 47, - risk_score_mapping: [], - rule_id: '77a3c3df-8ec4-4da4-b758-878f551dee69', - severity: 'medium', - severity_mapping: [], - tags: ['Elastic', 'Endpoint'], - threat: [], - throttle: null, - to: 'now', - type: 'query', - updated_at: '2020-02-14T19:49:28.326Z', - updated_by: 'elastic', - version: 1, - }, - ], - page: 1, - perPage: 2, - total: 2, - }, - result.current[2], - ]); - }); - }); - - test('re-fetch rules', async () => { - const spyOnfetchRules = jest.spyOn(api, 'fetchRules'); - await act(async () => { - const { result, waitForNextUpdate } = renderHook((id) => - useRules({ - pagination: { - page: 1, - perPage: 10, - total: 100, - }, - filterOptions: { - filter: '', - sortField: 'created_at', - sortOrder: 'desc', - tags: [], - showCustomRules: false, - showElasticRules: false, - }, - }) - ); - await waitForNextUpdate(); - await waitForNextUpdate(); - if (result.current[2]) { - result.current[2](); - } - await waitForNextUpdate(); - expect(spyOnfetchRules).toHaveBeenCalledTimes(2); - }); - }); - - test('fetch rules if props changes', async () => { - const spyOnfetchRules = jest.spyOn(api, 'fetchRules'); - await act(async () => { - const { rerender, waitForNextUpdate } = renderHook( - (args) => useRules(args), - { - initialProps: { - pagination: { - page: 1, - perPage: 10, - total: 100, - }, - filterOptions: { - filter: '', - sortField: 'created_at', - sortOrder: 'desc', - tags: [], - showCustomRules: false, - showElasticRules: false, - }, - }, - } - ); - await waitForNextUpdate(); - await waitForNextUpdate(); - rerender({ - pagination: { - page: 1, - perPage: 10, - total: 100, - }, - filterOptions: { - filter: 'hello world', - sortField: 'created_at', - sortOrder: 'desc', - tags: [], - showCustomRules: false, - showElasticRules: false, - }, - }); - await waitForNextUpdate(); - expect(spyOnfetchRules).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_rules.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_rules.tsx deleted file mode 100644 index b7ef04c79d3da..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_rules.tsx +++ /dev/null @@ -1,95 +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, useState, useRef } from 'react'; - -import { useAppToasts } from '../../../../../common/hooks/use_app_toasts'; -import { FetchRulesResponse, FilterOptions, PaginationOptions, Rule } from '../types'; -import { fetchRules } from '../api'; -import * as i18n from '../translations'; - -export type ReturnRules = [boolean, FetchRulesResponse | null, () => Promise]; - -export interface UseRules { - pagination: PaginationOptions; - filterOptions: FilterOptions; - dispatchRulesInReducer?: (rules: Rule[], pagination: Partial) => void; -} - -/** - * Hook for using the list of Rules from the Detection Engine API - * - * @param pagination desired pagination options (e.g. page/perPage) - * @param filterOptions desired filters (e.g. filter/sortField/sortOrder) - */ -export const useRules = ({ - pagination, - filterOptions, - dispatchRulesInReducer, -}: UseRules): ReturnRules => { - const [rules, setRules] = useState(null); - const reFetchRules = useRef<() => Promise>(() => Promise.resolve()); - const [loading, setLoading] = useState(true); - const { addError } = useAppToasts(); - - const filterTags = filterOptions.tags.sort().join(); - useEffect(() => { - let isSubscribed = true; - const abortCtrl = new AbortController(); - - const fetchData = async () => { - try { - setLoading(true); - const fetchRulesResult = await fetchRules({ - filterOptions, - pagination, - signal: abortCtrl.signal, - }); - - if (isSubscribed) { - setRules(fetchRulesResult); - if (dispatchRulesInReducer != null) { - dispatchRulesInReducer(fetchRulesResult.data, { - page: fetchRulesResult.page, - perPage: fetchRulesResult.perPage, - total: fetchRulesResult.total, - }); - } - } - } catch (error) { - if (isSubscribed) { - addError(error, { title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE }); - if (dispatchRulesInReducer != null) { - dispatchRulesInReducer([], {}); - } - } - } - if (isSubscribed) { - setLoading(false); - } - }; - - fetchData(); - reFetchRules.current = (): Promise => fetchData(); - return () => { - isSubscribed = false; - abortCtrl.abort(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - pagination.page, - pagination.perPage, - filterOptions.filter, - filterOptions.sortField, - filterOptions.sortOrder, - filterTags, - filterOptions.showCustomRules, - filterOptions.showElasticRules, - ]); - - return [loading, rules, reFetchRules.current]; -}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_rules_table.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_rules_table.ts deleted file mode 100644 index cb41401ee2f40..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_rules_table.ts +++ /dev/null @@ -1,127 +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 { Dispatch, useReducer, useEffect, useRef } from 'react'; -import { useAppToasts } from '../../../../../common/hooks/use_app_toasts'; -import * as i18n from '../translations'; -import { fetchRules } from '../api'; -import { rulesTableReducer, RulesTableState, RulesTableAction } from './rules_table_reducer'; -import { createRulesTableFacade, RulesTableFacade } from './rules_table_facade'; - -const INITIAL_SORT_FIELD = 'enabled'; - -const initialStateDefaults: RulesTableState = { - rules: [], - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - filterOptions: { - filter: '', - sortField: INITIAL_SORT_FIELD, - sortOrder: 'desc', - tags: [], - showCustomRules: false, - showElasticRules: false, - }, - loadingRulesAction: null, - loadingRuleIds: [], - selectedRuleIds: [], - lastUpdated: 0, - isRefreshOn: true, - isRefreshing: false, - isAllSelected: false, - showIdleModal: false, -}; - -export interface UseRulesTableParams { - initialStateOverride?: Partial; -} - -export interface UseRulesTableReturn extends RulesTableFacade { - state: RulesTableState; - dispatch: Dispatch; - reFetchRules: () => Promise; -} - -export const useRulesTable = (params: UseRulesTableParams): UseRulesTableReturn => { - const { initialStateOverride } = params; - - const initialState: RulesTableState = { - ...initialStateDefaults, - lastUpdated: Date.now(), - ...initialStateOverride, - }; - - const [state, dispatch] = useReducer(rulesTableReducer, initialState); - const facade = useRef(createRulesTableFacade(dispatch)); - const { addError } = useAppToasts(); - - const reFetchRules = useRef<() => Promise>(() => Promise.resolve()); - - const { pagination, filterOptions } = state; - const filterTags = filterOptions.tags.sort().join(); - - useEffect(() => { - let isSubscribed = true; - const abortCtrl = new AbortController(); - - const fetchData = async () => { - try { - facade.current.actionStarted('load', []); - - const fetchRulesResult = await fetchRules({ - filterOptions, - pagination, - signal: abortCtrl.signal, - }); - - if (isSubscribed) { - facade.current.setRules(fetchRulesResult.data, { - page: fetchRulesResult.page, - perPage: fetchRulesResult.perPage, - total: fetchRulesResult.total, - }); - } - } catch (error) { - if (isSubscribed) { - addError(error, { title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE }); - facade.current.setRules([], {}); - } - } - if (isSubscribed) { - facade.current.actionStopped(); - } - }; - - fetchData(); - reFetchRules.current = () => fetchData(); - - return () => { - isSubscribed = false; - abortCtrl.abort(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - pagination.page, - pagination.perPage, - filterOptions.filter, - filterOptions.sortField, - filterOptions.sortOrder, - filterTags, - filterOptions.showCustomRules, - filterOptions.showElasticRules, - ]); - - return { - state, - dispatch, - ...facade.current, - reFetchRules: reFetchRules.current, - }; -}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/utils.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/utils.ts new file mode 100644 index 0000000000000..c93e14727ce70 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/utils.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get } from 'lodash'; +import { FilterOptions, Rule, SortingOptions } from '../types'; + +/** + * Merge new rules into the currently cached rules + * + * @param currentRules + * @param newRules + */ +export function mergeRules(currentRules: Rule[], newRules: Rule[]): Rule[] { + const currentRuleIds = currentRules.map((r) => r.id); + return newRules.reduce( + (mergedRules, newRule) => + currentRuleIds.includes(newRule.id) + ? mergedRules.map((rule) => (newRule.id === rule.id ? newRule : rule)) + : [...mergedRules, newRule], + currentRules + ); +} + +/** + * Returns a comparator function to be used with .sort() + * + * @param sortingOptions SortingOptions + */ +export function getRulesComparator(sortingOptions: SortingOptions) { + return (ruleA: Rule, ruleB: Rule): number => { + const { field, order } = sortingOptions; + const direction = order === 'asc' ? 1 : -1; + + switch (field) { + case 'enabled': { + const a = get(ruleA, field); + const b = get(ruleB, field); + + return compareNumbers(Number(a), Number(b), direction); + } + case 'version': + case 'risk_score': + case 'execution_summary.last_execution.metrics.execution_gap_duration_s': + case 'execution_summary.last_execution.metrics.total_indexing_duration_ms': + case 'execution_summary.last_execution.metrics.total_search_duration_ms': { + const a = get(ruleA, field) ?? -Infinity; + const b = get(ruleB, field) ?? -Infinity; + + return compareNumbers(a, b, direction); + } + case 'updated_at': + case 'created_at': + case 'execution_summary.last_execution.date': { + const a = get(ruleA, field); + const b = get(ruleB, field); + + return compareNumbers( + a == null ? 0 : new Date(a).getTime(), + b == null ? 0 : new Date(b).getTime(), + direction + ); + } + case 'execution_summary.last_execution.status': + case 'severity': + case 'name': { + const a = get(ruleA, field); + const b = get(ruleB, field); + return (a || '').localeCompare(b || '') * direction; + } + } + }; +} + +/** + * A helper to compare two numbers. + * + * @param a - first number + * @param b - second number + * @param direction - comparison direction +1 for asc or -1 for desc + * @returns comparison result + */ +const compareNumbers = (a: number, b: number, direction: number) => { + // We cannot use `return (a - b);` here as it might result in NaN if one of inputs is Infinity. + if (a > b) { + return direction; + } else if (a < b) { + return -direction; + } + return 0; +}; + +/** + * Returns a predicate function to be used with .filter() + * + * @param filterOptions Current table filter + */ +export function getRulesPredicate(filterOptions: FilterOptions) { + return (rule: Rule) => { + if ( + filterOptions.filter && + !rule.name.toLowerCase().includes(filterOptions.filter.toLowerCase()) + ) { + return false; + } + if (filterOptions.showCustomRules && rule.immutable) { + return false; + } + if (filterOptions.showElasticRules && !rule.immutable) { + return false; + } + if (filterOptions.tags.length && !filterOptions.tags.every((tag) => rule.tags.includes(tag))) { + return false; + } + return true; + }; +} diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index 9aecab3d06a02..009a95fe31367 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -171,16 +171,33 @@ export interface PaginationOptions { } export interface FetchRulesProps { - pagination?: PaginationOptions; + pagination?: Pick; filterOptions?: FilterOptions; - signal: AbortSignal; + sortingOptions?: SortingOptions; + signal?: AbortSignal; +} + +export type RulesSortingFields = + | 'created_at' + | 'enabled' + | 'execution_summary.last_execution.date' + | 'execution_summary.last_execution.metrics.execution_gap_duration_s' + | 'execution_summary.last_execution.metrics.total_indexing_duration_ms' + | 'execution_summary.last_execution.metrics.total_search_duration_ms' + | 'execution_summary.last_execution.status' + | 'name' + | 'risk_score' + | 'severity' + | 'updated_at' + | 'version'; + +export interface SortingOptions { + field: RulesSortingFields; + order: SortOrder; } -export type RulesSortingFields = 'enabled' | 'updated_at' | 'name' | 'created_at'; export interface FilterOptions { filter: string; - sortField: RulesSortingFields; - sortOrder: SortOrder; showCustomRules: boolean; showElasticRules: boolean; tags: string[]; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.test.ts index c293e26f1740c..7f69d07e83467 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.test.ts @@ -12,8 +12,6 @@ import { convertRulesFilterToKQL } from './utils'; describe('convertRulesFilterToKQL', () => { const filterOptions: FilterOptions = { filter: '', - sortField: 'name', - sortOrder: 'asc', showCustomRules: false, showElasticRules: false, tags: [], diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index 7702f38c7ab90..e4f51b05ad6d9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -42,7 +42,6 @@ import { AlertsHistogramPanel } from '../../components/alerts_kpis/alerts_histog import { useUserData } from '../../components/user_info'; import { OverviewEmpty } from '../../../overview/components/overview_empty'; import { DetectionEngineNoIndex } from './detection_engine_no_index'; -import { DetectionEngineHeaderPage } from '../../components/detection_engine_header_page'; import { useListsConfig } from '../../containers/detection_engine/lists/use_lists_config'; import { DetectionEngineUserUnauthenticated } from './detection_engine_user_unauthenticated'; import * as i18n from './translations'; @@ -77,6 +76,7 @@ import { FILTER_OPEN, } from '../../components/alerts_table/alerts_filter_group'; import { EmptyPage } from '../../../common/components/empty_page'; +import { HeaderPage } from '../../../common/components/header_page'; /** * Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space. */ @@ -258,7 +258,7 @@ const DetectionEnginePageComponent: React.FC = ({ if (loading) { return ( - + @@ -269,7 +269,7 @@ const DetectionEnginePageComponent: React.FC = ({ if (isUserAuthenticated != null && !isUserAuthenticated && !loading) { return ( - + ); @@ -278,7 +278,7 @@ const DetectionEnginePageComponent: React.FC = ({ if ((!loading && signalIndexNeedsInit) || needsListsConfiguration) { return ( - + = ({ data-test-subj="detectionsAlertsPage" > - + = ({ > {i18n.BUTTON_MANAGE_RULES} - + @@ -383,7 +383,7 @@ const DetectionEnginePageComponent: React.FC = ({ ) : ( - + )} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx index 06e026ba8f6e1..93bc806e7ae0d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Dispatch } from 'react'; +import { Dispatch } from 'react'; import { NavigateToAppOptions } from '../../../../../../../../../src/core/public'; import { APP_UI_ID } from '../../../../../../common/constants'; import { BulkAction } from '../../../../../../common/detection_engine/schemas/common/schemas'; @@ -27,8 +27,8 @@ import { exportRules, performBulkAction, Rule, - RulesTableAction, } from '../../../../containers/detection_engine/rules'; +import { RulesTableActions } from '../../../../containers/detection_engine/rules/rules_table/rules_table_context'; import { transformOutput } from '../../../../containers/detection_engine/rules/transforms'; import * as i18n from '../translations'; import { bucketRulesResponse, getExportedRulesCount } from './helpers'; @@ -46,11 +46,11 @@ export const editRuleAction = ( export const duplicateRulesAction = async ( rules: Rule[], ruleIds: string[], - dispatch: React.Dispatch, - dispatchToaster: Dispatch + dispatchToaster: Dispatch, + setLoadingRules: RulesTableActions['setLoadingRules'] ): Promise => { try { - dispatch({ type: 'loadingRuleIds', ids: ruleIds, actionType: 'duplicate' }); + setLoadingRules({ ids: ruleIds, action: 'duplicate' }); const response = await duplicateRules({ // We cast this back and forth here as the front end types are not really the right io-ts ones // and the two types conflict with each other. @@ -70,17 +70,17 @@ export const duplicateRulesAction = async ( } catch (error) { errorToToaster({ title: i18n.DUPLICATE_RULE_ERROR, error, dispatchToaster }); } finally { - dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); + setLoadingRules({ ids: [], action: null }); } }; export const exportRulesAction = async ( exportRuleId: string[], - dispatch: React.Dispatch, - dispatchToaster: Dispatch + dispatchToaster: Dispatch, + setLoadingRules: RulesTableActions['setLoadingRules'] ) => { try { - dispatch({ type: 'loadingRuleIds', ids: exportRuleId, actionType: 'export' }); + setLoadingRules({ ids: exportRuleId, action: 'export' }); const blob = await exportRules({ ids: exportRuleId }); downloadBlob(blob, `${i18n.EXPORT_FILENAME}.ndjson`); @@ -92,18 +92,18 @@ export const exportRulesAction = async ( } catch (e) { displayErrorToast(i18n.BULK_ACTION_FAILED, [e.message], dispatchToaster); } finally { - dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); + setLoadingRules({ ids: [], action: null }); } }; export const deleteRulesAction = async ( ruleIds: string[], - dispatch: React.Dispatch, dispatchToaster: Dispatch, + setLoadingRules: RulesTableActions['setLoadingRules'], onRuleDeleted?: () => void ) => { try { - dispatch({ type: 'loadingRuleIds', ids: ruleIds, actionType: 'delete' }); + setLoadingRules({ ids: ruleIds, action: 'delete' }); const response = await deleteRules({ ids: ruleIds }); const { errors } = bucketRulesResponse(response); if (errors.length > 0) { @@ -122,27 +122,27 @@ export const deleteRulesAction = async ( dispatchToaster, }); } finally { - dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); + setLoadingRules({ ids: [], action: null }); } }; export const enableRulesAction = async ( ids: string[], enabled: boolean, - dispatch: React.Dispatch, - dispatchToaster: Dispatch + dispatchToaster: Dispatch, + setLoadingRules: RulesTableActions['setLoadingRules'], + updateRules: RulesTableActions['updateRules'] ) => { const errorTitle = enabled ? i18n.BATCH_ACTION_ACTIVATE_SELECTED_ERROR(ids.length) : i18n.BATCH_ACTION_DEACTIVATE_SELECTED_ERROR(ids.length); try { - dispatch({ type: 'loadingRuleIds', ids, actionType: enabled ? 'enable' : 'disable' }); + setLoadingRules({ ids, action: enabled ? 'enable' : 'disable' }); const response = await enableRules({ ids, enabled }); const { rules, errors } = bucketRulesResponse(response); - - dispatch({ type: 'updateRules', rules }); + updateRules(rules); if (errors.length > 0) { displayErrorToast( @@ -167,7 +167,7 @@ export const enableRulesAction = async ( } catch (e) { displayErrorToast(errorTitle, [e.message], dispatchToaster); } finally { - dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); + setLoadingRules({ ids: [], action: null }); } }; @@ -176,11 +176,11 @@ export const rulesBulkActionByQuery = async ( selectedItemsCount: number, query: string, action: BulkAction, - dispatch: React.Dispatch, - dispatchToaster: Dispatch + dispatchToaster: Dispatch, + setLoadingRules: RulesTableActions['setLoadingRules'] ) => { try { - dispatch({ type: 'loadingRuleIds', ids: visibleRuleIds, actionType: action }); + setLoadingRules({ ids: visibleRuleIds, action }); if (action === BulkAction.export) { const blob = await performBulkAction({ query, action }); @@ -197,6 +197,6 @@ export const rulesBulkActionByQuery = async ( } catch (e) { displayErrorToast(i18n.BULK_ACTION_FAILED, [e.message], dispatchToaster); } finally { - dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); + setLoadingRules({ ids: [], action: null }); } }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/batch_actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/batch_actions.tsx deleted file mode 100644 index 5b558824b4659..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/batch_actions.tsx +++ /dev/null @@ -1,245 +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 { EuiContextMenuItem, EuiToolTip } from '@elastic/eui'; -import React, { Dispatch } from 'react'; -import * as i18n from '../translations'; -import { RulesTableAction } from '../../../../containers/detection_engine/rules/rules_table'; -import { - rulesBulkActionByQuery, - deleteRulesAction, - duplicateRulesAction, - enableRulesAction, - exportRulesAction, -} from './actions'; -import { ActionToaster, displayWarningToast } from '../../../../../common/components/toasters'; -import { Rule } from '../../../../containers/detection_engine/rules'; -import * as detectionI18n from '../../translations'; -import { isMlRule } from '../../../../../../common/machine_learning/helpers'; -import { canEditRuleWithActions } from '../../../../../common/utils/privileges'; -import { BulkAction } from '../../../../../../common/detection_engine/schemas/common/schemas'; - -interface GetBatchItems { - closePopover: () => void; - dispatch: Dispatch; - dispatchToaster: Dispatch; - hasMlPermissions: boolean; - hasActionsPrivileges: boolean; - loadingRuleIds: string[]; - reFetchRules: () => Promise; - refetchPrePackagedRulesStatus: () => Promise; - rules: Rule[]; - selectedRuleIds: string[]; - isAllSelected: boolean; - filterQuery: string; - confirmDeletion: () => Promise; - selectedItemsCount: number; -} - -export const getBatchItems = ({ - closePopover, - dispatch, - dispatchToaster, - hasMlPermissions, - loadingRuleIds, - reFetchRules, - refetchPrePackagedRulesStatus, - rules, - selectedRuleIds, - hasActionsPrivileges, - isAllSelected, - filterQuery, - confirmDeletion, - selectedItemsCount, -}: GetBatchItems) => { - const selectedRules = rules.filter(({ id }) => selectedRuleIds.includes(id)); - - const containsEnabled = selectedRules.some(({ enabled }) => enabled); - const containsDisabled = selectedRules.some(({ enabled }) => !enabled); - const containsLoading = selectedRuleIds.some((id) => loadingRuleIds.includes(id)); - const containsImmutable = selectedRules.some(({ immutable }) => immutable); - - const missingActionPrivileges = - !hasActionsPrivileges && - selectedRules.some((rule) => !canEditRuleWithActions(rule, hasActionsPrivileges)); - - const handleActivateAction = async () => { - closePopover(); - const deactivatedRules = selectedRules.filter(({ enabled }) => !enabled); - const deactivatedRulesNoML = deactivatedRules.filter(({ type }) => !isMlRule(type)); - - const mlRuleCount = deactivatedRules.length - deactivatedRulesNoML.length; - if (!hasMlPermissions && mlRuleCount > 0) { - displayWarningToast(detectionI18n.ML_RULES_UNAVAILABLE(mlRuleCount), dispatchToaster); - } - - const ruleIds = hasMlPermissions - ? deactivatedRules.map(({ id }) => id) - : deactivatedRulesNoML.map(({ id }) => id); - - if (isAllSelected) { - await rulesBulkActionByQuery( - ruleIds, - selectedItemsCount, - filterQuery, - BulkAction.enable, - dispatch, - dispatchToaster - ); - await reFetchRules(); - } else { - await enableRulesAction(ruleIds, true, dispatch, dispatchToaster); - } - }; - - const handleDeactivateActions = async () => { - closePopover(); - const activatedIds = selectedRules.filter(({ enabled }) => enabled).map(({ id }) => id); - if (isAllSelected) { - await rulesBulkActionByQuery( - activatedIds, - selectedItemsCount, - filterQuery, - BulkAction.disable, - dispatch, - dispatchToaster - ); - await reFetchRules(); - } else { - await enableRulesAction(activatedIds, false, dispatch, dispatchToaster); - } - }; - - const handleDuplicateAction = async () => { - closePopover(); - if (isAllSelected) { - await rulesBulkActionByQuery( - selectedRuleIds, - selectedItemsCount, - filterQuery, - BulkAction.duplicate, - dispatch, - dispatchToaster - ); - await reFetchRules(); - } else { - await duplicateRulesAction(selectedRules, selectedRuleIds, dispatch, dispatchToaster); - } - await reFetchRules(); - await refetchPrePackagedRulesStatus(); - }; - - const handleDeleteAction = async () => { - closePopover(); - if (isAllSelected) { - if ((await confirmDeletion()) === false) { - // User has cancelled deletion - return; - } - - await rulesBulkActionByQuery( - selectedRuleIds, - selectedItemsCount, - filterQuery, - BulkAction.delete, - dispatch, - dispatchToaster - ); - } else { - await deleteRulesAction(selectedRuleIds, dispatch, dispatchToaster); - } - await reFetchRules(); - await refetchPrePackagedRulesStatus(); - }; - - const handleExportAction = async () => { - closePopover(); - if (isAllSelected) { - await rulesBulkActionByQuery( - selectedRuleIds, - selectedItemsCount, - filterQuery, - BulkAction.export, - dispatch, - dispatchToaster - ); - } else { - await exportRulesAction( - selectedRules.map((r) => r.rule_id), - dispatch, - dispatchToaster - ); - } - }; - - return [ - - - <>{i18n.BATCH_ACTION_ACTIVATE_SELECTED} - - , - - - <>{i18n.BATCH_ACTION_DEACTIVATE_SELECTED} - - , - - {i18n.BATCH_ACTION_EXPORT_SELECTED} - , - - - - <>{i18n.BATCH_ACTION_DUPLICATE_SELECTED} - - , - - {i18n.BATCH_ACTION_DELETE_SELECTED} - , - ]; -}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.test.tsx deleted file mode 100644 index 59c09c415f50e..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.test.tsx +++ /dev/null @@ -1,75 +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 uuid from 'uuid'; -import '../../../../../common/mock/match_media'; -import { deleteRulesAction, duplicateRulesAction, editRuleAction } from './actions'; -import { getActions } from './columns'; -import { mockRule } from './__mocks__/mock'; - -jest.mock('./actions', () => ({ - duplicateRulesAction: jest.fn(), - deleteRulesAction: jest.fn(), - editRuleAction: jest.fn(), -})); - -const duplicateRulesActionMock = duplicateRulesAction as jest.Mock; -const deleteRulesActionMock = deleteRulesAction as jest.Mock; -const editRuleActionMock = editRuleAction as jest.Mock; - -describe('AllRulesTable Columns', () => { - describe('getActions', () => { - const rule = mockRule(uuid.v4()); - const dispatch = jest.fn(); - const dispatchToaster = jest.fn(); - const reFetchRules = jest.fn(); - const refetchPrePackagedRulesStatus = jest.fn(); - - beforeEach(() => { - duplicateRulesActionMock.mockClear(); - deleteRulesActionMock.mockClear(); - reFetchRules.mockClear(); - }); - - test('duplicate rule onClick should call rule edit after the rule is duplicated', async () => { - const ruleDuplicate = mockRule('newRule'); - const navigateToApp = jest.fn(); - duplicateRulesActionMock.mockImplementation(() => Promise.resolve([ruleDuplicate])); - - const duplicateRulesActionObject = getActions( - dispatch, - dispatchToaster, - navigateToApp, - reFetchRules, - refetchPrePackagedRulesStatus, - true - )[1]; - await duplicateRulesActionObject.onClick(rule); - expect(duplicateRulesActionMock).toHaveBeenCalled(); - expect(editRuleActionMock).toHaveBeenCalledWith(ruleDuplicate.id, navigateToApp); - }); - - test('delete rule onClick should call refetch after the rule is deleted', async () => { - const navigateToApp = jest.fn(); - - const deleteRulesActionObject = getActions( - dispatch, - dispatchToaster, - navigateToApp, - reFetchRules, - refetchPrePackagedRulesStatus, - true - )[3]; - await deleteRulesActionObject.onClick(rule); - expect(deleteRulesActionMock).toHaveBeenCalledTimes(1); - expect(reFetchRules).toHaveBeenCalledTimes(1); - expect(deleteRulesActionMock.mock.invocationCallOrder[0]).toBeLessThan( - reFetchRules.mock.invocationCallOrder[0] - ); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx deleted file mode 100644 index d03e61334b728..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx +++ /dev/null @@ -1,445 +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, { Dispatch } from 'react'; -import { - EuiBasicTableColumn, - EuiTableActionsColumnType, - EuiText, - EuiToolTip, - EuiLink, - EuiBadge, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import { - RuleExecutionSummary, - DurationMetric, -} from '../../../../../../common/detection_engine/schemas/common'; -import { isMlRule } from '../../../../../../common/machine_learning/helpers'; -import { Rule } from '../../../../containers/detection_engine/rules'; -import { getEmptyTagValue } from '../../../../../common/components/empty_value'; -import { FormattedRelativePreferenceDate } from '../../../../../common/components/formatted_date'; -import { getRuleDetailsUrl } from '../../../../../common/components/link_to/redirect_to_detection_engine'; -import { ActionToaster } from '../../../../../common/components/toasters'; -import { PopoverItems } from '../../../../../common/components/popover_items'; -import { RuleSwitch } from '../../../../components/rules/rule_switch'; -import { SeverityBadge } from '../../../../components/rules/severity_badge'; -import { RuleStatusBadge } from '../../../../components/rules/rule_execution_status'; -import * as i18n from '../translations'; -import { - deleteRulesAction, - duplicateRulesAction, - editRuleAction, - exportRulesAction, -} from './actions'; -import { RulesTableAction } from '../../../../containers/detection_engine/rules/rules_table'; -import { LinkAnchor } from '../../../../../common/components/links'; -import { getToolTipContent, canEditRuleWithActions } from '../../../../../common/utils/privileges'; -import { PopoverTooltip } from './popover_tooltip'; -import { TableHeaderTooltipCell } from './table_header_tooltip_cell'; - -import { - APP_UI_ID, - SecurityPageName, - DEFAULT_RELATIVE_DATE_THRESHOLD, -} from '../../../../../../common/constants'; -import { DocLinksStart, NavigateToAppOptions } from '../../../../../../../../../src/core/public'; - -type FormatUrl = (path: string) => string; -type HasReadActionsPrivileges = - | boolean - | Readonly<{ - [x: string]: boolean; - }>; - -export type TableColumn = EuiBasicTableColumn | EuiTableActionsColumnType; - -export const getActions = ( - dispatch: React.Dispatch, - dispatchToaster: Dispatch, - navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise, - reFetchRules: () => Promise, - refetchPrePackagedRulesStatus: () => Promise, - actionsPrivileges: HasReadActionsPrivileges -) => [ - { - 'data-test-subj': 'editRuleAction', - description: i18n.EDIT_RULE_SETTINGS, - name: !actionsPrivileges ? ( - - <>{i18n.EDIT_RULE_SETTINGS} - - ) : ( - i18n.EDIT_RULE_SETTINGS - ), - icon: 'controlsHorizontal', - onClick: (rowItem: Rule) => editRuleAction(rowItem.id, navigateToApp), - enabled: (rowItem: Rule) => canEditRuleWithActions(rowItem, actionsPrivileges), - }, - { - 'data-test-subj': 'duplicateRuleAction', - description: i18n.DUPLICATE_RULE, - icon: 'copy', - name: !actionsPrivileges ? ( - - <>{i18n.DUPLICATE_RULE} - - ) : ( - i18n.DUPLICATE_RULE - ), - enabled: (rowItem: Rule) => canEditRuleWithActions(rowItem, actionsPrivileges), - onClick: async (rowItem: Rule) => { - const createdRules = await duplicateRulesAction( - [rowItem], - [rowItem.id], - dispatch, - dispatchToaster - ); - if (createdRules?.length) { - editRuleAction(createdRules[0].id, navigateToApp); - } - }, - }, - { - 'data-test-subj': 'exportRuleAction', - description: i18n.EXPORT_RULE, - icon: 'exportAction', - name: i18n.EXPORT_RULE, - onClick: (rowItem: Rule) => exportRulesAction([rowItem.rule_id], dispatch, dispatchToaster), - enabled: (rowItem: Rule) => !rowItem.immutable, - }, - { - 'data-test-subj': 'deleteRuleAction', - description: i18n.DELETE_RULE, - icon: 'trash', - name: i18n.DELETE_RULE, - onClick: async (rowItem: Rule) => { - await deleteRulesAction([rowItem.id], dispatch, dispatchToaster); - await reFetchRules(); - await refetchPrePackagedRulesStatus(); - }, - }, -]; - -interface GetColumnsProps { - dispatch: React.Dispatch; - formatUrl: FormatUrl; - hasMlPermissions: boolean; - hasPermissions: boolean; - loadingRuleIds: string[]; - navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise; - hasReadActionsPrivileges: HasReadActionsPrivileges; - dispatchToaster: Dispatch; - reFetchRules: () => Promise; - refetchPrePackagedRulesStatus: () => Promise; - docLinks: DocLinksStart; -} - -const getColumnEnabled = ({ - hasMlPermissions, - hasReadActionsPrivileges, - dispatch, - hasPermissions, - loadingRuleIds, -}: GetColumnsProps): TableColumn => ({ - field: 'enabled', - name: i18n.COLUMN_ACTIVATE, - render: (_, rule: Rule) => ( - - - - ), - width: '95px', - sortable: true, -}); - -const getColumnRuleName = ({ navigateToApp, formatUrl }: GetColumnsProps): TableColumn => ({ - field: 'name', - name: i18n.COLUMN_RULE, - render: (value: Rule['name'], item: Rule) => ( - - void }) => { - ev.preventDefault(); - navigateToApp(APP_UI_ID, { - deepLinkId: SecurityPageName.rules, - path: getRuleDetailsUrl(item.id), - }); - }} - href={formatUrl(getRuleDetailsUrl(item.id))} - > - {value} - - - ), - width: '38%', - sortable: true, - truncateText: true, -}); - -const getColumnTags = (): TableColumn => ({ - field: 'tags', - name: null, - align: 'center', - render: (tags: Rule['tags']) => { - if (tags.length === 0) { - return null; - } - - const renderItem = (tag: string, i: number) => ( - - {tag} - - ); - return ( - - ); - }, - width: '65px', - truncateText: true, -}); - -const getActionsColumns = ({ - hasPermissions, - hasReadActionsPrivileges, - dispatch, - dispatchToaster, - navigateToApp, - reFetchRules, - refetchPrePackagedRulesStatus, -}: GetColumnsProps): TableColumn[] => - hasPermissions - ? [ - { - actions: getActions( - dispatch, - dispatchToaster, - navigateToApp, - reFetchRules, - refetchPrePackagedRulesStatus, - hasReadActionsPrivileges - ), - width: '40px', - } as EuiTableActionsColumnType, - ] - : []; - -export const getRulesColumns = (columnsProps: GetColumnsProps): TableColumn[] => { - return [ - getColumnRuleName(columnsProps), - getColumnTags(), - { - field: 'risk_score', - name: i18n.COLUMN_RISK_SCORE, - render: (value: Rule['risk_score']) => ( - - {value} - - ), - width: '85px', - truncateText: true, - }, - { - field: 'severity', - name: i18n.COLUMN_SEVERITY, - render: (value: Rule['severity']) => , - width: '12%', - truncateText: true, - }, - { - field: 'execution_summary.last_execution.date', - name: i18n.COLUMN_LAST_COMPLETE_RUN, - render: (value: RuleExecutionSummary['last_execution']['date'] | undefined) => { - return value == null ? ( - getEmptyTagValue() - ) : ( - - ); - }, - width: '16%', - truncateText: true, - }, - { - field: 'execution_summary.last_execution.status', - name: i18n.COLUMN_LAST_RESPONSE, - render: (value: RuleExecutionSummary['last_execution']['status'] | undefined) => ( - - ), - width: '16%', - truncateText: true, - }, - { - field: 'updated_at', - name: i18n.COLUMN_LAST_UPDATE, - render: (value: Rule['updated_at']) => { - return value == null ? ( - getEmptyTagValue() - ) : ( - - ); - }, - sortable: true, - width: '18%', - truncateText: true, - }, - { - field: 'version', - name: i18n.COLUMN_VERSION, - render: (value: Rule['version']) => { - return value == null ? ( - getEmptyTagValue() - ) : ( - - {value} - - ); - }, - width: '65px', - truncateText: true, - }, - getColumnEnabled(columnsProps), - ...getActionsColumns(columnsProps), - ]; -}; - -export const getMonitoringColumns = (columnsProps: GetColumnsProps): TableColumn[] => { - const { docLinks } = columnsProps; - return [ - { ...getColumnRuleName(columnsProps), width: '28%' }, - getColumnTags(), - { - field: 'execution_summary.last_execution.metrics.total_indexing_duration_ms', - name: ( - - ), - render: (value: DurationMetric | undefined) => ( - - {value != null ? value.toFixed() : getEmptyTagValue()} - - ), - width: '16%', - truncateText: true, - }, - { - field: 'execution_summary.last_execution.metrics.total_search_duration_ms', - name: ( - - ), - render: (value: DurationMetric | undefined) => ( - - {value != null ? value.toFixed() : getEmptyTagValue()} - - ), - width: '14%', - truncateText: true, - }, - { - field: 'execution_summary.last_execution.metrics.execution_gap_duration_s', - name: ( - - - -

- - {'see documentation'} - - ), - }} - /> -

-
-
-
- } - /> - ), - render: (value: DurationMetric | undefined) => ( - - {value != null ? value.toFixed() : getEmptyTagValue()} - - ), - width: '14%', - truncateText: true, - }, - { - field: 'execution_summary.last_execution.status', - name: i18n.COLUMN_LAST_RESPONSE, - render: (value: RuleExecutionSummary['last_execution']['status'] | undefined) => ( - - ), - width: '12%', - truncateText: true, - }, - { - field: 'execution_summary.last_execution.date', - name: i18n.COLUMN_LAST_COMPLETE_RUN, - render: (value: RuleExecutionSummary['last_execution']['date'] | undefined) => { - return value == null ? ( - getEmptyTagValue() - ) : ( - - ); - }, - width: '16%', - truncateText: true, - }, - getColumnEnabled(columnsProps), - ...getActionsColumns(columnsProps), - ]; -}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/use_all_exception_lists.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/use_all_exception_lists.tsx index 44518944a9227..4c65be03b24dd 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/use_all_exception_lists.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/use_all_exception_lists.tsx @@ -95,7 +95,6 @@ export const useAllExceptionLists = ({ pagination: { page: 1, perPage: 10000, - total: 0, }, signal: abortCtrl.signal, }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx index c63aa17902740..b78dfbcce4f7b 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx @@ -5,144 +5,24 @@ * 2.0. */ -import React from 'react'; -import { shallow, mount } from 'enzyme'; import { waitFor } from '@testing-library/react'; - -import '../../../../../common/mock/match_media'; -import '../../../../../common/mock/formatted_relative'; +import { mount, shallow } from 'enzyme'; +import React from 'react'; +import { useKibana } from '../../../../../common/lib/kibana'; import { TestProviders } from '../../../../../common/mock'; - -import { useKibana, useUiSetting$ } from '../../../../../common/lib/kibana'; -import { createUseUiSetting$Mock } from '../../../../../common/lib/kibana/kibana_react.mock'; -import { - DEFAULT_RULE_REFRESH_INTERVAL_ON, - DEFAULT_RULE_REFRESH_INTERVAL_VALUE, - DEFAULT_RULE_REFRESH_IDLE_VALUE, - DEFAULT_RULES_TABLE_REFRESH_SETTING, -} from '../../../../../../common/constants'; - -import { useRulesTable, RulesTableState } from '../../../../containers/detection_engine/rules'; - +import '../../../../../common/mock/formatted_relative'; +import '../../../../../common/mock/match_media'; import { AllRules } from './index'; -jest.mock('react-router-dom', () => { - const original = jest.requireActual('react-router-dom'); - - return { - ...original, - useHistory: () => ({ - useHistory: jest.fn(), - }), - }; -}); - jest.mock('../../../../../common/components/link_to'); jest.mock('../../../../../common/lib/kibana'); jest.mock('../../../../containers/detection_engine/rules'); +jest.mock('../../../../containers/detection_engine/rules/rules_table/rules_table_context'); const useKibanaMock = useKibana as jest.Mocked; -const mockUseUiSetting$ = useUiSetting$ as jest.Mock; -const mockUseRulesTable = useRulesTable as jest.Mock; describe('AllRules', () => { - const mockRefetchRulesData = jest.fn(); - beforeEach(() => { - jest.useFakeTimers(); - - mockUseUiSetting$.mockImplementation((key, defaultValue) => { - const useUiSetting$Mock = createUseUiSetting$Mock(); - - return key === DEFAULT_RULES_TABLE_REFRESH_SETTING - ? [ - { - on: DEFAULT_RULE_REFRESH_INTERVAL_ON, - value: DEFAULT_RULE_REFRESH_INTERVAL_VALUE, - idleTimeout: DEFAULT_RULE_REFRESH_IDLE_VALUE, - }, - jest.fn(), - ] - : useUiSetting$Mock(key, defaultValue); - }); - - mockUseRulesTable.mockImplementation(({ initialStateOverride }) => { - const initialState: RulesTableState = { - rules: [ - { - actions: [], - author: [], - created_at: '2020-02-14T19:49:28.178Z', - created_by: 'elastic', - description: 'jibber jabber', - enabled: false, - false_positives: [], - filters: [], - from: 'now-660s', - id: 'rule-id-1', - immutable: true, - index: ['endgame-*'], - interval: '10m', - language: 'kuery', - max_signals: 100, - name: 'Credential Dumping - Detected - Elastic Endpoint', - output_index: '.siem-signals-default', - query: 'host.name:*', - references: [], - risk_score: 73, - risk_score_mapping: [], - rule_id: '571afc56-5ed9-465d-a2a9-045f099f6e7e', - severity: 'high', - severity_mapping: [], - tags: ['Elastic', 'Endpoint'], - threat: [], - throttle: null, - to: 'now', - type: 'query', - updated_at: '2020-02-14T19:49:28.320Z', - updated_by: 'elastic', - version: 1, - }, - ], - pagination: { - page: 1, - perPage: 20, - total: 1, - }, - filterOptions: { - filter: '', - sortField: 'enabled', - sortOrder: 'desc', - tags: [], - showCustomRules: false, - showElasticRules: false, - }, - loadingRulesAction: null, - loadingRuleIds: [], - selectedRuleIds: [], - lastUpdated: 0, - isRefreshOn: true, - isRefreshing: false, - isAllSelected: false, - showIdleModal: false, - }; - - return { - state: { ...initialState, ...initialStateOverride }, - dispatch: jest.fn(), - reFetchRules: mockRefetchRulesData, - setRules: jest.fn(), - updateRules: jest.fn(), - updateOptions: jest.fn(), - actionStarted: jest.fn(), - actionStopped: jest.fn(), - setShowIdleModal: jest.fn(), - setLastRefreshDate: jest.fn(), - setAutoRefreshOn: jest.fn(), - setIsRefreshing: jest.fn(), - }; - }); - useKibanaMock().services.application.capabilities = { navLinks: {}, management: {}, @@ -163,7 +43,6 @@ describe('AllRules', () => { hasPermissions loading={false} loadingCreatePrePackagedRules={false} - refetchPrePackagedRulesStatus={jest.fn()} rulesCustomInstalled={0} rulesInstalled={0} rulesNotInstalled={0} @@ -175,75 +54,6 @@ describe('AllRules', () => { expect(wrapper.find('[data-test-subj="allRulesTableTab-rules"]')).toHaveLength(1); }); - it('it pulls from uiSettings to determine default refresh values', async () => { - mount( - - - - ); - - await waitFor(() => { - expect(mockRefetchRulesData).not.toHaveBeenCalled(); - - jest.advanceTimersByTime(DEFAULT_RULE_REFRESH_INTERVAL_VALUE); - expect(mockRefetchRulesData).toHaveBeenCalledTimes(1); - }); - }); - - // refresh functionality largely tested in cypress tests - it('it pulls from storage and does not set an auto refresh interval if storage indicates refresh is paused', async () => { - mockUseUiSetting$.mockImplementation(() => [ - { - on: false, - value: DEFAULT_RULE_REFRESH_INTERVAL_VALUE, - idleTimeout: DEFAULT_RULE_REFRESH_IDLE_VALUE, - }, - jest.fn(), - ]); - - const wrapper = mount( - - - - ); - - await waitFor(() => { - expect(mockRefetchRulesData).not.toHaveBeenCalled(); - - jest.advanceTimersByTime(DEFAULT_RULE_REFRESH_INTERVAL_VALUE); - expect(mockRefetchRulesData).not.toHaveBeenCalled(); - - wrapper.find('[data-test-subj="refreshSettings"] button').first().simulate('click'); - - wrapper.find('[data-test-subj="refreshSettingsSwitch"]').first().simulate('click'); - - jest.advanceTimersByTime(DEFAULT_RULE_REFRESH_INTERVAL_VALUE); - expect(mockRefetchRulesData).not.toHaveBeenCalled(); - }); - }); - describe('tabs', () => { it('renders all rules tab by default', async () => { const wrapper = mount( @@ -253,7 +63,6 @@ describe('AllRules', () => { hasPermissions loading={false} loadingCreatePrePackagedRules={false} - refetchPrePackagedRulesStatus={jest.fn()} rulesCustomInstalled={1} rulesInstalled={0} rulesNotInstalled={0} @@ -278,7 +87,6 @@ describe('AllRules', () => { hasPermissions loading={false} loadingCreatePrePackagedRules={false} - refetchPrePackagedRulesStatus={jest.fn()} rulesCustomInstalled={1} rulesInstalled={0} rulesNotInstalled={0} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx index 76a049936a722..8c7205ee70687 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx @@ -7,20 +7,15 @@ import { EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui'; import React, { useMemo, useState } from 'react'; -import { useHistory } from 'react-router-dom'; - -import { SecurityPageName } from '../../../../../app/types'; -import { useFormatUrl } from '../../../../../common/components/link_to'; import { CreatePreBuiltRules } from '../../../../containers/detection_engine/rules'; -import { RulesTables } from './rules_tables'; import * as i18n from '../translations'; +import { RulesTables } from './rules_tables'; interface AllRulesProps { createPrePackagedRules: CreatePreBuiltRules | null; hasPermissions: boolean; loading: boolean; loadingCreatePrePackagedRules: boolean; - refetchPrePackagedRulesStatus: () => Promise; rulesCustomInstalled: number | null; rulesInstalled: number | null; rulesNotInstalled: number | null; @@ -60,15 +55,12 @@ export const AllRules = React.memo( hasPermissions, loading, loadingCreatePrePackagedRules, - refetchPrePackagedRulesStatus, rulesCustomInstalled, rulesInstalled, rulesNotInstalled, rulesNotUpdated, setRefreshRulesData, }) => { - const history = useHistory(); - const { formatUrl } = useFormatUrl(SecurityPageName.rules); const [allRulesTab, setAllRulesTab] = useState(AllRulesTabs.rules); const tabs = useMemo( @@ -87,8 +79,7 @@ export const AllRules = React.memo( ))} ), - // eslint-disable-next-line react-hooks/exhaustive-deps - [allRulesTabs, allRulesTab, setAllRulesTab] + [allRulesTab] ); return ( @@ -96,18 +87,15 @@ export const AllRules = React.memo( {tabs} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.test.tsx new file mode 100644 index 0000000000000..44651172a6b26 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.test.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import uuid from 'uuid'; +import '../../../../../common/mock/match_media'; +import { deleteRulesAction, duplicateRulesAction, editRuleAction } from './actions'; +import { getRulesTableActions } from './rules_table_actions'; +import { mockRule } from './__mocks__/mock'; + +jest.mock('./actions', () => ({ + duplicateRulesAction: jest.fn(), + deleteRulesAction: jest.fn(), + editRuleAction: jest.fn(), +})); + +const duplicateRulesActionMock = duplicateRulesAction as jest.Mock; +const deleteRulesActionMock = deleteRulesAction as jest.Mock; +const editRuleActionMock = editRuleAction as jest.Mock; + +describe('getRulesTableActions', () => { + const rule = mockRule(uuid.v4()); + const dispatchToaster = jest.fn(); + const reFetchRules = jest.fn(); + const setLoadingRules = jest.fn(); + + beforeEach(() => { + duplicateRulesActionMock.mockClear(); + deleteRulesActionMock.mockClear(); + reFetchRules.mockClear(); + }); + + test('duplicate rule onClick should call rule edit after the rule is duplicated', async () => { + const ruleDuplicate = mockRule('newRule'); + const navigateToApp = jest.fn(); + duplicateRulesActionMock.mockImplementation(() => Promise.resolve([ruleDuplicate])); + + const duplicateRulesActionObject = getRulesTableActions( + dispatchToaster, + navigateToApp, + reFetchRules, + true, + setLoadingRules + )[1]; + const duplicateRulesActionHandler = duplicateRulesActionObject.onClick; + expect(duplicateRulesActionHandler).toBeDefined(); + + await duplicateRulesActionHandler!(rule); + expect(duplicateRulesActionMock).toHaveBeenCalled(); + expect(editRuleActionMock).toHaveBeenCalledWith(ruleDuplicate.id, navigateToApp); + }); + + test('delete rule onClick should call refetch after the rule is deleted', async () => { + const navigateToApp = jest.fn(); + + const deleteRulesActionObject = getRulesTableActions( + dispatchToaster, + navigateToApp, + reFetchRules, + true, + setLoadingRules + )[3]; + const deleteRuleActionHandler = deleteRulesActionObject.onClick; + expect(deleteRuleActionHandler).toBeDefined(); + + await deleteRuleActionHandler!(rule); + expect(deleteRulesActionMock).toHaveBeenCalledTimes(1); + expect(reFetchRules).toHaveBeenCalledTimes(1); + expect(deleteRulesActionMock.mock.invocationCallOrder[0]).toBeLessThan( + reFetchRules.mock.invocationCallOrder[0] + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.tsx new file mode 100644 index 0000000000000..156375826aeb5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.tsx @@ -0,0 +1,99 @@ +/* + * 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 { + DefaultItemAction, + EuiBasicTableColumn, + EuiTableActionsColumnType, + EuiToolTip, +} from '@elastic/eui'; +import React, { Dispatch } from 'react'; +import { NavigateToAppOptions } from '../../../../../../../../../src/core/public'; +import { ActionToaster } from '../../../../../common/components/toasters'; +import { canEditRuleWithActions } from '../../../../../common/utils/privileges'; +import { Rule } from '../../../../containers/detection_engine/rules'; +import { RulesTableActions } from '../../../../containers/detection_engine/rules/rules_table/rules_table_context'; +import * as i18n from '../translations'; +import { + deleteRulesAction, + duplicateRulesAction, + editRuleAction, + exportRulesAction, +} from './actions'; + +type NavigateToApp = (appId: string, options?: NavigateToAppOptions | undefined) => Promise; + +export type TableColumn = EuiBasicTableColumn | EuiTableActionsColumnType; + +export const getRulesTableActions = ( + dispatchToaster: Dispatch, + navigateToApp: NavigateToApp, + reFetchRules: RulesTableActions['reFetchRules'], + actionsPrivileges: boolean, + setLoadingRules: RulesTableActions['setLoadingRules'] +): Array> => [ + { + type: 'icon', + 'data-test-subj': 'editRuleAction', + description: i18n.EDIT_RULE_SETTINGS, + name: !actionsPrivileges ? ( + + <>{i18n.EDIT_RULE_SETTINGS} + + ) : ( + i18n.EDIT_RULE_SETTINGS + ), + icon: 'controlsHorizontal', + onClick: (rule: Rule) => editRuleAction(rule.id, navigateToApp), + enabled: (rule: Rule) => canEditRuleWithActions(rule, actionsPrivileges), + }, + { + type: 'icon', + 'data-test-subj': 'duplicateRuleAction', + description: i18n.DUPLICATE_RULE, + icon: 'copy', + name: !actionsPrivileges ? ( + + <>{i18n.DUPLICATE_RULE} + + ) : ( + i18n.DUPLICATE_RULE + ), + enabled: (rule: Rule) => canEditRuleWithActions(rule, actionsPrivileges), + onClick: async (rule: Rule) => { + const createdRules = await duplicateRulesAction( + [rule], + [rule.id], + dispatchToaster, + setLoadingRules + ); + if (createdRules?.length) { + editRuleAction(createdRules[0].id, navigateToApp); + } + }, + }, + { + type: 'icon', + 'data-test-subj': 'exportRuleAction', + description: i18n.EXPORT_RULE, + icon: 'exportAction', + name: i18n.EXPORT_RULE, + onClick: (rule: Rule) => exportRulesAction([rule.rule_id], dispatchToaster, setLoadingRules), + enabled: (rule: Rule) => !rule.immutable, + }, + { + type: 'icon', + 'data-test-subj': 'deleteRuleAction', + description: i18n.DELETE_RULE, + icon: 'trash', + name: i18n.DELETE_RULE, + onClick: async (rule: Rule) => { + await deleteRulesAction([rule.id], dispatchToaster, setLoadingRules); + await reFetchRules(); + }, + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx index 5668a4d489c53..c21680242b818 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx @@ -5,71 +5,58 @@ * 2.0. */ -/* eslint-disable complexity */ - import { EuiBasicTable, - EuiLoadingContent, - EuiProgress, EuiConfirmModal, - EuiWindowEvent, EuiEmptyPrompt, + EuiLoadingContent, + EuiProgress, } from '@elastic/eui'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { debounce } from 'lodash/fp'; -import { History } from 'history'; - +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { AllRulesTabs } from '.'; +import { HeaderSection } from '../../../../../common/components/header_section'; +import { Loader } from '../../../../../common/components/loader'; +import { useBoolState } from '../../../../../common/hooks/use_bool_state'; +import { useValueChanged } from '../../../../../common/hooks/use_value_changed'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { PrePackagedRulesPrompt } from '../../../../components/rules/pre_packaged_rules/load_empty_prompt'; import { - useRulesTable, CreatePreBuiltRules, FilterOptions, - RulesSortingFields, Rule, + RulesSortingFields, } from '../../../../containers/detection_engine/rules'; - -import { FormatUrl } from '../../../../../common/components/link_to'; -import { HeaderSection } from '../../../../../common/components/header_section'; -import { useKibana, useUiSetting$ } from '../../../../../common/lib/kibana'; -import { useStateToaster } from '../../../../../common/components/toasters'; -import { Loader } from '../../../../../common/components/loader'; -import { PrePackagedRulesPrompt } from '../../../../components/rules/pre_packaged_rules/load_empty_prompt'; +import { useRulesTableContext } from '../../../../containers/detection_engine/rules/rules_table/rules_table_context'; +import { useAsyncConfirmation } from '../../../../containers/detection_engine/rules/rules_table/use_async_confirmation'; +import { convertRulesFilterToKQL } from '../../../../containers/detection_engine/rules/utils'; import { getPrePackagedRuleStatus } from '../helpers'; import * as i18n from '../translations'; import { EuiBasicTableOnChange } from '../types'; -import { getBatchItems } from './batch_actions'; -import { getRulesColumns, getMonitoringColumns } from './columns'; +import { useBulkActions } from './use_bulk_actions'; +import { useMonitoringColumns, useRulesColumns } from './use_columns'; import { showRulesTable } from './helpers'; import { RulesTableFilters } from './rules_table_filters/rules_table_filters'; -import { useMlCapabilities } from '../../../../../common/components/ml/hooks/use_ml_capabilities'; -import { hasMlAdminPermissions } from '../../../../../../common/machine_learning/has_ml_admin_permissions'; -import { hasMlLicense } from '../../../../../../common/machine_learning/has_ml_license'; -import { isBoolean } from '../../../../../common/utils/privileges'; import { AllRulesUtilityBar } from './utility_bar'; -import { DEFAULT_RULES_TABLE_REFRESH_SETTING } from '../../../../../../common/constants'; -import { AllRulesTabs } from '.'; -import { useValueChanged } from '../../../../../common/hooks/use_value_changed'; -import { convertRulesFilterToKQL } from '../../../../containers/detection_engine/rules/utils'; -import { useBoolState } from '../../../../../common/hooks/use_bool_state'; -import { useAsyncConfirmation } from '../../../../containers/detection_engine/rules/rules_table/use_async_confirmation'; const INITIAL_SORT_FIELD = 'enabled'; interface RulesTableProps { - history: History; - formatUrl: FormatUrl; createPrePackagedRules: CreatePreBuiltRules | null; hasPermissions: boolean; loading: boolean; loadingCreatePrePackagedRules: boolean; - refetchPrePackagedRulesStatus: () => Promise; rulesCustomInstalled: number | null; rulesInstalled: number | null; rulesNotInstalled: number | null; rulesNotUpdated: number | null; - setRefreshRulesData: (refreshRule: () => Promise) => void; selectedTab: AllRulesTabs; + setRefreshRulesData: (refreshRule: () => Promise) => void; } +const NO_ITEMS_MESSAGE = ( + {i18n.NO_RULES}} titleSize="xs" body={i18n.NO_RULES_BODY} /> +); + /** * Table Component for displaying all Rules for a given cluster. Provides the ability to filter * by name, sort by enabled, and perform the following actions: @@ -80,99 +67,49 @@ interface RulesTableProps { */ export const RulesTables = React.memo( ({ - history, - formatUrl, createPrePackagedRules, hasPermissions, loading, loadingCreatePrePackagedRules, - refetchPrePackagedRulesStatus, rulesCustomInstalled, rulesInstalled, rulesNotInstalled, rulesNotUpdated, - setRefreshRulesData, selectedTab, + setRefreshRulesData, }) => { - const docLinks = useKibana().services.docLinks; - const [initLoading, setInitLoading] = useState(true); + const { timelines } = useKibana().services; + const tableRef = useRef(null); + const rulesTableContext = useRulesTableContext(); const { - services: { - application: { - capabilities: { actions }, - }, - timelines, + state: { + rules, + filterOptions, + isActionInProgress, + isAllSelected, + isFetched, + isFetching, + isLoading, + isRefetching, + isRefreshOn, + lastUpdated, + loadingRuleIds, + pagination, + selectedRuleIds, + sortingOptions, }, - } = useKibana(); - - const tableRef = useRef(null); - - const [defaultAutoRefreshSetting] = useUiSetting$<{ - on: boolean; - value: number; - idleTimeout: number; - }>(DEFAULT_RULES_TABLE_REFRESH_SETTING); - - const rulesTable = useRulesTable({ - initialStateOverride: { - isRefreshOn: defaultAutoRefreshSetting.on, + actions: { + reFetchRules, + setFilterOptions, + setIsAllSelected, + setIsRefreshOn, + setPage, + setPerPage, + setSelectedRuleIds, + setSortingOptions, }, - }); - - const { - filterOptions, - loadingRuleIds, - loadingRulesAction, - pagination, - rules, - selectedRuleIds, - lastUpdated, - showIdleModal, - isRefreshOn, - isRefreshing, - isAllSelected, - } = rulesTable.state; - - const { - dispatch, - updateOptions, - setShowIdleModal, - setLastRefreshDate, - setAutoRefreshOn, - setIsRefreshing, - reFetchRules, - } = rulesTable; - - const [, dispatchToaster] = useStateToaster(); - const mlCapabilities = useMlCapabilities(); - const { navigateToApp } = useKibana().services.application; - - // TODO: Refactor license check + hasMlAdminPermissions to common check - const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities); - - const isLoadingRules = loadingRulesAction === 'load'; - const isLoadingAnActionOnRule = useMemo(() => { - if ( - loadingRuleIds.length > 0 && - (loadingRulesAction === 'disable' || loadingRulesAction === 'enable') - ) { - return false; - } else if (loadingRuleIds.length > 0) { - return true; - } - return false; - }, [loadingRuleIds, loadingRulesAction]); - - const sorting = useMemo( - () => ({ - sort: { - field: filterOptions.sortField, - direction: filterOptions.sortOrder, - }, - }), - [filterOptions] - ); + } = rulesTableContext; const prePackagedRuleStatus = getPrePackagedRuleStatus( rulesInstalled, @@ -180,11 +117,6 @@ export const RulesTables = React.memo( rulesNotUpdated ); - const hasActionsPrivileges = useMemo( - () => (isBoolean(actions.show) ? actions.show : true), - [actions] - ); - const [isDeleteConfirmationVisible, showDeleteConfirmation, hideDeleteConfirmation] = useBoolState(); @@ -196,41 +128,11 @@ export const RulesTables = React.memo( const selectedItemsCount = isAllSelected ? pagination.total : selectedRuleIds.length; const hasPagination = pagination.total > pagination.perPage; - const getBatchItemsPopoverContent = useCallback( - (closePopover: () => void): JSX.Element[] => { - return getBatchItems({ - isAllSelected, - closePopover, - dispatch, - dispatchToaster, - hasMlPermissions, - hasActionsPrivileges, - loadingRuleIds, - selectedRuleIds, - reFetchRules, - refetchPrePackagedRulesStatus, - rules, - filterQuery: convertRulesFilterToKQL(filterOptions), - confirmDeletion, - selectedItemsCount, - }); - }, - [ - isAllSelected, - dispatch, - dispatchToaster, - hasMlPermissions, - loadingRuleIds, - reFetchRules, - refetchPrePackagedRulesStatus, - rules, - selectedRuleIds, - hasActionsPrivileges, - filterOptions, - confirmDeletion, - selectedItemsCount, - ] - ); + const getBatchItemsPopoverContent = useBulkActions({ + filterQuery: convertRulesFilterToKQL(filterOptions), + confirmDeletion, + selectedItemsCount, + }); const paginationMemo = useMemo( () => ({ @@ -244,84 +146,47 @@ export const RulesTables = React.memo( const onFilterChangedCallback = useCallback( (newFilter: Partial) => { - updateOptions(newFilter, { page: 1 }); + setFilterOptions((currentFilter) => ({ ...currentFilter, ...newFilter })); + setPage(1); + setSelectedRuleIds([]); + setIsAllSelected(false); }, - [updateOptions] + [setFilterOptions, setIsAllSelected, setPage, setSelectedRuleIds] ); const tableOnChangeCallback = useCallback( ({ page, sort }: EuiBasicTableOnChange) => { - updateOptions( - { - sortField: (sort?.field as RulesSortingFields) ?? INITIAL_SORT_FIELD, // Narrowing EuiBasicTable sorting types - sortOrder: sort?.direction ?? 'desc', - }, - { page: page.index + 1, perPage: page.size } - ); - setLastRefreshDate(); + setSortingOptions({ + field: (sort?.field as RulesSortingFields) ?? INITIAL_SORT_FIELD, // Narrowing EuiBasicTable sorting types + order: sort?.direction ?? 'desc', + }); + setPage(page.index + 1); + setPerPage(page.size); }, - [updateOptions, setLastRefreshDate] + [setPage, setPerPage, setSortingOptions] ); - const [rulesColumns, monitoringColumns] = useMemo(() => { - const props = { - dispatch, - formatUrl, - hasMlPermissions, - hasPermissions, - loadingRuleIds: - loadingRulesAction != null && - (loadingRulesAction === 'enable' || loadingRulesAction === 'disable') - ? loadingRuleIds - : [], - navigateToApp, - hasReadActionsPrivileges: hasActionsPrivileges, - dispatchToaster, - history, - reFetchRules, - refetchPrePackagedRulesStatus, - docLinks, - }; - return [getRulesColumns(props), getMonitoringColumns(props)]; - }, [ - dispatch, - dispatchToaster, - formatUrl, - refetchPrePackagedRulesStatus, - hasActionsPrivileges, - hasPermissions, - hasMlPermissions, - history, - loadingRuleIds, - loadingRulesAction, - navigateToApp, - reFetchRules, - docLinks, - ]); + const rulesColumns = useRulesColumns({ hasPermissions }); + const monitoringColumns = useMonitoringColumns({ hasPermissions }); useEffect(() => { - setRefreshRulesData(reFetchRules); + setRefreshRulesData(async () => { + await reFetchRules(); + }); }, [reFetchRules, setRefreshRulesData]); - useEffect(() => { - if (initLoading && !loading && !isLoadingRules) { - setInitLoading(false); - } - }, [initLoading, loading, isLoadingRules]); - const handleCreatePrePackagedRules = useCallback(async () => { if (createPrePackagedRules != null) { await createPrePackagedRules(); await reFetchRules(); - await refetchPrePackagedRulesStatus(); } - }, [createPrePackagedRules, reFetchRules, refetchPrePackagedRulesStatus]); + }, [createPrePackagedRules, reFetchRules]); const isSelectAllCalled = useRef(false); // Synchronize selectedRuleIds with EuiBasicTable's selected rows useValueChanged((ruleIds) => { - if (tableRef.current?.changeSelection != null) { + if (tableRef.current != null) { tableRef.current.setSelection(rules.filter((rule) => ruleIds.includes(rule.id))); } }, selectedRuleIds); @@ -342,96 +207,33 @@ export const RulesTables = React.memo( if (isSelectAllCalled.current) { isSelectAllCalled.current = false; } else { - dispatch({ type: 'selectedRuleIds', ids: selected.map(({ id }) => id) }); + setSelectedRuleIds(selected.map(({ id }) => id)); + setIsAllSelected(false); } }, }), - [loadingRuleIds, dispatch] + [loadingRuleIds, setIsAllSelected, setSelectedRuleIds] ); const toggleSelectAll = useCallback(() => { isSelectAllCalled.current = true; - dispatch({ type: 'setIsAllSelected', isAllSelected: !isAllSelected }); - }, [dispatch, isAllSelected]); - - const refreshTable = useCallback( - async (mode: 'auto' | 'manual' = 'manual'): Promise => { - if (isLoadingAnActionOnRule) { - return; - } - - const isAutoRefresh = mode === 'auto'; - if (isAutoRefresh) { - setIsRefreshing(true); - } - - await reFetchRules(); - await refetchPrePackagedRulesStatus(); - setLastRefreshDate(); - - if (isAutoRefresh) { - setIsRefreshing(false); - } - }, - [ - isLoadingAnActionOnRule, - setIsRefreshing, - reFetchRules, - refetchPrePackagedRulesStatus, - setLastRefreshDate, - ] - ); - - const handleAutoRefresh = useCallback(async (): Promise => { - await refreshTable('auto'); - }, [refreshTable]); - - const handleManualRefresh = useCallback(async (): Promise => { - await refreshTable(); - }, [refreshTable]); - - const handleResetIdleTimer = useCallback((): void => { - if (isRefreshOn) { - setShowIdleModal(true); - setAutoRefreshOn(false); - } - }, [setShowIdleModal, setAutoRefreshOn, isRefreshOn]); - - const debounceResetIdleTimer = useMemo(() => { - return debounce(defaultAutoRefreshSetting.idleTimeout, handleResetIdleTimer); - }, [handleResetIdleTimer, defaultAutoRefreshSetting.idleTimeout]); - - useEffect(() => { - const interval = setInterval(() => { - if (isRefreshOn) { - handleAutoRefresh(); - } - }, defaultAutoRefreshSetting.value); - - return () => { - clearInterval(interval); - }; - }, [isRefreshOn, handleAutoRefresh, defaultAutoRefreshSetting.value]); - - const handleIdleModalContinue = useCallback((): void => { - setShowIdleModal(false); - handleAutoRefresh(); - setAutoRefreshOn(true); - }, [setShowIdleModal, setAutoRefreshOn, handleAutoRefresh]); + setIsAllSelected(!isAllSelected); + setSelectedRuleIds(!isAllSelected ? rules.map(({ id }) => id) : []); + }, [rules, isAllSelected, setIsAllSelected, setSelectedRuleIds]); const handleAutoRefreshSwitch = useCallback( (refreshOn: boolean) => { if (refreshOn) { - handleAutoRefresh(); + reFetchRules(); } - setAutoRefreshOn(refreshOn); + setIsRefreshOn(refreshOn); }, - [setAutoRefreshOn, handleAutoRefresh] + [setIsRefreshOn, reFetchRules] ); const shouldShowRulesTable = useMemo( - (): boolean => showRulesTable({ rulesCustomInstalled, rulesInstalled }) && !initLoading, - [initLoading, rulesCustomInstalled, rulesInstalled] + (): boolean => showRulesTable({ rulesCustomInstalled, rulesInstalled }) && !isLoading, + [isLoading, rulesCustomInstalled, rulesInstalled] ); const shouldShowPrepackagedRulesPrompt = useMemo( @@ -439,8 +241,8 @@ export const RulesTables = React.memo( rulesCustomInstalled != null && rulesCustomInstalled === 0 && prePackagedRuleStatus === 'ruleNotInstalled' && - !initLoading, - [initLoading, prePackagedRuleStatus, rulesCustomInstalled] + !isLoading, + [isLoading, prePackagedRuleStatus, rulesCustomInstalled] ); const tableProps = @@ -453,13 +255,7 @@ export const RulesTables = React.memo( return ( <> - - - - - - - {!initLoading && (loading || isLoadingRules || isLoadingAnActionOnRule) && isRefreshing && ( + {isFetched && isRefetching && ( ( color="accent" /> )} + {((!isFetched && isRefetching) || isActionInProgress) && ( + + )} @@ -485,9 +284,6 @@ export const RulesTables = React.memo( /> )} - {!initLoading && - (loading || isLoadingRules || isLoadingAnActionOnRule) && - !isRefreshing && } {shouldShowPrepackagedRulesPrompt && ( ( userHasPermissions={hasPermissions} /> )} - {initLoading && ( + {isLoading && ( )} - {showIdleModal && ( - -

{i18n.REFRESH_PROMPT_BODY}

-
- )} {isDeleteConfirmationVisible && ( ( paginationTotal={pagination.total ?? 0} numberSelectedItems={selectedItemsCount} onGetBatchItemsPopoverContent={getBatchItemsPopoverContent} - onRefresh={handleManualRefresh} + onRefresh={reFetchRules} isAutoRefreshOn={isRefreshOn} onRefreshSwitch={handleAutoRefreshSwitch} isAllSelected={isAllSelected} @@ -543,18 +327,18 @@ export const RulesTables = React.memo( itemId="id" items={rules} isSelectable={hasPermissions} - noItemsMessage={ - {i18n.NO_RULES}} - titleSize="xs" - body={i18n.NO_RULES_BODY} - /> - } + noItemsMessage={NO_ITEMS_MESSAGE} onChange={tableOnChangeCallback} pagination={paginationMemo} ref={tableRef} selection={euiBasicTableSelectionProps} - sorting={sorting} + sorting={{ + sort: { + // EuiBasicTable has incorrect `sort.field` types which accept only `keyof Item` and reject fields in dot notation + field: sortingOptions.field as keyof Rule, + direction: sortingOptions.order, + }, + }} {...tableProps} /> diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_bulk_actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_bulk_actions.tsx new file mode 100644 index 0000000000000..95d74ab48dddd --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_bulk_actions.tsx @@ -0,0 +1,266 @@ +/* + * 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 { EuiContextMenuItem, EuiToolTip } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { BulkAction } from '../../../../../../common/detection_engine/schemas/common/schemas'; +import { isMlRule } from '../../../../../../common/machine_learning/helpers'; +import { displayWarningToast, useStateToaster } from '../../../../../common/components/toasters'; +import { canEditRuleWithActions } from '../../../../../common/utils/privileges'; +import { useRulesTableContext } from '../../../../containers/detection_engine/rules/rules_table/rules_table_context'; +import * as detectionI18n from '../../translations'; +import * as i18n from '../translations'; +import { + deleteRulesAction, + duplicateRulesAction, + enableRulesAction, + exportRulesAction, + rulesBulkActionByQuery, +} from './actions'; +import { useHasActionsPrivileges } from './use_has_actions_privileges'; +import { useHasMlPermissions } from './use_has_ml_permissions'; + +interface UseBulkActionsArgs { + filterQuery: string; + confirmDeletion: () => Promise; + selectedItemsCount: number; +} + +export const useBulkActions = ({ + filterQuery, + confirmDeletion, + selectedItemsCount, +}: UseBulkActionsArgs) => { + const hasMlPermissions = useHasMlPermissions(); + const rulesTableContext = useRulesTableContext(); + const [, dispatchToaster] = useStateToaster(); + const hasActionsPrivileges = useHasActionsPrivileges(); + + const { + state: { isAllSelected, rules, loadingRuleIds, selectedRuleIds }, + actions: { reFetchRules, setLoadingRules, updateRules }, + } = rulesTableContext; + + return useCallback( + (closePopover: () => void): JSX.Element[] => { + const selectedRules = rules.filter(({ id }) => selectedRuleIds.includes(id)); + + const containsEnabled = selectedRules.some(({ enabled }) => enabled); + const containsDisabled = selectedRules.some(({ enabled }) => !enabled); + const containsLoading = selectedRuleIds.some((id) => loadingRuleIds.includes(id)); + const containsImmutable = selectedRules.some(({ immutable }) => immutable); + + const missingActionPrivileges = + !hasActionsPrivileges && + selectedRules.some((rule) => !canEditRuleWithActions(rule, hasActionsPrivileges)); + + const handleActivateAction = async () => { + closePopover(); + const deactivatedRules = selectedRules.filter(({ enabled }) => !enabled); + const deactivatedRulesNoML = deactivatedRules.filter(({ type }) => !isMlRule(type)); + + const mlRuleCount = deactivatedRules.length - deactivatedRulesNoML.length; + if (!hasMlPermissions && mlRuleCount > 0) { + displayWarningToast(detectionI18n.ML_RULES_UNAVAILABLE(mlRuleCount), dispatchToaster); + } + + const ruleIds = hasMlPermissions + ? deactivatedRules.map(({ id }) => id) + : deactivatedRulesNoML.map(({ id }) => id); + + if (isAllSelected) { + await rulesBulkActionByQuery( + ruleIds, + selectedItemsCount, + filterQuery, + BulkAction.enable, + dispatchToaster, + setLoadingRules + ); + await reFetchRules(); + } else { + await enableRulesAction(ruleIds, true, dispatchToaster, setLoadingRules, updateRules); + } + }; + + const handleDeactivateActions = async () => { + closePopover(); + const activatedIds = selectedRules.filter(({ enabled }) => enabled).map(({ id }) => id); + if (isAllSelected) { + await rulesBulkActionByQuery( + activatedIds, + selectedItemsCount, + filterQuery, + BulkAction.disable, + dispatchToaster, + setLoadingRules + ); + await reFetchRules(); + } else { + await enableRulesAction( + activatedIds, + false, + dispatchToaster, + setLoadingRules, + updateRules + ); + } + }; + + const handleDuplicateAction = async () => { + closePopover(); + if (isAllSelected) { + await rulesBulkActionByQuery( + selectedRuleIds, + selectedItemsCount, + filterQuery, + BulkAction.duplicate, + dispatchToaster, + setLoadingRules + ); + await reFetchRules(); + } else { + await duplicateRulesAction( + selectedRules, + selectedRuleIds, + dispatchToaster, + setLoadingRules + ); + } + await reFetchRules(); + }; + + const handleDeleteAction = async () => { + closePopover(); + if (isAllSelected) { + if ((await confirmDeletion()) === false) { + // User has cancelled deletion + return; + } + + await rulesBulkActionByQuery( + selectedRuleIds, + selectedItemsCount, + filterQuery, + BulkAction.delete, + dispatchToaster, + setLoadingRules + ); + } else { + await deleteRulesAction(selectedRuleIds, dispatchToaster, setLoadingRules); + } + await reFetchRules(); + }; + + const handleExportAction = async () => { + closePopover(); + if (isAllSelected) { + await rulesBulkActionByQuery( + selectedRuleIds, + selectedItemsCount, + filterQuery, + BulkAction.export, + dispatchToaster, + setLoadingRules + ); + } else { + await exportRulesAction( + selectedRules.map((r) => r.rule_id), + dispatchToaster, + setLoadingRules + ); + } + }; + + return [ + + + <>{i18n.BATCH_ACTION_ACTIVATE_SELECTED} + + , + + + <>{i18n.BATCH_ACTION_DEACTIVATE_SELECTED} + + , + + {i18n.BATCH_ACTION_EXPORT_SELECTED} + , + + + + <>{i18n.BATCH_ACTION_DUPLICATE_SELECTED} + + , + + {i18n.BATCH_ACTION_DELETE_SELECTED} + , + ]; + }, + [ + rules, + selectedRuleIds, + hasActionsPrivileges, + isAllSelected, + loadingRuleIds, + hasMlPermissions, + dispatchToaster, + selectedItemsCount, + filterQuery, + setLoadingRules, + reFetchRules, + updateRules, + confirmDeletion, + ] + ); +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx new file mode 100644 index 0000000000000..fa3e02c183516 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx @@ -0,0 +1,408 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiBadge, + EuiBasicTableColumn, + EuiLink, + EuiTableActionsColumnType, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useMemo } from 'react'; +import { + APP_UI_ID, + DEFAULT_RELATIVE_DATE_THRESHOLD, + SecurityPageName, +} from '../../../../../../common/constants'; +import { isMlRule } from '../../../../../../common/machine_learning/helpers'; +import { getEmptyTagValue } from '../../../../../common/components/empty_value'; +import { FormattedRelativePreferenceDate } from '../../../../../common/components/formatted_date'; +import { LinkAnchor } from '../../../../../common/components/links'; +import { useFormatUrl } from '../../../../../common/components/link_to'; +import { getRuleDetailsUrl } from '../../../../../common/components/link_to/redirect_to_detection_engine'; +import { PopoverItems } from '../../../../../common/components/popover_items'; +import { useStateToaster } from '../../../../../common/components/toasters'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { canEditRuleWithActions, getToolTipContent } from '../../../../../common/utils/privileges'; +import { RuleSwitch } from '../../../../components/rules/rule_switch'; +import { SeverityBadge } from '../../../../components/rules/severity_badge'; +import { Rule } from '../../../../containers/detection_engine/rules'; +import { useRulesTableContext } from '../../../../containers/detection_engine/rules/rules_table/rules_table_context'; +import * as i18n from '../translations'; +import { PopoverTooltip } from './popover_tooltip'; +import { TableHeaderTooltipCell } from './table_header_tooltip_cell'; +import { useHasActionsPrivileges } from './use_has_actions_privileges'; +import { useHasMlPermissions } from './use_has_ml_permissions'; +import { getRulesTableActions } from './rules_table_actions'; +import { RuleStatusBadge } from '../../../../components/rules/rule_execution_status'; +import { + DurationMetric, + RuleExecutionSummary, +} from '../../../../../../common/detection_engine/schemas/common'; + +export type TableColumn = EuiBasicTableColumn | EuiTableActionsColumnType; + +interface ColumnsProps { + hasPermissions: boolean; +} + +const useEnabledColumn = ({ hasPermissions }: ColumnsProps): TableColumn => { + const hasMlPermissions = useHasMlPermissions(); + const hasActionsPrivileges = useHasActionsPrivileges(); + const { loadingRulesAction, loadingRuleIds } = useRulesTableContext().state; + + const loadingIds = useMemo( + () => + loadingRulesAction === 'enable' || loadingRulesAction === 'disable' ? loadingRuleIds : [], + [loadingRuleIds, loadingRulesAction] + ); + + return useMemo( + () => ({ + field: 'enabled', + name: i18n.COLUMN_ACTIVATE, + render: (_, rule: Rule) => ( + + + + ), + width: '95px', + sortable: true, + }), + [hasActionsPrivileges, hasMlPermissions, hasPermissions, loadingIds] + ); +}; + +const useRuleNameColumn = (): TableColumn => { + const { formatUrl } = useFormatUrl(SecurityPageName.rules); + const { navigateToApp } = useKibana().services.application; + + return useMemo( + () => ({ + field: 'name', + name: i18n.COLUMN_RULE, + render: (value: Rule['name'], item: Rule) => ( + + void }) => { + ev.preventDefault(); + navigateToApp(APP_UI_ID, { + deepLinkId: SecurityPageName.rules, + path: getRuleDetailsUrl(item.id), + }); + }} + href={formatUrl(getRuleDetailsUrl(item.id))} + > + {value} + + + ), + sortable: true, + truncateText: true, + width: '38%', + }), + [formatUrl, navigateToApp] + ); +}; + +const TAGS_COLUMN: TableColumn = { + field: 'tags', + name: null, + align: 'center', + render: (tags: Rule['tags']) => { + if (tags.length === 0) { + return null; + } + + const renderItem = (tag: string, i: number) => ( + + {tag} + + ); + return ( + + ); + }, + width: '65px', + truncateText: true, +}; + +const useActionsColumn = (): EuiTableActionsColumnType => { + const { navigateToApp } = useKibana().services.application; + const hasActionsPrivileges = useHasActionsPrivileges(); + const [, dispatchToaster] = useStateToaster(); + const { reFetchRules, setLoadingRules } = useRulesTableContext().actions; + + return useMemo( + () => ({ + actions: getRulesTableActions( + dispatchToaster, + navigateToApp, + reFetchRules, + hasActionsPrivileges, + setLoadingRules + ), + width: '40px', + }), + [dispatchToaster, hasActionsPrivileges, navigateToApp, reFetchRules, setLoadingRules] + ); +}; + +export const useRulesColumns = ({ hasPermissions }: ColumnsProps): TableColumn[] => { + const actionsColumn = useActionsColumn(); + const enabledColumn = useEnabledColumn({ hasPermissions }); + const ruleNameColumn = useRuleNameColumn(); + const { isInMemorySorting } = useRulesTableContext().state; + + return useMemo( + () => [ + ruleNameColumn, + TAGS_COLUMN, + { + field: 'risk_score', + name: i18n.COLUMN_RISK_SCORE, + render: (value: Rule['risk_score']) => ( + + {value} + + ), + sortable: !!isInMemorySorting, + truncateText: true, + width: '85px', + }, + { + field: 'severity', + name: i18n.COLUMN_SEVERITY, + render: (value: Rule['severity']) => , + sortable: !!isInMemorySorting, + truncateText: true, + width: '12%', + }, + { + field: 'execution_summary.last_execution.date', + name: i18n.COLUMN_LAST_COMPLETE_RUN, + render: (value: RuleExecutionSummary['last_execution']['date'] | undefined) => { + return value == null ? ( + getEmptyTagValue() + ) : ( + + ); + }, + sortable: !!isInMemorySorting, + truncateText: true, + width: '16%', + }, + { + field: 'execution_summary.last_execution.status', + name: i18n.COLUMN_LAST_RESPONSE, + render: (value: RuleExecutionSummary['last_execution']['status'] | undefined) => ( + + ), + sortable: !!isInMemorySorting, + truncateText: true, + width: '16%', + }, + { + field: 'updated_at', + name: i18n.COLUMN_LAST_UPDATE, + render: (value: Rule['updated_at']) => { + return value == null ? ( + getEmptyTagValue() + ) : ( + + ); + }, + sortable: true, + width: '18%', + truncateText: true, + }, + { + field: 'version', + name: i18n.COLUMN_VERSION, + render: (value: Rule['version']) => { + return value == null ? ( + getEmptyTagValue() + ) : ( + + {value} + + ); + }, + sortable: !!isInMemorySorting, + truncateText: true, + width: '65px', + }, + enabledColumn, + ...(hasPermissions ? [actionsColumn] : []), + ], + [actionsColumn, enabledColumn, hasPermissions, isInMemorySorting, ruleNameColumn] + ); +}; + +export const useMonitoringColumns = ({ hasPermissions }: ColumnsProps): TableColumn[] => { + const docLinks = useKibana().services.docLinks; + const actionsColumn = useActionsColumn(); + const enabledColumn = useEnabledColumn({ hasPermissions }); + const ruleNameColumn = useRuleNameColumn(); + const { isInMemorySorting } = useRulesTableContext().state; + + return useMemo( + () => [ + { + ...ruleNameColumn, + width: '28%', + }, + TAGS_COLUMN, + { + field: 'execution_summary.last_execution.metrics.total_indexing_duration_ms', + name: ( + + ), + render: (value: DurationMetric | undefined) => ( + + {value != null ? value.toFixed() : getEmptyTagValue()} + + ), + sortable: !!isInMemorySorting, + truncateText: true, + width: '16%', + }, + { + field: 'execution_summary.last_execution.metrics.total_search_duration_ms', + name: ( + + ), + render: (value: DurationMetric | undefined) => ( + + {value != null ? value.toFixed() : getEmptyTagValue()} + + ), + sortable: !!isInMemorySorting, + truncateText: true, + width: '14%', + }, + { + field: 'execution_summary.last_execution.metrics.execution_gap_duration_s', + name: ( + + + +

+ + {'see documentation'} + + ), + }} + /> +

+
+
+
+ } + /> + ), + render: (value: DurationMetric | undefined) => ( + + {value != null ? value.toFixed() : getEmptyTagValue()} + + ), + sortable: !!isInMemorySorting, + truncateText: true, + width: '14%', + }, + { + field: 'execution_summary.last_execution.status', + name: i18n.COLUMN_LAST_RESPONSE, + render: (value: RuleExecutionSummary['last_execution']['status'] | undefined) => ( + + ), + sortable: !!isInMemorySorting, + truncateText: true, + width: '12%', + }, + { + field: 'execution_summary.last_execution.date', + name: i18n.COLUMN_LAST_COMPLETE_RUN, + render: (value: RuleExecutionSummary['last_execution']['date'] | undefined) => { + return value == null ? ( + getEmptyTagValue() + ) : ( + + ); + }, + sortable: !!isInMemorySorting, + truncateText: true, + width: '16%', + }, + enabledColumn, + ...(hasPermissions ? [actionsColumn] : []), + ], + [ + actionsColumn, + docLinks.links.siem.troubleshootGaps, + enabledColumn, + hasPermissions, + isInMemorySorting, + ruleNameColumn, + ] + ); +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_has_actions_privileges.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_has_actions_privileges.ts new file mode 100644 index 0000000000000..c2acbed3158f1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_has_actions_privileges.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useKibana } from '../../../../../common/lib/kibana'; +import { isBoolean } from '../../../../../common/utils/privileges'; + +export const useHasActionsPrivileges = () => { + const { + services: { + application: { + capabilities: { actions }, + }, + }, + } = useKibana(); + return isBoolean(actions.show) ? actions.show : true; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_has_ml_permissions.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_has_ml_permissions.ts new file mode 100644 index 0000000000000..c941ba7183b86 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_has_ml_permissions.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { hasMlAdminPermissions } from '../../../../../../common/machine_learning/has_ml_admin_permissions'; +import { hasMlLicense } from '../../../../../../common/machine_learning/has_ml_license'; +import { useMlCapabilities } from '../../../../../common/components/ml/hooks/use_ml_capabilities'; + +export const useHasMlPermissions = () => { + const mlCapabilities = useMlCapabilities(); + + // TODO: Refactor license check + hasMlAdminPermissions to common check + return hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities); +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx index 2e22037ce35b8..2e53091dc97df 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx @@ -23,7 +23,7 @@ interface AllRulesUtilityBarProps { isAutoRefreshOn?: boolean; numberSelectedItems: number; onGetBatchItemsPopoverContent?: (closePopover: () => void) => JSX.Element[]; - onRefresh?: (refreshRule: boolean) => void; + onRefresh?: () => void; onRefreshSwitch?: (checked: boolean) => void; onToggleSelectAll?: () => void; paginationTotal: number; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx index 6967324d3ce45..e9d8749ee5601 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx @@ -34,7 +34,6 @@ import { StepDefineRule } from '../../../../components/rules/step_define_rule'; import { StepAboutRule } from '../../../../components/rules/step_about_rule'; import { StepScheduleRule } from '../../../../components/rules/step_schedule_rule'; import { StepRuleActions } from '../../../../components/rules/step_rule_actions'; -import { DetectionEngineHeaderPage } from '../../../../components/detection_engine_header_page'; import * as RuleI18n from '../translations'; import { redirectToDetections, @@ -49,6 +48,7 @@ import { SecurityPageName } from '../../../../../app/types'; import { ruleStepsOrder } from '../utils'; import { APP_UI_ID } from '../../../../../../common/constants'; import { useKibana } from '../../../../../common/lib/kibana'; +import { HeaderPage } from '../../../../../common/components/header_page'; const formHookNoop = async (): Promise => undefined; @@ -288,7 +288,7 @@ const CreateRulePageComponent: React.FC = () => { - = ({ - = ({
- + {ruleError} {getLegacyUrlConflictCallout} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx index 89f1cd7b26144..d73049bd01cbc 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx @@ -29,7 +29,6 @@ import { import { displaySuccessToast, useStateToaster } from '../../../../../common/components/toasters'; import { SpyRoute } from '../../../../../common/utils/route/spy_routes'; import { useUserData } from '../../../../components/user_info'; -import { DetectionEngineHeaderPage } from '../../../../components/detection_engine_header_page'; import { StepPanel } from '../../../../components/rules/step_panel'; import { StepAboutRule } from '../../../../components/rules/step_about_rule'; import { StepDefineRule } from '../../../../components/rules/step_define_rule'; @@ -57,6 +56,7 @@ import { SecurityPageName } from '../../../../../app/types'; import { ruleStepsOrder } from '../utils'; import { useKibana } from '../../../../../common/lib/kibana'; import { APP_UI_ID } from '../../../../../../common/constants'; +import { HeaderPage } from '../../../../../common/components/header_page'; const formHookNoop = async (): Promise => undefined; @@ -351,7 +351,7 @@ const EditRulePageComponent: FC = () => { - { }; }); +jest.mock('../../../containers/detection_engine/rules/rules_table/rules_table_context'); jest.mock('../../../containers/detection_engine/lists/use_lists_config'); jest.mock('../../../../common/components/link_to'); jest.mock('../../../components/user_info'); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx index ab9797e1c2f79..39956dabb92b6 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx @@ -14,7 +14,6 @@ import { getDetectionEngineUrl, getCreateRuleUrl, } from '../../../../common/components/link_to/redirect_to_detection_engine'; -import { DetectionEngineHeaderPage } from '../../../components/detection_engine_header_page'; import { SecuritySolutionPageWrapper } from '../../../../common/components/page_wrapper'; import { SpyRoute } from '../../../../common/utils/route/spy_routes'; @@ -29,6 +28,7 @@ import { redirectToDetections, userHasPermissions, } from './helpers'; +import { RulesPageHeader } from './rules_page_header'; import * as i18n from './translations'; import { SecurityPageName } from '../../../../app/types'; import { LinkButton } from '../../../../common/components/links'; @@ -38,6 +38,7 @@ import { MlJobCompatibilityCallout } from '../../../components/callouts/ml_job_c import { MissingPrivilegesCallOut } from '../../../components/callouts/missing_privileges_callout'; import { APP_UI_ID } from '../../../../../common/constants'; import { useKibana } from '../../../../common/lib/kibana'; +import { RulesTableContextProvider } from '../../../containers/detection_engine/rules/rules_table/rules_table_context'; type Func = () => Promise; @@ -163,6 +164,11 @@ const RulesPageComponent: React.FC = () => { return null; } + const totalRules = + rulesInstalled != null && rulesCustomInstalled != null + ? rulesInstalled + rulesCustomInstalled + : null; + return ( <> @@ -188,77 +194,81 @@ const RulesPageComponent: React.FC = () => { showExceptionsCheckBox showCheckBox /> - - - - {loadPrebuiltRulesAndTemplatesButton && ( - {loadPrebuiltRulesAndTemplatesButton} - )} - {reloadPrebuiltRulesAndTemplatesButton && ( - {reloadPrebuiltRulesAndTemplatesButton} - )} - - + + + + + {loadPrebuiltRulesAndTemplatesButton && ( + {loadPrebuiltRulesAndTemplatesButton} + )} + {reloadPrebuiltRulesAndTemplatesButton && ( + {reloadPrebuiltRulesAndTemplatesButton} + )} + + + setShowValueListsModal(true)} + > + {i18n.UPLOAD_VALUE_LISTS} + + + + setShowValueListsModal(true)} + isDisabled={!userHasPermissions(canUserCRUD) || loading} + onClick={() => { + setShowImportModal(true); + }} > - {i18n.UPLOAD_VALUE_LISTS} + {i18n.IMPORT_RULE} - - - - { - setShowImportModal(true); - }} - > - {i18n.IMPORT_RULE} - - - - - {i18n.ADD_NEW_RULE} - - - - - {(prePackagedRuleStatus === 'ruleNeedUpdate' || - prePackagedTimelineStatus === 'timelineNeedUpdate') && ( - + + + {i18n.ADD_NEW_RULE} + + + + + {(prePackagedRuleStatus === 'ruleNeedUpdate' || + prePackagedTimelineStatus === 'timelineNeedUpdate') && ( + + )} + - )} - - + + diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/rules_page_header.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/rules_page_header.tsx new file mode 100644 index 0000000000000..ed6a1b44c2791 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/rules_page_header.tsx @@ -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 React from 'react'; +import { HeaderPage } from '../../../../common/components/header_page'; +import { useRulesTableContext } from '../../../containers/detection_engine/rules/rules_table/rules_table_context'; + +import * as i18n from './translations'; + +interface RulesPageHeaderProps { + children: React.ReactNode; +} + +export const RulesPageHeader = ({ children }: RulesPageHeaderProps) => { + const { isInMemorySorting } = useRulesTableContext().state; + + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index cf25bd6a72a8c..bd9fbc7c66cd3 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -52,6 +52,36 @@ export const PAGE_TITLE = i18n.translate('xpack.securitySolution.detectionEngine defaultMessage: 'Rules', }); +export const EXPERIMENTAL_ON = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.experimentalOn', + { + defaultMessage: 'Experimental: On', + } +); + +export const EXPERIMENTAL_ON_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.experimentalOnDescription', + { + defaultMessage: + 'The experimental rules table view allows for advanced sorting and filtering capabilities. If you experience performance issues when working with the table, you can turn the experimental view off in Security Solution Advanced Settings.', + } +); + +export const EXPERIMENTAL_OFF = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.experimentalOff', + { + defaultMessage: 'Experimental: Off', + } +); + +export const EXPERIMENTAL_OFF_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.experimentalOffDescription', + { + defaultMessage: + "The experimental rules table view allows for advanced sorting and filtering capabilities. It is turned off because you have more than 3000 rules. If you'd like to turn it on, you can do that in Security Solutions Advanced Settings.", + } +); + export const ADD_PAGE_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.addPageTitle', { diff --git a/x-pack/plugins/security_solution/server/ui_settings.ts b/x-pack/plugins/security_solution/server/ui_settings.ts index 3a9711a872ef3..0a50d44c47fde 100644 --- a/x-pack/plugins/security_solution/server/ui_settings.ts +++ b/x-pack/plugins/security_solution/server/ui_settings.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; -import { CoreSetup } from '../../../../src/core/server'; +import { CoreSetup, UiSettingsParams } from '../../../../src/core/server'; import { APP_ID, DEFAULT_ANOMALY_SCORE, @@ -20,7 +20,6 @@ import { DEFAULT_INDEX_PATTERN_EXPERIMENTAL, DEFAULT_INTERVAL_PAUSE, DEFAULT_INTERVAL_VALUE, - DEFAULT_RULE_REFRESH_IDLE_VALUE, DEFAULT_RULE_REFRESH_INTERVAL_ON, DEFAULT_RULE_REFRESH_INTERVAL_VALUE, DEFAULT_RULES_TABLE_REFRESH_SETTING, @@ -34,15 +33,31 @@ import { IP_REPUTATION_LINKS_SETTING_DEFAULT, NEWS_FEED_URL_SETTING, NEWS_FEED_URL_SETTING_DEFAULT, + RULES_TABLE_ADVANCED_FILTERING_THRESHOLD, + DEFAULT_RULES_TABLE_IN_MEMORY_THRESHOLD, } from '../common/constants'; import { transformConfigSchema } from '../common/transforms/types'; import { ExperimentalFeatures } from '../common/experimental_features'; +type SettingsConfig = Record>; + +/** + * This helper is used to preserve settings order in the UI + * + * @param settings - UI settings config + * @returns Settings config with the order field added + */ +const orderSettings = (settings: SettingsConfig): SettingsConfig => { + return Object.fromEntries( + Object.entries(settings).map(([id, setting], index) => [id, { ...setting, order: index }]) + ); +}; + export const initUiSettings = ( uiSettings: CoreSetup['uiSettings'], experimentalFeatures: ExperimentalFeatures ) => { - uiSettings.register({ + const securityUiSettings: Record> = { [DEFAULT_APP_REFRESH_INTERVAL]: { type: 'json', name: i18n.translate('xpack.securitySolution.uiSettings.defaultRefreshIntervalLabel', { @@ -163,17 +178,31 @@ export const initUiSettings = ( type: 'json', value: `{ "on": ${DEFAULT_RULE_REFRESH_INTERVAL_ON}, - "value": ${DEFAULT_RULE_REFRESH_INTERVAL_VALUE}, - "idleTimeout": ${DEFAULT_RULE_REFRESH_IDLE_VALUE} + "value": ${DEFAULT_RULE_REFRESH_INTERVAL_VALUE} }`, category: [APP_ID], requiresPageReload: true, schema: schema.object({ - idleTimeout: schema.number({ min: 300000 }), value: schema.number({ min: 60000 }), on: schema.boolean(), }), }, + [RULES_TABLE_ADVANCED_FILTERING_THRESHOLD]: { + name: i18n.translate('xpack.securitySolution.uiSettings.advancedFilteringMaxRules', { + defaultMessage: 'Experimental sorting and filtering capabilities threshold', + }), + description: i18n.translate( + 'xpack.securitySolution.uiSettings.advancedFilteringMaxRulesDescription', + { + defaultMessage: `

Experimental sorting and filtering is enabled on the Rules and Rule Monitoring tables when the total number of rules in the current Kibana space doesn't exceed this threshold

`, + } + ), + type: 'number', + value: DEFAULT_RULES_TABLE_IN_MEMORY_THRESHOLD, + category: [APP_ID], + requiresPageReload: true, + schema: schema.number({ min: 0, max: 10000 }), + }, [NEWS_FEED_URL_SETTING]: { name: i18n.translate('xpack.securitySolution.uiSettings.newsFeedUrl', { defaultMessage: 'News feed URL', @@ -230,5 +259,7 @@ export const initUiSettings = ( }, } : {}), - }); + }; + + uiSettings.register(orderSettings(securityUiSettings)); }; From 3797b78ce722bcefc617ef233c2917cf6905f126 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Tue, 25 Jan 2022 14:35:21 +0000 Subject: [PATCH 09/46] [File data visualizer] Removing file upload retries (#123696) --- x-pack/plugins/file_upload/server/analyze_file.tsx | 11 +++++++---- x-pack/plugins/file_upload/server/import_data.ts | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/file_upload/server/analyze_file.tsx b/x-pack/plugins/file_upload/server/analyze_file.tsx index ecefd34b0cefc..cdb0bddecb395 100644 --- a/x-pack/plugins/file_upload/server/analyze_file.tsx +++ b/x-pack/plugins/file_upload/server/analyze_file.tsx @@ -19,10 +19,13 @@ export async function analyzeFile( overrides: InputOverrides ): Promise { overrides.explain = overrides.explain === undefined ? 'true' : overrides.explain; - const { body } = await client.asInternalUser.textStructure.findStructure({ - body: data, - ...overrides, - }); + const { body } = await client.asInternalUser.textStructure.findStructure( + { + body: data, + ...overrides, + }, + { maxRetries: 0 } + ); const { hasOverrides, reducedOverrides } = formatOverrides(overrides); diff --git a/x-pack/plugins/file_upload/server/import_data.ts b/x-pack/plugins/file_upload/server/import_data.ts index 2419710c04c5a..c2975fca959f0 100644 --- a/x-pack/plugins/file_upload/server/import_data.ts +++ b/x-pack/plugins/file_upload/server/import_data.ts @@ -104,7 +104,7 @@ export function importDataProvider({ asCurrentUser }: IScopedClusterClient) { } // @ts-expect-error settings.index is not compatible - await asCurrentUser.indices.create({ index, body }); + await asCurrentUser.indices.create({ index, body }, { maxRetries: 0 }); } async function indexData(index: string, pipelineId: string, data: InputData) { @@ -120,7 +120,7 @@ export function importDataProvider({ asCurrentUser }: IScopedClusterClient) { settings.pipeline = pipelineId; } - const { body: resp } = await asCurrentUser.bulk(settings); + const { body: resp } = await asCurrentUser.bulk(settings, { maxRetries: 0 }); if (resp.errors) { throw resp; } else { From 403fdcbd4cb0224107c62613a68898e9e803d78a Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Tue, 25 Jan 2022 10:09:42 -0500 Subject: [PATCH 10/46] [Uptime] Update functional test directory to use a pinned version of package registry via docker (#117736) * update functional test directory to use a pinned version of package registry via docker * remove console log * adjust config * skip synthetics tests if no docker image * remove extra configs * move synthetics tests to a different directory * update tests * update tests * remove duplicate tests * update helpers Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../browser/throttling_fields.tsx | 2 +- x-pack/scripts/functional_tests.js | 1 + x-pack/test/functional/apps/uptime/index.ts | 1 - x-pack/test/functional/config.js | 11 +- .../fixtures/package_registry_config.yml | 4 + x-pack/test/functional/page_objects/index.ts | 2 - .../test/functional/services/uptime/uptime.ts | 3 - x-pack/test/functional_synthetics/README.md | 3 + .../apps/uptime/index.ts | 16 +++ .../apps/uptime/synthetics_integration.ts | 27 ++--- x-pack/test/functional_synthetics/config.js | 111 ++++++++++++++++++ .../fixtures/package_registry_config.yml | 4 + .../ftr_provider_context.ts | 14 +++ x-pack/test/functional_synthetics/helpers.ts | 31 +++++ .../page_objects/index.ts | 17 +++ .../synthetics_integration_page.ts | 2 +- .../functional_synthetics/services/index.ts | 19 +++ .../services/uptime/index.ts | 8 ++ .../services/uptime/synthetics_package.ts | 0 .../services/uptime/uptime.ts | 18 +++ 20 files changed, 267 insertions(+), 27 deletions(-) create mode 100644 x-pack/test/functional/fixtures/package_registry_config.yml create mode 100644 x-pack/test/functional_synthetics/README.md create mode 100644 x-pack/test/functional_synthetics/apps/uptime/index.ts rename x-pack/test/{functional => functional_synthetics}/apps/uptime/synthetics_integration.ts (96%) create mode 100644 x-pack/test/functional_synthetics/config.js create mode 100644 x-pack/test/functional_synthetics/fixtures/package_registry_config.yml create mode 100644 x-pack/test/functional_synthetics/ftr_provider_context.ts create mode 100644 x-pack/test/functional_synthetics/helpers.ts create mode 100644 x-pack/test/functional_synthetics/page_objects/index.ts rename x-pack/test/{functional => functional_synthetics}/page_objects/synthetics_integration_page.ts (99%) create mode 100644 x-pack/test/functional_synthetics/services/index.ts create mode 100644 x-pack/test/functional_synthetics/services/uptime/index.ts rename x-pack/test/{functional => functional_synthetics}/services/uptime/synthetics_package.ts (100%) create mode 100644 x-pack/test/functional_synthetics/services/uptime/uptime.ts diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/throttling_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/throttling_fields.tsx index 8648b531ef3a8..6d52ef755d0a2 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/throttling_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/throttling_fields.tsx @@ -120,7 +120,6 @@ export const ThrottlingFields = memo(({ validate }) => { } labelAppend={} isInvalid={!!validate[ConfigKey.LATENCY]?.(fields)} - data-test-subj="syntheticsBrowserLatency" error={ (({ validate }) => { configKey: ConfigKey.LATENCY, }) } + data-test-subj="syntheticsBrowserLatency" append={ ms diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index f23c041b58149..7f83b1464805a 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -24,6 +24,7 @@ const alwaysImportedTests = [ require.resolve('../test/saved_object_tagging/functional/config.ts'), require.resolve('../test/usage_collection/config.ts'), require.resolve('../test/fleet_functional/config.ts'), + require.resolve('../test/functional_synthetics/config.js'), ]; const onlyNotInCoverageTests = [ require.resolve('../test/api_integration/config_security_basic.ts'), diff --git a/x-pack/test/functional/apps/uptime/index.ts b/x-pack/test/functional/apps/uptime/index.ts index 79e0fbd56e39e..d36f8124599fe 100644 --- a/x-pack/test/functional/apps/uptime/index.ts +++ b/x-pack/test/functional/apps/uptime/index.ts @@ -59,7 +59,6 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => { loadTestFile(require.resolve('./locations')); loadTestFile(require.resolve('./settings')); loadTestFile(require.resolve('./certificates')); - loadTestFile(require.resolve('./synthetics_integration')); }); describe('with generated data but no data reset', () => { diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 52682d63e7301..67c2f9b386425 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -10,6 +10,13 @@ import { resolve } from 'path'; import { services } from './services'; import { pageObjects } from './page_objects'; +// Docker image to use for Fleet API integration tests. +// This hash comes from the latest successful build of the Snapshot Distribution of the Package Registry, for +// example: https://beats-ci.elastic.co/blue/organizations/jenkins/Ingest-manager%2Fpackage-storage/detail/snapshot/74/pipeline/257#step-302-log-1. +// It should be updated any time there is a new Docker image published for the Snapshot Distribution of the Package Registry. +export const dockerImage = + 'docker.elastic.co/package-registry/distribution:ffcbe0ba25b9bae09a671249cbb1b25af0aa1994'; + // the default export of config files must be a config provider // that returns an object with the projects config values export default async function ({ readConfigFile }) { @@ -84,7 +91,7 @@ export default async function ({ readConfigFile }) { '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', // server restarts should not invalidate active sessions '--xpack.encryptedSavedObjects.encryptionKey="DkdXazszSCYexXqz4YktBGHCRkV6hyNK"', '--xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled=true', - '--savedObjects.maxImportPayloadBytes=10485760', // for OSS test management/_import_objects + '--savedObjects.maxImportPayloadBytes=10485760', // for OSS test management/_import_objects, ], }, uiSettings: { @@ -484,7 +491,7 @@ export default async function ({ readConfigFile }) { }, }, - //Kibana feature privilege isn't specific to advancedSetting. It can be anything. https://github.com/elastic/kibana/issues/35965 + // Kibana feature privilege isn't specific to advancedSetting. It can be anything. https://github.com/elastic/kibana/issues/35965 test_api_keys: { elasticsearch: { cluster: ['manage_security', 'manage_api_key'], diff --git a/x-pack/test/functional/fixtures/package_registry_config.yml b/x-pack/test/functional/fixtures/package_registry_config.yml new file mode 100644 index 0000000000000..00e01fe9ea0fc --- /dev/null +++ b/x-pack/test/functional/fixtures/package_registry_config.yml @@ -0,0 +1,4 @@ +package_paths: + - /packages/production + - /packages/staging + - /packages/snapshot diff --git a/x-pack/test/functional/page_objects/index.ts b/x-pack/test/functional/page_objects/index.ts index 7d2991692b127..7bd8968ce6aeb 100644 --- a/x-pack/test/functional/page_objects/index.ts +++ b/x-pack/test/functional/page_objects/index.ts @@ -24,7 +24,6 @@ import { StatusPageObject } from './status_page'; import { UpgradeAssistantPageObject } from './upgrade_assistant_page'; import { RollupPageObject } from './rollup_page'; import { UptimePageObject } from './uptime_page'; -import { SyntheticsIntegrationPageProvider } from './synthetics_integration_page'; import { ApiKeysPageProvider } from './api_keys_page'; import { LicenseManagementPageProvider } from './license_management_page'; import { IndexManagementPageProvider } from './index_management_page'; @@ -67,7 +66,6 @@ export const pageObjects = { statusPage: StatusPageObject, upgradeAssistant: UpgradeAssistantPageObject, uptime: UptimePageObject, - syntheticsIntegration: SyntheticsIntegrationPageProvider, rollup: RollupPageObject, apiKeys: ApiKeysPageProvider, licenseManagement: LicenseManagementPageProvider, diff --git a/x-pack/test/functional/services/uptime/uptime.ts b/x-pack/test/functional/services/uptime/uptime.ts index 1f808d4e5939a..b345be012968d 100644 --- a/x-pack/test/functional/services/uptime/uptime.ts +++ b/x-pack/test/functional/services/uptime/uptime.ts @@ -15,7 +15,6 @@ import { UptimeAlertsProvider } from './alerts'; import { UptimeMLAnomalyProvider } from './ml_anomaly'; import { UptimeCertProvider } from './certificates'; import { UptimeOverviewProvider } from './overview'; -import { SyntheticsPackageProvider } from './synthetics_package'; export function UptimeProvider(context: FtrProviderContext) { const common = UptimeCommonProvider(context); @@ -26,7 +25,6 @@ export function UptimeProvider(context: FtrProviderContext) { const ml = UptimeMLAnomalyProvider(context); const cert = UptimeCertProvider(context); const overview = UptimeOverviewProvider(context); - const syntheticsPackage = SyntheticsPackageProvider(context); return { common, @@ -37,6 +35,5 @@ export function UptimeProvider(context: FtrProviderContext) { ml, cert, overview, - syntheticsPackage, }; } diff --git a/x-pack/test/functional_synthetics/README.md b/x-pack/test/functional_synthetics/README.md new file mode 100644 index 0000000000000..35324397ac3fc --- /dev/null +++ b/x-pack/test/functional_synthetics/README.md @@ -0,0 +1,3 @@ +# Kibana Functional Testing + +See our [Functional Testing Guide](https://www.elastic.co/guide/en/kibana/current/development-tests.html#development-functional-tests) diff --git a/x-pack/test/functional_synthetics/apps/uptime/index.ts b/x-pack/test/functional_synthetics/apps/uptime/index.ts new file mode 100644 index 0000000000000..64a9da5c30ea3 --- /dev/null +++ b/x-pack/test/functional_synthetics/apps/uptime/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ loadTestFile, getService }: FtrProviderContext) => { + describe('Uptime app', function () { + describe('with generated data', () => { + loadTestFile(require.resolve('./synthetics_integration')); + }); + }); +}; diff --git a/x-pack/test/functional/apps/uptime/synthetics_integration.ts b/x-pack/test/functional_synthetics/apps/uptime/synthetics_integration.ts similarity index 96% rename from x-pack/test/functional/apps/uptime/synthetics_integration.ts rename to x-pack/test/functional_synthetics/apps/uptime/synthetics_integration.ts index ae7edf3524d7d..1521099abc146 100644 --- a/x-pack/test/functional/apps/uptime/synthetics_integration.ts +++ b/x-pack/test/functional_synthetics/apps/uptime/synthetics_integration.ts @@ -8,8 +8,10 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; import { FullAgentPolicy } from '../../../../plugins/fleet/common'; +import { skipIfNoDockerRegistry } from '../../helpers'; -export default function ({ getPageObjects, getService }: FtrProviderContext) { +export default function (providerContext: FtrProviderContext) { + const { getPageObjects, getService } = providerContext; const monitorName = 'Sample Synthetics integration'; const uptimePage = getPageObjects(['syntheticsIntegration']); @@ -129,10 +131,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { type: `synthetics/${monitorType}`, use_output: 'default', }); - - // FLAKY: https://github.com/elastic/kibana/issues/116980 - describe.skip('When on the Synthetics Integration Policy Create Page', function () { - this.tags(['ciGroup10']); + describe('When on the Synthetics Integration Policy Create Page', function () { + skipIfNoDockerRegistry(providerContext); const basicConfig = { name: monitorName, apmServiceName: 'Sample APM Service', @@ -172,8 +172,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/103390 - describe.skip('create new policy', () => { + describe('create new policy', () => { let version: string; beforeEach(async () => { @@ -558,6 +557,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { schedule: '@every 3m', timeout: '16s', tags: [config.tags], + throttling: '5d/3u/20l', 'service.name': config.apmServiceName, 'source.zip_url.url': config.zipUrl, 'source.zip_url.folder': config.folder, @@ -607,6 +607,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { schedule: '@every 3m', timeout: '16s', tags: [config.tags], + throttling: '5d/3u/20l', 'service.name': config.apmServiceName, 'source.inline.script': config.inlineScript, __ui: { @@ -665,6 +666,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { schedule: '@every 3m', timeout: '16s', tags: [config.tags], + throttling: '1337d/1338u/1339l', 'service.name': config.apmServiceName, 'source.zip_url.url': config.zipUrl, 'source.zip_url.folder': config.folder, @@ -672,11 +674,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'source.zip_url.password': config.password, params: JSON.parse(config.params), synthetics_args: [advancedConfig.syntheticsArgs], - 'throttling.is_enabled': advancedConfig.isThrottlingEnabled, - 'throttling.download_speed': advancedConfig.downloadSpeed, - 'throttling.upload_speed': advancedConfig.uploadSpeed, - 'throttling.latency': advancedConfig.latency, - 'throttling.config': `${advancedConfig.downloadSpeed}d/${advancedConfig.uploadSpeed}u/${advancedConfig.latency}l`, __ui: { is_tls_enabled: false, is_zip_url_tls_enabled: false, @@ -740,11 +737,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'source.zip_url.password': config.password, params: JSON.parse(config.params), synthetics_args: [advancedConfig.syntheticsArgs], - 'throttling.is_enabled': advancedConfig.isThrottlingEnabled, - 'throttling.download_speed': advancedConfig.downloadSpeed, - 'throttling.upload_speed': advancedConfig.uploadSpeed, - 'throttling.latency': advancedConfig.latency, - 'throttling.config': 'false', + throttling: false, __ui: { is_tls_enabled: false, is_zip_url_tls_enabled: false, diff --git a/x-pack/test/functional_synthetics/config.js b/x-pack/test/functional_synthetics/config.js new file mode 100644 index 0000000000000..28cd7e3b099dc --- /dev/null +++ b/x-pack/test/functional_synthetics/config.js @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import path, { resolve } from 'path'; + +import { defineDockerServersConfig } from '@kbn/test'; + +import { services } from './services'; +import { pageObjects } from './page_objects'; + +// Docker image to use for Fleet API integration tests. +// This hash comes from the latest successful build of the Snapshot Distribution of the Package Registry, for +// example: https://beats-ci.elastic.co/blue/organizations/jenkins/Ingest-manager%2Fpackage-storage/detail/snapshot/74/pipeline/257#step-302-log-1. +// It should be updated any time there is a new Docker image published for the Snapshot Distribution of the Package Registry that updates Synthetics. +export const dockerImage = + 'docker.elastic.co/package-registry/distribution:48202133e7506873aff3cc7c3b1d284158727779'; + +// the default export of config files must be a config provider +// that returns an object with the projects config values +export default async function ({ readConfigFile }) { + const registryPort = process.env.FLEET_PACKAGE_REGISTRY_PORT; + + const kibanaCommonConfig = await readConfigFile( + require.resolve('../../../test/common/config.js') + ); + const kibanaFunctionalConfig = await readConfigFile( + require.resolve('../../../test/functional/config.js') + ); + + // mount the config file for the package registry as well as + // the directory containing additional packages into the container + const dockerArgs = [ + '-v', + `${path.join( + path.dirname(__filename), + './fixtures/package_registry_config.yml' + )}:/package-registry/config.yml`, + ]; + + return { + // list paths to the files that contain your plugins tests + testFiles: [resolve(__dirname, './apps/uptime')], + + services, + pageObjects, + + servers: kibanaFunctionalConfig.get('servers'), + + esTestCluster: { + license: 'trial', + from: 'snapshot', + serverArgs: ['path.repo=/tmp/', 'xpack.security.authc.api_key.enabled=true'], + }, + + kbnTestServer: { + ...kibanaCommonConfig.get('kbnTestServer'), + serverArgs: [ + ...kibanaCommonConfig.get('kbnTestServer.serverArgs'), + '--status.allowAnonymous=true', + '--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d', + '--xpack.maps.showMapsInspectorAdapter=true', + '--xpack.maps.preserveDrawingBuffer=true', + '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', // server restarts should not invalidate active sessions + '--xpack.encryptedSavedObjects.encryptionKey="DkdXazszSCYexXqz4YktBGHCRkV6hyNK"', + '--xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled=true', + '--savedObjects.maxImportPayloadBytes=10485760', // for OSS test management/_import_objects, + ...(registryPort ? [`--xpack.fleet.registryUrl=http://localhost:${registryPort}`] : []), + ], + }, + uiSettings: { + defaults: { + 'accessibility:disableAnimations': true, + 'dateFormat:tz': 'UTC', + 'visualization:visualize:legacyPieChartsLibrary': true, + }, + }, + // the apps section defines the urls that + // `PageObjects.common.navigateTo(appKey)` will use. + // Merge urls for your plugin with the urls defined in + // Kibana's config in order to use this helper + apps: { + ...kibanaFunctionalConfig.get('apps'), + fleet: { + pathname: '/app/fleet', + }, + }, + + // choose where screenshots should be saved + screenshots: { + directory: resolve(__dirname, 'screenshots'), + }, + + junit: { + reportName: 'Chrome Elastic Synthetics Integration UI Functional Tests', + }, + dockerServers: defineDockerServersConfig({ + registry: { + enabled: !!registryPort, + image: dockerImage, + portInContainer: 8080, + port: registryPort, + args: dockerArgs, + waitForLogLine: 'package manifests loaded', + }, + }), + }; +} diff --git a/x-pack/test/functional_synthetics/fixtures/package_registry_config.yml b/x-pack/test/functional_synthetics/fixtures/package_registry_config.yml new file mode 100644 index 0000000000000..00e01fe9ea0fc --- /dev/null +++ b/x-pack/test/functional_synthetics/fixtures/package_registry_config.yml @@ -0,0 +1,4 @@ +package_paths: + - /packages/production + - /packages/staging + - /packages/snapshot diff --git a/x-pack/test/functional_synthetics/ftr_provider_context.ts b/x-pack/test/functional_synthetics/ftr_provider_context.ts new file mode 100644 index 0000000000000..e757164fa1de9 --- /dev/null +++ b/x-pack/test/functional_synthetics/ftr_provider_context.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GenericFtrProviderContext, GenericFtrService } from '@kbn/test'; + +import { pageObjects } from './page_objects'; +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; +export class FtrService extends GenericFtrService {} diff --git a/x-pack/test/functional_synthetics/helpers.ts b/x-pack/test/functional_synthetics/helpers.ts new file mode 100644 index 0000000000000..959827b7490a5 --- /dev/null +++ b/x-pack/test/functional_synthetics/helpers.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Context } from 'mocha'; +import { ToolingLog } from '@kbn/dev-utils'; +import { FtrProviderContext } from './ftr_provider_context'; + +export function warnAndSkipTest(mochaContext: Context, log: ToolingLog) { + log.warning( + 'disabling tests because DockerServers service is not enabled, set FLEET_PACKAGE_REGISTRY_PORT to run them' + ); + mochaContext.skip(); +} + +export function skipIfNoDockerRegistry(providerContext: FtrProviderContext) { + const { getService } = providerContext; + const dockerServers = getService('dockerServers'); + + const server = dockerServers.get('registry'); + const log = getService('log'); + + beforeEach(function beforeSetupWithDockerRegistry() { + if (!server.enabled) { + warnAndSkipTest(this, log); + } + }); +} diff --git a/x-pack/test/functional_synthetics/page_objects/index.ts b/x-pack/test/functional_synthetics/page_objects/index.ts new file mode 100644 index 0000000000000..253157297713b --- /dev/null +++ b/x-pack/test/functional_synthetics/page_objects/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { pageObjects as kibanaFunctionalPageObjects } from '../../../../test/functional/page_objects'; + +import { SyntheticsIntegrationPageProvider } from './synthetics_integration_page'; + +// just like services, PageObjects are defined as a map of +// names to Providers. Merge in Kibana's or pick specific ones +export const pageObjects = { + ...kibanaFunctionalPageObjects, + syntheticsIntegration: SyntheticsIntegrationPageProvider, +}; diff --git a/x-pack/test/functional/page_objects/synthetics_integration_page.ts b/x-pack/test/functional_synthetics/page_objects/synthetics_integration_page.ts similarity index 99% rename from x-pack/test/functional/page_objects/synthetics_integration_page.ts rename to x-pack/test/functional_synthetics/page_objects/synthetics_integration_page.ts index ad39b4bb02452..fba4f2ce80e6b 100644 --- a/x-pack/test/functional/page_objects/synthetics_integration_page.ts +++ b/x-pack/test/functional_synthetics/page_objects/synthetics_integration_page.ts @@ -451,7 +451,7 @@ export function SyntheticsIntegrationPageProvider({ await testSubjects.click('syntheticsBrowserAdvancedFieldsAccordion'); const throttleSwitch = await this.findThrottleSwitch(); - if ((await throttleSwitch.isSelected()) !== isThrottlingEnabled) { + if (!isThrottlingEnabled) { await throttleSwitch.click(); } diff --git a/x-pack/test/functional_synthetics/services/index.ts b/x-pack/test/functional_synthetics/services/index.ts new file mode 100644 index 0000000000000..b8340acccb512 --- /dev/null +++ b/x-pack/test/functional_synthetics/services/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { services as kibanaFunctionalServices } from '../../../../test/functional/services'; +import { services as commonServices } from '../../common/services'; +import { UptimeProvider } from './uptime'; + +// define the name and providers for services that should be +// available to your tests. If you don't specify anything here +// only the built-in services will be available +export const services = { + ...kibanaFunctionalServices, + ...commonServices, + uptime: UptimeProvider, +}; diff --git a/x-pack/test/functional_synthetics/services/uptime/index.ts b/x-pack/test/functional_synthetics/services/uptime/index.ts new file mode 100644 index 0000000000000..649408c03284d --- /dev/null +++ b/x-pack/test/functional_synthetics/services/uptime/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 { UptimeProvider } from './uptime'; diff --git a/x-pack/test/functional/services/uptime/synthetics_package.ts b/x-pack/test/functional_synthetics/services/uptime/synthetics_package.ts similarity index 100% rename from x-pack/test/functional/services/uptime/synthetics_package.ts rename to x-pack/test/functional_synthetics/services/uptime/synthetics_package.ts diff --git a/x-pack/test/functional_synthetics/services/uptime/uptime.ts b/x-pack/test/functional_synthetics/services/uptime/uptime.ts new file mode 100644 index 0000000000000..24354f4ddae0d --- /dev/null +++ b/x-pack/test/functional_synthetics/services/uptime/uptime.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +import { SyntheticsPackageProvider } from './synthetics_package'; + +export function UptimeProvider(context: FtrProviderContext) { + const syntheticsPackage = SyntheticsPackageProvider(context); + + return { + syntheticsPackage, + }; +} From 2ebc8d2d61907df801a1673249c325fed8d8ca22 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Tue, 25 Jan 2022 10:17:28 -0500 Subject: [PATCH 11/46] [Maps][ML] Integration follow up: adds partition field info to map point tooltip if available (#123516) * add partition field info to tooltip if available * make partition field in tooltip consistent. simplify result fetch * make layers const * remove unnecessary type * fix types --- .../plugins/ml/public/maps/anomaly_source.tsx | 11 ++- .../ml/public/maps/anomaly_source_field.ts | 56 ++++++++++- .../maps/create_anomaly_source_editor.tsx | 10 +- .../plugins/ml/public/maps/layer_selector.tsx | 27 ++++-- .../maps/update_anomaly_source_editor.tsx | 6 +- x-pack/plugins/ml/public/maps/util.ts | 97 +++++++++---------- 6 files changed, 127 insertions(+), 80 deletions(-) diff --git a/x-pack/plugins/ml/public/maps/anomaly_source.tsx b/x-pack/plugins/ml/public/maps/anomaly_source.tsx index 1159f97dcbec9..23f99fa9b7455 100644 --- a/x-pack/plugins/ml/public/maps/anomaly_source.tsx +++ b/x-pack/plugins/ml/public/maps/anomaly_source.tsx @@ -29,13 +29,13 @@ import type { SourceEditorArgs } from '../../../maps/public'; import type { DataRequest } from '../../../maps/public'; import type { IVectorSource, SourceStatus } from '../../../maps/public'; import { ML_ANOMALY } from './anomaly_source_factory'; -import { getResultsForJobId, MlAnomalyLayers } from './util'; +import { getResultsForJobId, ML_ANOMALY_LAYERS, MlAnomalyLayersType } from './util'; import { UpdateAnomalySourceEditor } from './update_anomaly_source_editor'; import type { MlApiServices } from '../application/services/ml_api_service'; export interface AnomalySourceDescriptor extends AbstractSourceDescriptor { jobId: string; - typicalActual: MlAnomalyLayers; + typicalActual: MlAnomalyLayersType; } export class AnomalySource implements IVectorSource { @@ -50,7 +50,7 @@ export class AnomalySource implements IVectorSource { return { type: ML_ANOMALY, jobId: descriptor.jobId, - typicalActual: descriptor.typicalActual || 'actual', + typicalActual: descriptor.typicalActual || ML_ANOMALY_LAYERS.ACTUAL, }; } @@ -232,7 +232,7 @@ export class AnomalySource implements IVectorSource { } async getSupportedShapeTypes(): Promise { - return this._descriptor.typicalActual === 'connected' + return this._descriptor.typicalActual === ML_ANOMALY_LAYERS.TYPICAL_TO_ACTUAL ? [VECTOR_SHAPE_TYPE.LINE] : [VECTOR_SHAPE_TYPE.POINT]; } @@ -251,6 +251,9 @@ export class AnomalySource implements IVectorSource { const label = ANOMALY_SOURCE_FIELDS[key]?.label; if (label) { tooltipProperties.push(new AnomalySourceTooltipProperty(label, properties[key])); + } else if (!ANOMALY_SOURCE_FIELDS[key]) { + // partition field keys will be different each time so won't be in ANOMALY_SOURCE_FIELDS + tooltipProperties.push(new AnomalySourceTooltipProperty(key, properties[key])); } } } diff --git a/x-pack/plugins/ml/public/maps/anomaly_source_field.ts b/x-pack/plugins/ml/public/maps/anomaly_source_field.ts index 8a0e1f0104ee0..40c64171a310b 100644 --- a/x-pack/plugins/ml/public/maps/anomaly_source_field.ts +++ b/x-pack/plugins/ml/public/maps/anomaly_source_field.ts @@ -15,6 +15,16 @@ import { AnomalySource } from './anomaly_source'; import { ITooltipProperty } from '../../../maps/public'; import { Filter } from '../../../../../src/plugins/data/public'; +export const ACTUAL_LABEL = i18n.translate('xpack.ml.maps.anomalyLayerActualLabel', { + defaultMessage: 'Actual', +}); +export const TYPICAL_LABEL = i18n.translate('xpack.ml.maps.anomalyLayerTypicalLabel', { + defaultMessage: 'Typical', +}); +export const TYPICAL_TO_ACTUAL = i18n.translate('xpack.ml.maps.anomalyLayerTypicalToActualLabel', { + defaultMessage: 'Typical to actual', +}); + export const ANOMALY_SOURCE_FIELDS: Record> = { record_score: { label: i18n.translate('xpack.ml.maps.anomalyLayerRecordScoreLabel', { @@ -40,15 +50,51 @@ export const ANOMALY_SOURCE_FIELDS: Record> = { }), type: 'string', }, + // this value is only used to place the point on the map + actual: {}, actualDisplay: { - label: i18n.translate('xpack.ml.maps.anomalyLayerActualLabel', { - defaultMessage: 'Actual', - }), + label: ACTUAL_LABEL, type: 'string', }, + // this value is only used to place the point on the map + typical: {}, typicalDisplay: { - label: i18n.translate('xpack.ml.maps.anomalyLayerTypicalLabel', { - defaultMessage: 'Typical', + label: TYPICAL_LABEL, + type: 'string', + }, + partition_field_name: { + label: i18n.translate('xpack.ml.maps.anomalyLayerPartitionFieldNameLabel', { + defaultMessage: 'Partition field name', + }), + type: 'string', + }, + partition_field_value: { + label: i18n.translate('xpack.ml.maps.anomalyLayerPartitionFieldValueLabel', { + defaultMessage: 'Partition field value', + }), + type: 'string', + }, + by_field_name: { + label: i18n.translate('xpack.ml.maps.anomalyLayerByFieldNameLabel', { + defaultMessage: 'By field name', + }), + type: 'string', + }, + by_field_value: { + label: i18n.translate('xpack.ml.maps.anomalyLayerByFieldValueLabel', { + defaultMessage: 'By field value', + }), + type: 'string', + }, + over_field_name: { + label: i18n.translate('xpack.ml.maps.anomalyLayerOverFieldNameLabel', { + defaultMessage: 'Over field name', + }), + type: 'string', + }, + over_field_value: { + label: i18n.translate('xpack.ml.maps.anomalyLayerOverFieldValueLabel', { + defaultMessage: 'Over field value', }), type: 'string', }, diff --git a/x-pack/plugins/ml/public/maps/create_anomaly_source_editor.tsx b/x-pack/plugins/ml/public/maps/create_anomaly_source_editor.tsx index e04fd40f9416b..8270a91c930c4 100644 --- a/x-pack/plugins/ml/public/maps/create_anomaly_source_editor.tsx +++ b/x-pack/plugins/ml/public/maps/create_anomaly_source_editor.tsx @@ -11,7 +11,7 @@ import { EuiPanel } from '@elastic/eui'; import { AnomalySourceDescriptor } from './anomaly_source'; import { AnomalyJobSelector } from './anomaly_job_selector'; import { LayerSelector } from './layer_selector'; -import { MlAnomalyLayers } from './util'; +import { ML_ANOMALY_LAYERS, MlAnomalyLayersType } from './util'; import type { MlApiServices } from '../application/services/ml_api_service'; interface Props { @@ -21,7 +21,7 @@ interface Props { interface State { jobId?: string; - typicalActual?: MlAnomalyLayers; + typicalActual?: MlAnomalyLayersType; } export class CreateAnomalySourceEditor extends Component { @@ -32,7 +32,7 @@ export class CreateAnomalySourceEditor extends Component { if (this.state.jobId) { this.props.onSourceConfigChange({ jobId: this.state.jobId, - typicalActual: this.state.typicalActual, + typicalActual: this.state.typicalActual || ML_ANOMALY_LAYERS.ACTUAL, }); } } @@ -41,7 +41,7 @@ export class CreateAnomalySourceEditor extends Component { this._isMounted = true; } - private onTypicalActualChange = (typicalActual: MlAnomalyLayers) => { + private onTypicalActualChange = (typicalActual: MlAnomalyLayersType) => { if (!this._isMounted) { return; } @@ -73,7 +73,7 @@ export class CreateAnomalySourceEditor extends Component { const selector = this.state.jobId ? ( ) : null; return ( diff --git a/x-pack/plugins/ml/public/maps/layer_selector.tsx b/x-pack/plugins/ml/public/maps/layer_selector.tsx index 2998187ad465b..0e8b3aaff3504 100644 --- a/x-pack/plugins/ml/public/maps/layer_selector.tsx +++ b/x-pack/plugins/ml/public/maps/layer_selector.tsx @@ -9,11 +9,12 @@ import React, { Component } from 'react'; import { EuiComboBox, EuiFormRow, EuiComboBoxOptionOption } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { MlAnomalyLayers } from './util'; +import { ML_ANOMALY_LAYERS, MlAnomalyLayersType } from './util'; +import { ACTUAL_LABEL, TYPICAL_LABEL, TYPICAL_TO_ACTUAL } from './anomaly_source_field'; interface Props { - onChange: (typicalActual: MlAnomalyLayers) => void; - typicalActual: MlAnomalyLayers; + onChange: (typicalActual: MlAnomalyLayersType) => void; + typicalActual: MlAnomalyLayersType; } // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -33,10 +34,7 @@ export class LayerSelector extends Component { } onSelect = (selectedOptions: Array>) => { - const typicalActual: MlAnomalyLayers = selectedOptions[0].value! as - | 'typical' - | 'actual' - | 'connected'; + const typicalActual: MlAnomalyLayersType = selectedOptions[0].value! as MlAnomalyLayersType; if (this._isMounted) { this.setState({ typicalActual }); this.props.onChange(typicalActual); @@ -56,9 +54,18 @@ export class LayerSelector extends Component { singleSelection={true} onChange={this.onSelect} options={[ - { value: 'actual', label: 'actual' }, - { value: 'typical', label: 'typical' }, - { value: 'connected', label: 'connected' }, + { + value: ML_ANOMALY_LAYERS.ACTUAL, + label: ACTUAL_LABEL, + }, + { + value: ML_ANOMALY_LAYERS.TYPICAL, + label: TYPICAL_LABEL, + }, + { + value: ML_ANOMALY_LAYERS.TYPICAL_TO_ACTUAL, + label: TYPICAL_TO_ACTUAL, + }, ]} selectedOptions={options} /> diff --git a/x-pack/plugins/ml/public/maps/update_anomaly_source_editor.tsx b/x-pack/plugins/ml/public/maps/update_anomaly_source_editor.tsx index 7e53e6ebf9f5d..2b1d3d7e40f3f 100644 --- a/x-pack/plugins/ml/public/maps/update_anomaly_source_editor.tsx +++ b/x-pack/plugins/ml/public/maps/update_anomaly_source_editor.tsx @@ -10,11 +10,11 @@ import React, { Fragment, Component } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import { LayerSelector } from './layer_selector'; -import { MlAnomalyLayers } from './util'; +import { MlAnomalyLayersType } from './util'; interface Props { onChange: (...args: Array<{ propName: string; value: unknown }>) => void; - typicalActual: MlAnomalyLayers; + typicalActual: MlAnomalyLayersType; } // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -34,7 +34,7 @@ export class UpdateAnomalySourceEditor extends Component { { + onChange={(typicalActual: MlAnomalyLayersType) => { this.props.onChange({ propName: 'typicalActual', value: typicalActual, diff --git a/x-pack/plugins/ml/public/maps/util.ts b/x-pack/plugins/ml/public/maps/util.ts index 6a9d55ad64d38..ac38783d24ab7 100644 --- a/x-pack/plugins/ml/public/maps/util.ts +++ b/x-pack/plugins/ml/public/maps/util.ts @@ -13,7 +13,13 @@ import type { MlApiServices } from '../application/services/ml_api_service'; import { MLAnomalyDoc } from '../../common/types/anomalies'; import { VectorSourceRequestMeta } from '../../../maps/common'; -export type MlAnomalyLayers = 'typical' | 'actual' | 'connected'; +export const ML_ANOMALY_LAYERS = { + TYPICAL: 'typical', + ACTUAL: 'actual', + TYPICAL_TO_ACTUAL: 'typical to actual', +} as const; + +export type MlAnomalyLayersType = typeof ML_ANOMALY_LAYERS[keyof typeof ML_ANOMALY_LAYERS]; // Must reverse coordinates here. Map expects [lon, lat] - anomalies are stored as [lat, lon] for lat_lon jobs function getCoordinates(actualCoordinateStr: string, round: boolean = false): number[] { @@ -28,7 +34,7 @@ function getCoordinates(actualCoordinateStr: string, round: boolean = false): nu export async function getResultsForJobId( mlResultsService: MlApiServices['results'], jobId: string, - locationType: MlAnomalyLayers, + locationType: MlAnomalyLayersType, searchFilters: VectorSourceRequestMeta ): Promise { const { timeFilters } = searchFilters; @@ -64,16 +70,6 @@ export async function getResultsForJobId( } let resp: ESSearchResponse | null = null; - let hits: Array<{ - actual: number[]; - actualDisplay: number[]; - fieldName?: string; - functionDescription: string; - typical: number[]; - typicalDisplay: number[]; - record_score: number; - timestamp: string; - }> = []; try { resp = await mlResultsService.anomalySearch( @@ -86,8 +82,9 @@ export async function getResultsForJobId( // search may fail if the job doesn't already exist // ignore this error as the outer function call will raise a toast } - if (resp !== null && resp.hits.total.value > 0) { - hits = resp.hits.hits.map(({ _source }) => { + + const features: Feature[] = + resp?.hits.hits.map(({ _source }) => { const geoResults = _source.geo_results; const actualCoordStr = geoResults && geoResults.actual_point; const typicalCoordStr = geoResults && geoResults.typical_point; @@ -104,47 +101,41 @@ export async function getResultsForJobId( typical = getCoordinates(typicalCoordStr); typicalDisplay = getCoordinates(typicalCoordStr, true); } - return { - fieldName: _source.field_name, - functionDescription: _source.function_description, - timestamp: formatHumanReadableDateTimeSeconds(_source.timestamp), - typical, - typicalDisplay, - actual, - actualDisplay, - record_score: Math.floor(_source.record_score), - }; - }); - } - const features: Feature[] = hits.map((result) => { - let geometry: Geometry; - if (locationType === 'typical' || locationType === 'actual') { - geometry = { - type: 'Point', - coordinates: locationType === 'typical' ? result.typical : result.actual, - }; - } else { - geometry = { - type: 'LineString', - coordinates: [result.typical, result.actual], + let geometry: Geometry; + if (locationType === ML_ANOMALY_LAYERS.TYPICAL || locationType === ML_ANOMALY_LAYERS.ACTUAL) { + geometry = { + type: 'Point', + coordinates: locationType === ML_ANOMALY_LAYERS.TYPICAL ? typical : actual, + }; + } else { + geometry = { + type: 'LineString', + coordinates: [typical, actual], + }; + } + return { + type: 'Feature', + geometry, + properties: { + actual, + actualDisplay, + typical, + typicalDisplay, + fieldName: _source.field_name, + functionDescription: _source.function_description, + timestamp: formatHumanReadableDateTimeSeconds(_source.timestamp), + record_score: Math.floor(_source.record_score), + ...(_source.partition_field_name + ? { [_source.partition_field_name]: _source.partition_field_value } + : {}), + ...(_source.by_field_name ? { [_source.by_field_name]: _source.by_field_value } : {}), + ...(_source.over_field_name + ? { [_source.over_field_name]: _source.over_field_value } + : {}), + }, }; - } - return { - type: 'Feature', - geometry, - properties: { - actual: result.actual, - actualDisplay: result.actualDisplay, - typical: result.typical, - typicalDisplay: result.typicalDisplay, - fieldName: result.fieldName, - functionDescription: result.functionDescription, - timestamp: result.timestamp, - record_score: result.record_score, - }, - }; - }); + }) || []; return { type: 'FeatureCollection', From 08f87f00763918b4eb3173f74cc74c2e0765a264 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 25 Jan 2022 17:18:30 +0200 Subject: [PATCH 12/46] [ResponseOps][Cases] Get reporters and tags by aggregation (#123362) --- .../plugins/cases/server/client/cases/get.ts | 57 +++----------- .../cases/server/services/cases/index.ts | 77 ++++++++++++++++--- .../common/cases/reporters/get_reporters.ts | 6 +- .../tests/common/cases/tags/get_tags.ts | 6 +- 4 files changed, 81 insertions(+), 65 deletions(-) diff --git a/x-pack/plugins/cases/server/client/cases/get.ts b/x-pack/plugins/cases/server/client/cases/get.ts index b388abb58e449..0d0bcc7f78270 100644 --- a/x-pack/plugins/cases/server/client/cases/get.ts +++ b/x-pack/plugins/cases/server/client/cases/get.ts @@ -16,7 +16,6 @@ import { CaseResolveResponseRt, CaseResolveResponse, User, - UsersRt, AllTagsFindRequest, AllTagsFindRequestRt, excess, @@ -329,34 +328,18 @@ export async function getTags( fold(throwErrors(Boom.badRequest), identity) ); - const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } = - await authorization.getAuthorizationFilter(Operations.findCases); + const { filter: authorizationFilter } = await authorization.getAuthorizationFilter( + Operations.findCases + ); const filter = combineAuthorizedAndOwnerFilter(queryParams.owner, authorizationFilter); - const cases = await caseService.getTags({ + const tags = await caseService.getTags({ unsecuredSavedObjectsClient, filter, }); - const tags = new Set(); - const mappedCases: Array<{ - owner: string; - id: string; - }> = []; - - // Gather all necessary information in one pass - cases.saved_objects.forEach((theCase) => { - theCase.attributes.tags.forEach((tag) => tags.add(tag)); - mappedCases.push({ - id: theCase.id, - owner: theCase.attributes.owner, - }); - }); - - ensureSavedObjectsAreAuthorized(mappedCases); - - return [...tags.values()]; + return tags; } catch (error) { throw createCaseError({ message: `Failed to get tags: ${error}`, error, logger }); } @@ -377,38 +360,18 @@ export async function getReporters( fold(throwErrors(Boom.badRequest), identity) ); - const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } = - await authorization.getAuthorizationFilter(Operations.getReporters); + const { filter: authorizationFilter } = await authorization.getAuthorizationFilter( + Operations.getReporters + ); const filter = combineAuthorizedAndOwnerFilter(queryParams.owner, authorizationFilter); - const cases = await caseService.getReporters({ + const reporters = await caseService.getReporters({ unsecuredSavedObjectsClient, filter, }); - const reporters = new Map(); - const mappedCases: Array<{ - owner: string; - id: string; - }> = []; - - // Gather all necessary information in one pass - cases.saved_objects.forEach((theCase) => { - const user = theCase.attributes.created_by; - if (user.username != null) { - reporters.set(user.username, user); - } - - mappedCases.push({ - id: theCase.id, - owner: theCase.attributes.owner, - }); - }); - - ensureSavedObjectsAreAuthorized(mappedCases); - - return UsersRt.encode([...reporters.values()]); + return reporters; } catch (error) { throw createCaseError({ message: `Failed to get reporters: ${error}`, error, logger }); } diff --git a/x-pack/plugins/cases/server/services/cases/index.ts b/x-pack/plugins/cases/server/services/cases/index.ts index 7285761e6558a..412c45ee734d2 100644 --- a/x-pack/plugins/cases/server/services/cases/index.ts +++ b/x-pack/plugins/cases/server/services/cases/index.ts @@ -32,7 +32,6 @@ import { SUB_CASE_SAVED_OBJECT, } from '../../../common/constants'; import { - OWNER_FIELD, GetCaseIdsByAlertIdAggs, AssociationType, CaseResponse, @@ -1021,37 +1020,91 @@ export class CasesService { public async getReporters({ unsecuredSavedObjectsClient, filter, - }: GetReportersArgs): Promise> { + }: GetReportersArgs): Promise { try { this.log.debug(`Attempting to GET all reporters`); - return await unsecuredSavedObjectsClient.find({ + const results = await unsecuredSavedObjectsClient.find< + ESCaseAttributes, + { + reporters: { + buckets: Array<{ + key: string; + top_docs: { hits: { hits: Array<{ _source: { cases: { created_by: User } } }> } }; + }>; + }; + } + >({ type: CASE_SAVED_OBJECT, - fields: ['created_by', OWNER_FIELD], page: 1, - perPage: MAX_DOCS_PER_PAGE, + perPage: 1, filter, + aggs: { + reporters: { + terms: { + field: `${CASE_SAVED_OBJECT}.attributes.created_by.username`, + size: MAX_DOCS_PER_PAGE, + order: { _key: 'asc' }, + }, + aggs: { + top_docs: { + top_hits: { + sort: [ + { + [`${CASE_SAVED_OBJECT}.created_at`]: { + order: 'desc', + }, + }, + ], + size: 1, + _source: [`${CASE_SAVED_OBJECT}.created_by`], + }, + }, + }, + }, + }, }); + + return ( + results?.aggregations?.reporters?.buckets.map(({ key: username, top_docs: topDocs }) => { + const user = topDocs?.hits?.hits?.[0]?._source?.cases?.created_by ?? {}; + return { + username, + full_name: user.full_name ?? null, + email: user.email ?? null, + }; + }) ?? [] + ); } catch (error) { this.log.error(`Error on GET all reporters: ${error}`); throw error; } } - public async getTags({ - unsecuredSavedObjectsClient, - filter, - }: GetTagsArgs): Promise> { + public async getTags({ unsecuredSavedObjectsClient, filter }: GetTagsArgs): Promise { try { this.log.debug(`Attempting to GET all cases`); - return await unsecuredSavedObjectsClient.find({ + const results = await unsecuredSavedObjectsClient.find< + ESCaseAttributes, + { tags: { buckets: Array<{ key: string }> } } + >({ type: CASE_SAVED_OBJECT, - fields: ['tags', OWNER_FIELD], page: 1, - perPage: MAX_DOCS_PER_PAGE, + perPage: 1, filter, + aggs: { + tags: { + terms: { + field: `${CASE_SAVED_OBJECT}.attributes.tags`, + size: MAX_DOCS_PER_PAGE, + order: { _key: 'asc' }, + }, + }, + }, }); + + return results?.aggregations?.tags?.buckets.map(({ key }) => key) ?? []; } catch (error) { this.log.error(`Error on GET tags: ${error}`); throw error; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/reporters/get_reporters.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/reporters/get_reporters.ts index e34d9ccad39ac..902b83c66563d 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/reporters/get_reporters.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/reporters/get_reporters.ts @@ -74,17 +74,17 @@ export default ({ getService }: FtrProviderContext): void => { for (const scenario of [ { user: globalRead, - expectedReporters: [getUserInfo(secOnly), getUserInfo(obsOnly)], + expectedReporters: [getUserInfo(obsOnly), getUserInfo(secOnly)], }, { user: superUser, - expectedReporters: [getUserInfo(secOnly), getUserInfo(obsOnly)], + expectedReporters: [getUserInfo(obsOnly), getUserInfo(secOnly)], }, { user: secOnlyRead, expectedReporters: [getUserInfo(secOnly)] }, { user: obsOnlyRead, expectedReporters: [getUserInfo(obsOnly)] }, { user: obsSecRead, - expectedReporters: [getUserInfo(secOnly), getUserInfo(obsOnly)], + expectedReporters: [getUserInfo(obsOnly), getUserInfo(secOnly)], }, ]) { const reporters = await getReporters({ diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/tags/get_tags.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/tags/get_tags.ts index 0c7237683666f..689f961386755 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/tags/get_tags.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/tags/get_tags.ts @@ -74,17 +74,17 @@ export default ({ getService }: FtrProviderContext): void => { for (const scenario of [ { user: globalRead, - expectedTags: ['sec', 'obs'], + expectedTags: ['obs', 'sec'], }, { user: superUser, - expectedTags: ['sec', 'obs'], + expectedTags: ['obs', 'sec'], }, { user: secOnlyRead, expectedTags: ['sec'] }, { user: obsOnlyRead, expectedTags: ['obs'] }, { user: obsSecRead, - expectedTags: ['sec', 'obs'], + expectedTags: ['obs', 'sec'], }, ]) { const tags = await getTags({ From 2eccc81a451ca985789dc7757e83fb8a9eab3242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Tue, 25 Jan 2022 16:54:16 +0100 Subject: [PATCH 13/46] Allows search by description on event filters (#123615) --- .../public/management/pages/event_filters/constants.ts | 1 + .../pages/event_filters/view/event_filters_list_page.tsx | 2 +- .../view/event_filters/list/policy_event_filters_list.tsx | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts index df77915e5de59..e8338e21dd160 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts @@ -25,6 +25,7 @@ export const EVENT_FILTER_LIST = { export const SEARCHABLE_FIELDS: Readonly = [ `name`, + `description`, `entries.value`, `entries.entries.value`, `comments.comment`, diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx index 899968a8277db..d83bc8d36dd23 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx @@ -291,7 +291,7 @@ export const EventFiltersListPage = memo(() => { defaultValue={location.filter} onSearch={handleOnSearch} placeholder={i18n.translate('xpack.securitySolution.eventFilter.search.placeholder', { - defaultMessage: 'Search on the fields below: name, comments, value', + defaultMessage: 'Search on the fields below: name, description, comments, value', })} hasPolicyFilter policyList={policiesRequest.data?.items} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx index 225497ade8edd..2e2ca9b4835fd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx @@ -166,7 +166,7 @@ export const PolicyEventFiltersList = React.memo(({ placeholder={i18n.translate( 'xpack.securitySolution.endpoint.policy.eventFilters.list.search.placeholder', { - defaultMessage: 'Search on the fields below: name, comments, value', + defaultMessage: 'Search on the fields below: name, description, comments, value', } )} defaultValue={urlParams.filter} From f021f75aea40566f7e11a132d42ab4d521f145df Mon Sep 17 00:00:00 2001 From: Mat Schaffer Date: Wed, 26 Jan 2022 01:00:51 +0900 Subject: [PATCH 14/46] Validate server UUID retrieved from data/uuid using the same match from config validation (#123680) Exports the uuid regexp used in to validate configuration for use in validating the data/uuid file. Also includes a `trim()` to allow newlines that could have been edited automatically via file editing or configuration management. --- .../server/environment/resolve_uuid.test.ts | 36 ++++++++++++++----- src/core/server/environment/resolve_uuid.ts | 25 ++++++++----- src/core/server/http/http_config.ts | 3 +- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/core/server/environment/resolve_uuid.test.ts b/src/core/server/environment/resolve_uuid.test.ts index 3ee65e8ac99cf..75a229bb13e12 100644 --- a/src/core/server/environment/resolve_uuid.test.ts +++ b/src/core/server/environment/resolve_uuid.test.ts @@ -22,8 +22,8 @@ jest.mock('./fs', () => ({ writeFile: jest.fn(() => Promise.resolve('')), })); -const DEFAULT_FILE_UUID = 'FILE_UUID'; -const DEFAULT_CONFIG_UUID = 'CONFIG_UUID'; +const DEFAULT_FILE_UUID = 'ffffffff-bbbb-0ccc-0ddd-eeeeeeeeeeee'; +const DEFAULT_CONFIG_UUID = 'cccccccc-bbbb-0ccc-0ddd-eeeeeeeeeeee'; const fileNotFoundError = { code: 'ENOENT' }; const permissionError = { code: 'EACCES' }; const isDirectoryError = { code: 'EISDIR' }; @@ -91,7 +91,7 @@ describe('resolveInstanceUuid', () => { expect(logger.debug).toHaveBeenCalledTimes(1); expect(logger.debug.mock.calls[0]).toMatchInlineSnapshot(` Array [ - "Updating Kibana instance UUID to: CONFIG_UUID (was: FILE_UUID)", + "Updating Kibana instance UUID to: cccccccc-bbbb-0ccc-0ddd-eeeeeeeeeeee (was: ffffffff-bbbb-0ccc-0ddd-eeeeeeeeeeee)", ] `); }); @@ -106,7 +106,7 @@ describe('resolveInstanceUuid', () => { expect(logger.debug).toHaveBeenCalledTimes(1); expect(logger.debug.mock.calls[0]).toMatchInlineSnapshot(` Array [ - "Kibana instance UUID: CONFIG_UUID", + "Kibana instance UUID: cccccccc-bbbb-0ccc-0ddd-eeeeeeeeeeee", ] `); }); @@ -126,25 +126,45 @@ describe('resolveInstanceUuid', () => { expect(logger.debug).toHaveBeenCalledTimes(1); expect(logger.debug.mock.calls[0]).toMatchInlineSnapshot(` Array [ - "Setting new Kibana instance UUID: CONFIG_UUID", + "Setting new Kibana instance UUID: cccccccc-bbbb-0ccc-0ddd-eeeeeeeeeeee", ] `); }); }); describe('when file is present and config property is not set', () => { - it('does not write to file and returns the file uuid', async () => { + beforeEach(() => { serverConfig = createServerConfig(undefined); + }); + + it('does not write to file and returns the file uuid', async () => { const uuid = await resolveInstanceUuid({ pathConfig, serverConfig, logger }); expect(uuid).toEqual(DEFAULT_FILE_UUID); expect(writeFile).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledTimes(1); expect(logger.debug.mock.calls[0]).toMatchInlineSnapshot(` Array [ - "Resuming persistent Kibana instance UUID: FILE_UUID", + "Resuming persistent Kibana instance UUID: ffffffff-bbbb-0ccc-0ddd-eeeeeeeeeeee", ] `); }); + + describe('when file contains an invalid uuid', () => { + it('throws an explicit error for uuid formatting', async () => { + mockReadFile({ uuid: 'invalid uuid in data file' }); + await expect( + resolveInstanceUuid({ pathConfig, serverConfig, logger }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"data-folder/uuid contains an invalid UUID"`); + }); + }); + + describe('when file contains a trailing new line', () => { + it('returns the trimmed file uuid', async () => { + mockReadFile({ uuid: DEFAULT_FILE_UUID + '\n' }); + const uuid = await resolveInstanceUuid({ pathConfig, serverConfig, logger }); + expect(uuid).toEqual(DEFAULT_FILE_UUID); + }); + }); }); describe('when file is present with 7.6.0 UUID', () => { @@ -193,7 +213,7 @@ describe('resolveInstanceUuid', () => { "UUID from 7.6.0 bug detected, ignoring file UUID", ], Array [ - "Setting new Kibana instance UUID: CONFIG_UUID", + "Setting new Kibana instance UUID: cccccccc-bbbb-0ccc-0ddd-eeeeeeeeeeee", ], ] `); diff --git a/src/core/server/environment/resolve_uuid.ts b/src/core/server/environment/resolve_uuid.ts index 15c67d027bd83..c4ad3e32cfada 100644 --- a/src/core/server/environment/resolve_uuid.ts +++ b/src/core/server/environment/resolve_uuid.ts @@ -12,6 +12,7 @@ import { PathConfigType } from '@kbn/utils'; import { readFile, writeFile } from './fs'; import { HttpConfigType } from '../http'; import { Logger } from '../logging'; +import { uuidRegexp } from '../http/http_config'; const FILE_ENCODING = 'utf8'; const FILE_NAME = 'uuid'; @@ -63,16 +64,24 @@ export async function resolveInstanceUuid({ } async function readUuidFromFile(filepath: string, logger: Logger): Promise { + const content = await readFileContent(filepath); + + if (content === UUID_7_6_0_BUG) { + logger.debug(`UUID from 7.6.0 bug detected, ignoring file UUID`); + return undefined; + } + + if (content && !content.match(uuidRegexp)) { + throw new Error(`${filepath} contains an invalid UUID`); + } + + return content; +} + +async function readFileContent(filepath: string): Promise { try { const content = await readFile(filepath); - const decoded = content.toString(FILE_ENCODING); - - if (decoded === UUID_7_6_0_BUG) { - logger.debug(`UUID from 7.6.0 bug detected, ignoring file UUID`); - return undefined; - } else { - return decoded; - } + return content.toString(FILE_ENCODING).trim(); } catch (e) { if (e.code === 'ENOENT') { // non-existent uuid file is ok, we will create it. diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index a6a133753c3fe..22af901c20a98 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -21,7 +21,8 @@ import { } from './security_response_headers_config'; const validBasePathRegex = /^\/.*[^\/]$/; -const uuidRegexp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +export const uuidRegexp = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const hostURISchema = schema.uri({ scheme: ['http', 'https'] }); const match = (regex: RegExp, errorMsg: string) => (str: string) => regex.test(str) ? undefined : errorMsg; From 5819cfb1bf9affe6c10b69a347a4c27755e99a78 Mon Sep 17 00:00:00 2001 From: Thom Heymann <190132+thomheymann@users.noreply.github.com> Date: Tue, 25 Jan 2022 16:26:53 +0000 Subject: [PATCH 15/46] Add audit logging to space deletion (#123378) * Add audit logging to space deletion * Fix outcome * Delete all non-global saved objects * Added suggestions from code review * Fix tests --- .../server/authorization/audit_logger.test.ts | 1 + .../authorization/authorization.test.ts | 1 + .../alerts_client_factory.test.ts | 1 + .../tests/bulk_update.test.ts | 1 + .../tests/find_alerts.test.ts | 1 + .../alert_data_client/tests/get.test.ts | 1 + .../alert_data_client/tests/update.test.ts | 1 + .../security/server/audit/audit_events.ts | 6 --- .../server/audit/audit_service.test.ts | 1 + .../security/server/audit/audit_service.ts | 14 +++++- .../security/server/audit/index.mock.ts | 2 + .../authentication/authenticator.test.ts | 4 ++ x-pack/plugins/security/server/plugin.test.ts | 1 + .../secure_spaces_client_wrapper.test.ts | 43 +++++++++++++++++-- .../spaces/secure_spaces_client_wrapper.ts | 34 +++++++++++++++ .../spaces_client/spaces_client.mock.ts | 12 ++++-- .../spaces_client/spaces_client.test.ts | 26 +++++++---- .../server/spaces_client/spaces_client.ts | 23 +++++++++- .../spaces_client/spaces_client_service.ts | 14 ++++-- 19 files changed, 158 insertions(+), 29 deletions(-) diff --git a/x-pack/plugins/cases/server/authorization/audit_logger.test.ts b/x-pack/plugins/cases/server/authorization/audit_logger.test.ts index 48c6e9ebcd07a..c2f00e8cfff05 100644 --- a/x-pack/plugins/cases/server/authorization/audit_logger.test.ts +++ b/x-pack/plugins/cases/server/authorization/audit_logger.test.ts @@ -32,6 +32,7 @@ describe('audit_logger', () => { describe('log function', () => { const mockLogger: jest.Mocked = { log: jest.fn(), + enabled: true, }; let logger: AuthorizationAuditLogger; diff --git a/x-pack/plugins/cases/server/authorization/authorization.test.ts b/x-pack/plugins/cases/server/authorization/authorization.test.ts index f644f7366100b..693277161c330 100644 --- a/x-pack/plugins/cases/server/authorization/authorization.test.ts +++ b/x-pack/plugins/cases/server/authorization/authorization.test.ts @@ -24,6 +24,7 @@ describe('authorization', () => { request = httpServerMock.createKibanaRequest(); mockLogger = { log: jest.fn(), + enabled: true, }; }); diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts index 276ea070d6f87..af531e8ae8e12 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts @@ -46,6 +46,7 @@ const fakeRequest = { const auditLogger = { log: jest.fn(), + enabled: true, } as jest.Mocked; describe('AlertsClientFactory', () => { diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/bulk_update.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/bulk_update.test.ts index 92f5ea4517d3f..09861278cd5d5 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/tests/bulk_update.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/bulk_update.test.ts @@ -25,6 +25,7 @@ const alertingAuthMock = alertingAuthorizationMock.create(); const esClientMock = elasticsearchClientMock.createElasticsearchClient(); const auditLogger = { log: jest.fn(), + enabled: true, } as jest.Mocked; const alertsClientParams: jest.Mocked = { diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/find_alerts.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/find_alerts.test.ts index 5f9a20c14ea5b..bfff95b5d601b 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/tests/find_alerts.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/find_alerts.test.ts @@ -24,6 +24,7 @@ const alertingAuthMock = alertingAuthorizationMock.create(); const esClientMock = elasticsearchClientMock.createElasticsearchClient(); const auditLogger = { log: jest.fn(), + enabled: true, } as jest.Mocked; const alertsClientParams: jest.Mocked = { diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts index eaf6c0089ce12..0c74cc1463410 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts @@ -25,6 +25,7 @@ const alertingAuthMock = alertingAuthorizationMock.create(); const esClientMock = elasticsearchClientMock.createElasticsearchClient(); const auditLogger = { log: jest.fn(), + enabled: true, } as jest.Mocked; const alertsClientParams: jest.Mocked = { diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts index 85527e26a9cd3..0dcfc602bc281 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts @@ -24,6 +24,7 @@ const alertingAuthMock = alertingAuthorizationMock.create(); const esClientMock = elasticsearchClientMock.createElasticsearchClient(); const auditLogger = { log: jest.fn(), + enabled: true, } as jest.Mocked; const alertsClientParams: jest.Mocked = { diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts index 2dfaf8ece004f..37b2cecfa55c1 100644 --- a/x-pack/plugins/security/server/audit/audit_events.ts +++ b/x-pack/plugins/security/server/audit/audit_events.ts @@ -218,8 +218,6 @@ export enum SavedObjectAction { UPDATE = 'saved_object_update', DELETE = 'saved_object_delete', FIND = 'saved_object_find', - ADD_TO_SPACES = 'saved_object_add_to_spaces', - DELETE_FROM_SPACES = 'saved_object_delete_from_spaces', REMOVE_REFERENCES = 'saved_object_remove_references', OPEN_POINT_IN_TIME = 'saved_object_open_point_in_time', CLOSE_POINT_IN_TIME = 'saved_object_close_point_in_time', @@ -236,8 +234,6 @@ const savedObjectAuditVerbs: Record = { saved_object_update: ['update', 'updating', 'updated'], saved_object_delete: ['delete', 'deleting', 'deleted'], saved_object_find: ['access', 'accessing', 'accessed'], - saved_object_add_to_spaces: ['update', 'updating', 'updated'], - saved_object_delete_from_spaces: ['update', 'updating', 'updated'], saved_object_open_point_in_time: [ 'open point-in-time', 'opening point-in-time', @@ -272,8 +268,6 @@ const savedObjectAuditTypes: Record = { saved_object_update: 'change', saved_object_delete: 'deletion', saved_object_find: 'access', - saved_object_add_to_spaces: 'change', - saved_object_delete_from_spaces: 'change', saved_object_open_point_in_time: 'creation', saved_object_close_point_in_time: 'deletion', saved_object_remove_references: 'change', diff --git a/x-pack/plugins/security/server/audit/audit_service.test.ts b/x-pack/plugins/security/server/audit/audit_service.test.ts index 1815f617dceae..eb1a22e0b3543 100644 --- a/x-pack/plugins/security/server/audit/audit_service.test.ts +++ b/x-pack/plugins/security/server/audit/audit_service.test.ts @@ -68,6 +68,7 @@ describe('#setup', () => { Object { "asScoped": [Function], "withoutRequest": Object { + "enabled": true, "log": [Function], }, } diff --git a/x-pack/plugins/security/server/audit/audit_service.ts b/x-pack/plugins/security/server/audit/audit_service.ts index a29ec221b3474..6f81164be5a89 100644 --- a/x-pack/plugins/security/server/audit/audit_service.ts +++ b/x-pack/plugins/security/server/audit/audit_service.ts @@ -49,6 +49,14 @@ export interface AuditLogger { * ``` */ log: (event: AuditEvent | undefined) => void; + + /** + * Indicates whether audit logging is enabled or not. + * + * Useful for skipping resource-intense operations that don't need to be performed when audit + * logging is disabled. + */ + readonly enabled: boolean; } export interface AuditServiceSetup { @@ -122,7 +130,8 @@ export class AuditService { ); // Record feature usage at a regular interval if enabled and license allows - if (config.enabled && config.appender) { + const enabled = !!(config.enabled && config.appender); + if (enabled) { license.features$.subscribe((features) => { clearInterval(this.usageIntervalId!); if (features.allowAuditLogging) { @@ -169,6 +178,7 @@ export class AuditService { trace: { id: request.id }, }); }, + enabled, }); http.registerOnPostAuth((request, response, t) => { @@ -180,7 +190,7 @@ export class AuditService { return { asScoped, - withoutRequest: { log }, + withoutRequest: { log, enabled }, }; } diff --git a/x-pack/plugins/security/server/audit/index.mock.ts b/x-pack/plugins/security/server/audit/index.mock.ts index c84faacff0147..6ac9108b51a83 100644 --- a/x-pack/plugins/security/server/audit/index.mock.ts +++ b/x-pack/plugins/security/server/audit/index.mock.ts @@ -13,9 +13,11 @@ export const auditServiceMock = { getLogger: jest.fn(), asScoped: jest.fn().mockReturnValue({ log: jest.fn(), + enabled: true, }), withoutRequest: { log: jest.fn(), + enabled: true, }, } as jest.Mocked>; }, diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 62ca6168584fb..3685ea28c08b7 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -264,6 +264,7 @@ describe('Authenticator', () => { let mockSessVal: SessionValue; const auditLogger = { log: jest.fn(), + enabled: true, }; beforeEach(() => { @@ -1094,6 +1095,7 @@ describe('Authenticator', () => { let mockSessVal: SessionValue; const auditLogger = { log: jest.fn(), + enabled: true, }; beforeEach(() => { @@ -2009,6 +2011,7 @@ describe('Authenticator', () => { let mockSessVal: SessionValue; const auditLogger = { log: jest.fn(), + enabled: true, }; beforeEach(() => { @@ -2145,6 +2148,7 @@ describe('Authenticator', () => { let mockSessionValue: SessionValue; const auditLogger = { log: jest.fn(), + enabled: true, }; beforeEach(() => { diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 85c2fff5a438e..f4294ecbb6c11 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -68,6 +68,7 @@ describe('Security Plugin', () => { "audit": Object { "asScoped": [Function], "withoutRequest": Object { + "enabled": false, "log": [Function], }, }, diff --git a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts index 7f62948251d7c..2e39810d4cbde 100644 --- a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts @@ -8,7 +8,11 @@ import { mockEnsureAuthorized } from './secure_spaces_client_wrapper.test.mocks'; import { deepFreeze } from '@kbn/std'; -import type { EcsEventOutcome, SavedObjectsClientContract } from 'src/core/server'; +import type { + EcsEventOutcome, + SavedObjectsClientContract, + SavedObjectsFindResponse, +} from 'src/core/server'; import { SavedObjectsErrorHelpers } from 'src/core/server'; import { httpServerMock } from 'src/core/server/mocks'; @@ -63,6 +67,31 @@ const setup = ({ securityEnabled = false }: Opts = {}) => { return space; }); + baseClient.createSavedObjectFinder.mockImplementation(() => ({ + async *find() { + yield { + saved_objects: [ + { + namespaces: ['*'], + type: 'dashboard', + id: '1', + }, + { + namespaces: ['existing_space'], + type: 'dashboard', + id: '2', + }, + { + namespaces: ['default', 'existing_space'], + type: 'dashboard', + id: '3', + }, + ], + } as SavedObjectsFindResponse; + }, + async close() {}, + })); + const authorization = authorizationMock.create({ version: 'unit-test', applicationName: 'kibana', @@ -602,7 +631,7 @@ describe('SecureSpacesClientWrapper', () => { }); }); - test(`throws a forbidden error when unauthorized`, async () => { + it(`throws a forbidden error when unauthorized`, async () => { const username = 'some_user'; const { wrapper, baseClient, authorization, auditLogger, request } = setup({ @@ -637,7 +666,7 @@ describe('SecureSpacesClientWrapper', () => { }); }); - it('deletes the space when authorized', async () => { + it('deletes the space with all saved objects when authorized', async () => { const username = 'some_user'; const { wrapper, baseClient, authorization, auditLogger, request } = setup({ @@ -669,6 +698,14 @@ describe('SecureSpacesClientWrapper', () => { type: 'space', id: space.id, }); + expectAuditEvent(auditLogger, SavedObjectAction.DELETE, 'unknown', { + type: 'dashboard', + id: '2', + }); + expectAuditEvent(auditLogger, SavedObjectAction.UPDATE_OBJECTS_SPACES, 'unknown', { + type: 'dashboard', + id: '3', + }); }); }); diff --git a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts index 9d20a6ea40b24..c43216643205f 100644 --- a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts +++ b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts @@ -17,6 +17,7 @@ import type { LegacyUrlAliasTarget, Space, } from '../../../spaces/server'; +import { ALL_SPACES_ID } from '../../common/constants'; import type { AuditLogger } from '../audit'; import { SavedObjectAction, savedObjectEvent, SpaceAuditAction, spaceAuditEvent } from '../audit'; import type { AuthorizationServiceSetup } from '../authorization'; @@ -246,6 +247,10 @@ export class SecureSpacesClientWrapper implements ISpacesClient { return this.spacesClient.update(id, space); } + public createSavedObjectFinder(id: string) { + return this.spacesClient.createSavedObjectFinder(id); + } + public async delete(id: string) { if (this.useRbac) { try { @@ -265,6 +270,35 @@ export class SecureSpacesClientWrapper implements ISpacesClient { } } + // Fetch saved objects to be removed for audit logging + if (this.auditLogger.enabled) { + const finder = this.spacesClient.createSavedObjectFinder(id); + try { + for await (const response of finder.find()) { + response.saved_objects.forEach((savedObject) => { + const { namespaces = [] } = savedObject; + const isOnlySpace = namespaces.length === 1; // We can always rely on the `namespaces` field having >=1 element + if (namespaces.includes(ALL_SPACES_ID) && !namespaces.includes(id)) { + // This object exists in All Spaces and its `namespaces` field isn't going to change; there's nothing to audit + return; + } + this.auditLogger.log( + savedObjectEvent({ + action: isOnlySpace + ? SavedObjectAction.DELETE + : SavedObjectAction.UPDATE_OBJECTS_SPACES, + outcome: 'unknown', + savedObject: { type: savedObject.type, id: savedObject.id }, + deleteFromSpaces: [id], + }) + ); + }); + } + } finally { + await finder.close(); + } + } + this.auditLogger.log( spaceAuditEvent({ action: SpaceAuditAction.DELETE, diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts index ed47aed72fba2..97a26447ad297 100644 --- a/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts @@ -5,12 +5,15 @@ * 2.0. */ +import { savedObjectsRepositoryMock } from 'src/core/server/mocks'; + import type { Space } from '../../common'; import { DEFAULT_SPACE_ID } from '../../common/constants'; import type { SpacesClient } from './spaces_client'; -const createSpacesClientMock = () => - ({ +const createSpacesClientMock = () => { + const repositoryMock = savedObjectsRepositoryMock.create(); + return { getAll: jest.fn().mockResolvedValue([ { id: DEFAULT_SPACE_ID, @@ -28,10 +31,11 @@ const createSpacesClientMock = () => }), create: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)), update: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)), + createSavedObjectFinder: repositoryMock.createPointInTimeFinder, delete: jest.fn(), disableLegacyUrlAliases: jest.fn(), - } as unknown as jest.Mocked); - + } as unknown as jest.Mocked; +}; export const spacesClientMock = { create: createSpacesClientMock, }; diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts index e594306f5ee3a..86b2886d2461a 100644 --- a/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts @@ -77,7 +77,7 @@ describe('#getAll', () => { } as any); const mockConfig = createMockConfig({ maxSpaces: 1234 }); - const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []); const actualSpaces = await client.getAll(); expect(actualSpaces).toEqual(expectedSpaces); @@ -90,7 +90,10 @@ describe('#getAll', () => { }); test(`throws Boom.badRequest when an invalid purpose is provided'`, async () => { - const client = new SpacesClient(null as any, null as any, null as any); + const mockDebugLogger = createMockDebugLogger(); + const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); + const mockConfig = createMockConfig(); + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []); await expect( client.getAll({ purpose: 'invalid_purpose' as GetAllSpacesPurpose }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"unsupported space purpose: invalid_purpose"`); @@ -122,7 +125,7 @@ describe('#get', () => { const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); mockCallWithRequestRepository.get.mockResolvedValue(savedObject); - const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []); const id = savedObject.id; const actualSpace = await client.get(id); @@ -181,7 +184,7 @@ describe('#create', () => { const mockConfig = createMockConfig({ maxSpaces }); - const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []); const actualSpace = await client.create(spaceToCreate); @@ -207,7 +210,7 @@ describe('#create', () => { const mockConfig = createMockConfig({ maxSpaces }); - const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []); expect(client.create(spaceToCreate)).rejects.toThrowErrorMatchingInlineSnapshot( `"Unable to create Space, this exceeds the maximum number of spaces set by the xpack.spaces.maxSpaces setting"` @@ -267,7 +270,7 @@ describe('#update', () => { const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); mockCallWithRequestRepository.get.mockResolvedValue(savedObject); - const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []); const id = savedObject.id; const actualSpace = await client.update(id, spaceToUpdate); @@ -309,7 +312,7 @@ describe('#delete', () => { const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); mockCallWithRequestRepository.get.mockResolvedValue(reservedSavedObject); - const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []); expect(client.delete(id)).rejects.toThrowErrorMatchingInlineSnapshot( `"The foo space cannot be deleted because it is reserved."` @@ -324,7 +327,7 @@ describe('#delete', () => { const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); mockCallWithRequestRepository.get.mockResolvedValue(notReservedSavedObject); - const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []); await client.delete(id); @@ -339,7 +342,12 @@ describe('#delete', () => { const mockConfig = createMockConfig(); const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); - const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); + const client = new SpacesClient( + mockDebugLogger, + mockConfig, + mockCallWithRequestRepository, + [] + ); const aliases = [ { targetSpace: 'space1', targetType: 'foo', sourceId: '123' }, { targetSpace: 'space2', targetType: 'bar', sourceId: '456' }, diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts index 0a91c7aff1a08..2bd51a13bc642 100644 --- a/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts @@ -8,7 +8,11 @@ import Boom from '@hapi/boom'; import { omit } from 'lodash'; -import type { ISavedObjectsRepository, SavedObject } from 'src/core/server'; +import type { + ISavedObjectsPointInTimeFinder, + ISavedObjectsRepository, + SavedObject, +} from 'src/core/server'; import type { GetAllSpacesOptions, @@ -58,6 +62,13 @@ export interface ISpacesClient { */ update(id: string, space: Space): Promise; + /** + * Returns a {@link ISavedObjectsPointInTimeFinder} to help page through + * saved objects within the specified space. + * @param id the id of the space to search. + */ + createSavedObjectFinder(id: string): ISavedObjectsPointInTimeFinder; + /** * Deletes a space, and all saved objects belonging to that space. * @param id the id of the space to delete. @@ -78,7 +89,8 @@ export class SpacesClient implements ISpacesClient { constructor( private readonly debugLogger: (message: string) => void, private readonly config: ConfigType, - private readonly repository: ISavedObjectsRepository + private readonly repository: ISavedObjectsRepository, + private readonly nonGlobalTypeNames: string[] ) {} public async getAll(options: GetAllSpacesOptions = {}): Promise { @@ -136,6 +148,13 @@ export class SpacesClient implements ISpacesClient { return this.transformSavedObjectToSpace(updatedSavedObject); } + public createSavedObjectFinder(id: string) { + return this.repository.createPointInTimeFinder({ + type: this.nonGlobalTypeNames, + namespaces: [id], + }); + } + public async delete(id: string) { const existingSavedObject = await this.repository.get('space', id); if (isReservedSpace(this.transformSavedObjectToSpace(existingSavedObject))) { diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.ts index 6580a2d57f040..cf7766decc2dd 100644 --- a/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.ts +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.ts @@ -96,20 +96,28 @@ export class SpacesClientService { } public start(coreStart: CoreStart): SpacesClientServiceStart { + const nonGlobalTypes = coreStart.savedObjects + .getTypeRegistry() + .getAllTypes() + .filter((x) => x.namespaceType !== 'agnostic'); + const nonGlobalTypeNames = nonGlobalTypes.map((x) => x.name); + if (!this.repositoryFactory) { + const hiddenTypeNames = nonGlobalTypes.filter((x) => x.hidden).map((x) => x.name); this.repositoryFactory = (request, savedObjectsStart) => - savedObjectsStart.createScopedRepository(request, ['space']); + savedObjectsStart.createScopedRepository(request, [...hiddenTypeNames, 'space']); } + return { createSpacesClient: (request: KibanaRequest) => { if (!this.config) { throw new Error('Initialization error: spaces config is not available'); } - const baseClient = new SpacesClient( this.debugLogger, this.config, - this.repositoryFactory!(request, coreStart.savedObjects) + this.repositoryFactory!(request, coreStart.savedObjects), + nonGlobalTypeNames ); if (this.clientWrapper) { return this.clientWrapper(request, baseClient); From 291fa7100168e9867ee031e4e2476e6a3b13d09a Mon Sep 17 00:00:00 2001 From: stuart nelson Date: Tue, 25 Jan 2022 17:31:21 +0100 Subject: [PATCH 16/46] update typo in watcher docs (#123690) --- docs/management/watcher-ui/index.asciidoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/management/watcher-ui/index.asciidoc b/docs/management/watcher-ui/index.asciidoc index 4f68ac83d9622..4e874c362606f 100644 --- a/docs/management/watcher-ui/index.asciidoc +++ b/docs/management/watcher-ui/index.asciidoc @@ -8,7 +8,7 @@ Watches are helpful for analyzing mission-critical and business-critical streaming data. For example, you might watch application logs for performance outages or audit access logs for security threats. -To get started, open then main menu, +To get started, open the main menu, then click *Stack Management > Watcher*. With this UI, you can: @@ -43,7 +43,7 @@ and either of these watcher roles: * `watcher_admin`. You can perform all Watcher actions, including create and edit watches. * `watcher_user`. You can view watches, but not create or edit them. -To manage roles, open then main menu, then click *Stack Management > Roles*, or use the +To manage roles, open the main menu, then click *Stack Management > Roles*, or use the <>. Watches are shared between all users with the same role. From ed2c3b65bb181f7d9691309fca6dc223602203c6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 25 Jan 2022 10:02:17 -0700 Subject: [PATCH 17/46] Update dependency core-js to ^3.20.3 (main) (#123544) Co-authored-by: Renovate Bot --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 69c83edf2c8dc..69a69bcbd5805 100644 --- a/package.json +++ b/package.json @@ -214,7 +214,7 @@ "constate": "^1.3.2", "content-disposition": "0.5.3", "copy-to-clipboard": "^3.0.8", - "core-js": "^3.20.2", + "core-js": "^3.20.3", "cronstrue": "^1.51.0", "cytoscape": "^3.10.0", "cytoscape-dagre": "^2.2.2", diff --git a/yarn.lock b/yarn.lock index 5bd438ba1a180..b349030577ad4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10557,10 +10557,10 @@ core-js@^2.4.0, core-js@^2.5.0, core-js@^2.6.9: resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2" integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A== -core-js@^3.0.4, core-js@^3.20.2, core-js@^3.6.5, core-js@^3.8.2, core-js@^3.8.3: - version "3.20.2" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.20.2.tgz#46468d8601eafc8b266bd2dd6bf9dee622779581" - integrity sha512-nuqhq11DcOAbFBV4zCbKeGbKQsUDRqTX0oqx7AttUBuqe3h20ixsE039QHelbL6P4h+9kytVqyEtyZ6gsiwEYw== +core-js@^3.0.4, core-js@^3.20.3, core-js@^3.6.5, core-js@^3.8.2, core-js@^3.8.3: + version "3.20.3" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.20.3.tgz#c710d0a676e684522f3db4ee84e5e18a9d11d69a" + integrity sha512-vVl8j8ph6tRS3B8qir40H7yw7voy17xL0piAjlbBUsH7WIfzoedL/ZOr1OV9FyZQLWXsayOJyV4tnRyXR85/ag== core-util-is@1.0.2, core-util-is@^1.0.2, core-util-is@~1.0.0: version "1.0.2" From 831a40f8140355859b10ca03426b7b0923a2105a Mon Sep 17 00:00:00 2001 From: Kaarina Tungseth Date: Tue, 25 Jan 2022 11:11:06 -0600 Subject: [PATCH 18/46] [DOCS} Adds 8.0.0-rc2 release notes (#123395) * [DOCS} Adds 8.0.0-rc2 release notes * Update docs/CHANGELOG.asciidoc Co-authored-by: Lisa Cawley * Update docs/CHANGELOG.asciidoc Co-authored-by: Lisa Cawley * Update docs/CHANGELOG.asciidoc Co-authored-by: Lisa Cawley * Update docs/CHANGELOG.asciidoc Co-authored-by: Lisa Cawley Co-authored-by: Lisa Cawley --- docs/CHANGELOG.asciidoc | 93 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 4 deletions(-) diff --git a/docs/CHANGELOG.asciidoc b/docs/CHANGELOG.asciidoc index b406ced798c0c..03bccef3b822e 100644 --- a/docs/CHANGELOG.asciidoc +++ b/docs/CHANGELOG.asciidoc @@ -10,12 +10,101 @@ Review important information about the {kib} 8.0.0 releases. +* <> * <> * <> * <> * <> -- +[[release-notes-8.0.0-rc2]] +== {kib} 8.0.0-rc2 + +coming::[8.0.0-rc2] + +For information about the {kib} 8.0.0-rc2 release, review the following information. + +[float] +[[breaking-changes-8.0.0-rc2]] +=== Breaking change + +Breaking changes can prevent your application from optimal operation and performance. +Before you upgrade, review the breaking change, then mitigate the impact to your application. + +// tag::notable-breaking-changes[] + +[discrete] +[[breaking-122722]] +.Removes the ability to use `elasticsearch.username: elastic` in production +[%collapsible] +==== +*Details* + +In production, you are no longer able to use the `elastic` superuser to authenticate to {es}. For more information, refer to {kibana-pull}122722[#122722]. + +*Impact* + +When you configure `elasticsearch.username: elastic`, {kib} fails. +==== + +// end::notable-breaking-changes[] + +To review the breaking changes in previous versions, refer to the following: + +<> | <> | <> | +<> + +[float] +[[features-8.0.0-rc2]] +=== Features +{kib} 8.0.0-rc2 adds the following new and notable features. + +Dashboard:: +Dashboard Integration {kibana-pull}115991[#115991] +Elastic Security:: +For the Elastic Security 8.0.0-rc2 release information, refer to {security-guide}/release-notes.html[_Elastic Security Solution Release Notes_]. +Monitoring:: +Enterprise Search Stack Monitoring {kibana-pull}114303[#114303] +Observability:: +* Adds Agent Keys in APM settings - Create agent keys {kibana-pull}120373[#120373] +* Adds Agent Keys in APM settings - Agent key table {kibana-pull}119543[#119543] +* Allows users to set Download Speed, Upload Speed, and Latency for their synthetic monitors in Uptime {kibana-pull}118594[#118594] +Platform:: +Changes saved objects management inspect view to a read-only JSON view of the whole saved object {kibana-pull}112034[#112034] + +[[enhancements-and-bug-fixes-v8.0.0-rc2]] +=== Enhancements and bug fixes + +For detailed information about the 8.0.0-rc2 release, review the enhancements and bug fixes. + +[float] +[[enhancement-v8.0.0-rc2]] +==== Enhancements +Elastic Security:: +For the Elastic Security 8.0.0-rc2 release information, refer to {security-guide}/release-notes.html[_Elastic Security Solution Release Notes_]. +Security:: +Adds session cleanup audit logging {kibana-pull}122419[#122419] +Observability:: +Make a monitor's steps details page work on mobile resolutions in Uptime {kibana-pull}122171[#122171] + +[float] +[[fixes-v8.0.0-rc2]] +==== Bug Fixes +Alerting:: +Fixes PagerDuty timestamp validation {kibana-pull}122321[#122321] +Dashboard:: +* Creates Explicit Diffing System {kibana-pull}121241[#121241] +* Fixes blank panel save and display issue {kibana-pull}120815[#120815] +* Fixes full screen error when pressing back arrow on browser {kibana-pull}118113[#118113] +Elastic Security:: +For the Elastic Security 8.0.0-rc2 release information, refer to {security-guide}/release-notes.html[_Elastic Security Solution Release Notes_]. +Maps:: +* Fixes Point to point and Tracks layers label properties not showing in the legend {kibana-pull}122993[#122993] +* Fixes Color ramp UI for percent of a top term in join layer is broken {kibana-pull}122718[#122718] +Observability:: +* Updates index pattern permission error in APM {kibana-pull}122680[#122680] +* Honor time unit for Inventory Threshold in Metrics {kibana-pull}122294[#122294] +* Adds locator to aid other plugins in linking properly to Uptime {kibana-pull}123004[#123004] +* Fixes a bug in which headers would be incorrectly centered on desktop in Uptime {kibana-pull}122643[#122643] + [[release-notes-8.0.0-rc1]] == {kib} 8.0.0-rc1 @@ -28,8 +117,6 @@ Review the {kib} 8.0.0-rc1 changes, then use the <> | <> | From cd06e5f5af4d4efec0bbcab3aaa2e956a6753b40 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Tue, 25 Jan 2022 09:38:54 -0800 Subject: [PATCH 19/46] Revert "Add audit logging to space deletion (#123378)" This reverts commit 5819cfb1bf9affe6c10b69a347a4c27755e99a78. --- .../server/authorization/audit_logger.test.ts | 1 - .../authorization/authorization.test.ts | 1 - .../alerts_client_factory.test.ts | 1 - .../tests/bulk_update.test.ts | 1 - .../tests/find_alerts.test.ts | 1 - .../alert_data_client/tests/get.test.ts | 1 - .../alert_data_client/tests/update.test.ts | 1 - .../security/server/audit/audit_events.ts | 6 +++ .../server/audit/audit_service.test.ts | 1 - .../security/server/audit/audit_service.ts | 14 +----- .../security/server/audit/index.mock.ts | 2 - .../authentication/authenticator.test.ts | 4 -- x-pack/plugins/security/server/plugin.test.ts | 1 - .../secure_spaces_client_wrapper.test.ts | 43 ++----------------- .../spaces/secure_spaces_client_wrapper.ts | 34 --------------- .../spaces_client/spaces_client.mock.ts | 12 ++---- .../spaces_client/spaces_client.test.ts | 26 ++++------- .../server/spaces_client/spaces_client.ts | 23 +--------- .../spaces_client/spaces_client_service.ts | 14 ++---- 19 files changed, 29 insertions(+), 158 deletions(-) diff --git a/x-pack/plugins/cases/server/authorization/audit_logger.test.ts b/x-pack/plugins/cases/server/authorization/audit_logger.test.ts index c2f00e8cfff05..48c6e9ebcd07a 100644 --- a/x-pack/plugins/cases/server/authorization/audit_logger.test.ts +++ b/x-pack/plugins/cases/server/authorization/audit_logger.test.ts @@ -32,7 +32,6 @@ describe('audit_logger', () => { describe('log function', () => { const mockLogger: jest.Mocked = { log: jest.fn(), - enabled: true, }; let logger: AuthorizationAuditLogger; diff --git a/x-pack/plugins/cases/server/authorization/authorization.test.ts b/x-pack/plugins/cases/server/authorization/authorization.test.ts index 693277161c330..f644f7366100b 100644 --- a/x-pack/plugins/cases/server/authorization/authorization.test.ts +++ b/x-pack/plugins/cases/server/authorization/authorization.test.ts @@ -24,7 +24,6 @@ describe('authorization', () => { request = httpServerMock.createKibanaRequest(); mockLogger = { log: jest.fn(), - enabled: true, }; }); diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts index af531e8ae8e12..276ea070d6f87 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts @@ -46,7 +46,6 @@ const fakeRequest = { const auditLogger = { log: jest.fn(), - enabled: true, } as jest.Mocked; describe('AlertsClientFactory', () => { diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/bulk_update.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/bulk_update.test.ts index 09861278cd5d5..92f5ea4517d3f 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/tests/bulk_update.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/bulk_update.test.ts @@ -25,7 +25,6 @@ const alertingAuthMock = alertingAuthorizationMock.create(); const esClientMock = elasticsearchClientMock.createElasticsearchClient(); const auditLogger = { log: jest.fn(), - enabled: true, } as jest.Mocked; const alertsClientParams: jest.Mocked = { diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/find_alerts.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/find_alerts.test.ts index bfff95b5d601b..5f9a20c14ea5b 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/tests/find_alerts.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/find_alerts.test.ts @@ -24,7 +24,6 @@ const alertingAuthMock = alertingAuthorizationMock.create(); const esClientMock = elasticsearchClientMock.createElasticsearchClient(); const auditLogger = { log: jest.fn(), - enabled: true, } as jest.Mocked; const alertsClientParams: jest.Mocked = { diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts index 0c74cc1463410..eaf6c0089ce12 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts @@ -25,7 +25,6 @@ const alertingAuthMock = alertingAuthorizationMock.create(); const esClientMock = elasticsearchClientMock.createElasticsearchClient(); const auditLogger = { log: jest.fn(), - enabled: true, } as jest.Mocked; const alertsClientParams: jest.Mocked = { diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts index 0dcfc602bc281..85527e26a9cd3 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts @@ -24,7 +24,6 @@ const alertingAuthMock = alertingAuthorizationMock.create(); const esClientMock = elasticsearchClientMock.createElasticsearchClient(); const auditLogger = { log: jest.fn(), - enabled: true, } as jest.Mocked; const alertsClientParams: jest.Mocked = { diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts index 37b2cecfa55c1..2dfaf8ece004f 100644 --- a/x-pack/plugins/security/server/audit/audit_events.ts +++ b/x-pack/plugins/security/server/audit/audit_events.ts @@ -218,6 +218,8 @@ export enum SavedObjectAction { UPDATE = 'saved_object_update', DELETE = 'saved_object_delete', FIND = 'saved_object_find', + ADD_TO_SPACES = 'saved_object_add_to_spaces', + DELETE_FROM_SPACES = 'saved_object_delete_from_spaces', REMOVE_REFERENCES = 'saved_object_remove_references', OPEN_POINT_IN_TIME = 'saved_object_open_point_in_time', CLOSE_POINT_IN_TIME = 'saved_object_close_point_in_time', @@ -234,6 +236,8 @@ const savedObjectAuditVerbs: Record = { saved_object_update: ['update', 'updating', 'updated'], saved_object_delete: ['delete', 'deleting', 'deleted'], saved_object_find: ['access', 'accessing', 'accessed'], + saved_object_add_to_spaces: ['update', 'updating', 'updated'], + saved_object_delete_from_spaces: ['update', 'updating', 'updated'], saved_object_open_point_in_time: [ 'open point-in-time', 'opening point-in-time', @@ -268,6 +272,8 @@ const savedObjectAuditTypes: Record = { saved_object_update: 'change', saved_object_delete: 'deletion', saved_object_find: 'access', + saved_object_add_to_spaces: 'change', + saved_object_delete_from_spaces: 'change', saved_object_open_point_in_time: 'creation', saved_object_close_point_in_time: 'deletion', saved_object_remove_references: 'change', diff --git a/x-pack/plugins/security/server/audit/audit_service.test.ts b/x-pack/plugins/security/server/audit/audit_service.test.ts index eb1a22e0b3543..1815f617dceae 100644 --- a/x-pack/plugins/security/server/audit/audit_service.test.ts +++ b/x-pack/plugins/security/server/audit/audit_service.test.ts @@ -68,7 +68,6 @@ describe('#setup', () => { Object { "asScoped": [Function], "withoutRequest": Object { - "enabled": true, "log": [Function], }, } diff --git a/x-pack/plugins/security/server/audit/audit_service.ts b/x-pack/plugins/security/server/audit/audit_service.ts index 6f81164be5a89..a29ec221b3474 100644 --- a/x-pack/plugins/security/server/audit/audit_service.ts +++ b/x-pack/plugins/security/server/audit/audit_service.ts @@ -49,14 +49,6 @@ export interface AuditLogger { * ``` */ log: (event: AuditEvent | undefined) => void; - - /** - * Indicates whether audit logging is enabled or not. - * - * Useful for skipping resource-intense operations that don't need to be performed when audit - * logging is disabled. - */ - readonly enabled: boolean; } export interface AuditServiceSetup { @@ -130,8 +122,7 @@ export class AuditService { ); // Record feature usage at a regular interval if enabled and license allows - const enabled = !!(config.enabled && config.appender); - if (enabled) { + if (config.enabled && config.appender) { license.features$.subscribe((features) => { clearInterval(this.usageIntervalId!); if (features.allowAuditLogging) { @@ -178,7 +169,6 @@ export class AuditService { trace: { id: request.id }, }); }, - enabled, }); http.registerOnPostAuth((request, response, t) => { @@ -190,7 +180,7 @@ export class AuditService { return { asScoped, - withoutRequest: { log, enabled }, + withoutRequest: { log }, }; } diff --git a/x-pack/plugins/security/server/audit/index.mock.ts b/x-pack/plugins/security/server/audit/index.mock.ts index 6ac9108b51a83..c84faacff0147 100644 --- a/x-pack/plugins/security/server/audit/index.mock.ts +++ b/x-pack/plugins/security/server/audit/index.mock.ts @@ -13,11 +13,9 @@ export const auditServiceMock = { getLogger: jest.fn(), asScoped: jest.fn().mockReturnValue({ log: jest.fn(), - enabled: true, }), withoutRequest: { log: jest.fn(), - enabled: true, }, } as jest.Mocked>; }, diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 3685ea28c08b7..62ca6168584fb 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -264,7 +264,6 @@ describe('Authenticator', () => { let mockSessVal: SessionValue; const auditLogger = { log: jest.fn(), - enabled: true, }; beforeEach(() => { @@ -1095,7 +1094,6 @@ describe('Authenticator', () => { let mockSessVal: SessionValue; const auditLogger = { log: jest.fn(), - enabled: true, }; beforeEach(() => { @@ -2011,7 +2009,6 @@ describe('Authenticator', () => { let mockSessVal: SessionValue; const auditLogger = { log: jest.fn(), - enabled: true, }; beforeEach(() => { @@ -2148,7 +2145,6 @@ describe('Authenticator', () => { let mockSessionValue: SessionValue; const auditLogger = { log: jest.fn(), - enabled: true, }; beforeEach(() => { diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index f4294ecbb6c11..85c2fff5a438e 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -68,7 +68,6 @@ describe('Security Plugin', () => { "audit": Object { "asScoped": [Function], "withoutRequest": Object { - "enabled": false, "log": [Function], }, }, diff --git a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts index 2e39810d4cbde..7f62948251d7c 100644 --- a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts @@ -8,11 +8,7 @@ import { mockEnsureAuthorized } from './secure_spaces_client_wrapper.test.mocks'; import { deepFreeze } from '@kbn/std'; -import type { - EcsEventOutcome, - SavedObjectsClientContract, - SavedObjectsFindResponse, -} from 'src/core/server'; +import type { EcsEventOutcome, SavedObjectsClientContract } from 'src/core/server'; import { SavedObjectsErrorHelpers } from 'src/core/server'; import { httpServerMock } from 'src/core/server/mocks'; @@ -67,31 +63,6 @@ const setup = ({ securityEnabled = false }: Opts = {}) => { return space; }); - baseClient.createSavedObjectFinder.mockImplementation(() => ({ - async *find() { - yield { - saved_objects: [ - { - namespaces: ['*'], - type: 'dashboard', - id: '1', - }, - { - namespaces: ['existing_space'], - type: 'dashboard', - id: '2', - }, - { - namespaces: ['default', 'existing_space'], - type: 'dashboard', - id: '3', - }, - ], - } as SavedObjectsFindResponse; - }, - async close() {}, - })); - const authorization = authorizationMock.create({ version: 'unit-test', applicationName: 'kibana', @@ -631,7 +602,7 @@ describe('SecureSpacesClientWrapper', () => { }); }); - it(`throws a forbidden error when unauthorized`, async () => { + test(`throws a forbidden error when unauthorized`, async () => { const username = 'some_user'; const { wrapper, baseClient, authorization, auditLogger, request } = setup({ @@ -666,7 +637,7 @@ describe('SecureSpacesClientWrapper', () => { }); }); - it('deletes the space with all saved objects when authorized', async () => { + it('deletes the space when authorized', async () => { const username = 'some_user'; const { wrapper, baseClient, authorization, auditLogger, request } = setup({ @@ -698,14 +669,6 @@ describe('SecureSpacesClientWrapper', () => { type: 'space', id: space.id, }); - expectAuditEvent(auditLogger, SavedObjectAction.DELETE, 'unknown', { - type: 'dashboard', - id: '2', - }); - expectAuditEvent(auditLogger, SavedObjectAction.UPDATE_OBJECTS_SPACES, 'unknown', { - type: 'dashboard', - id: '3', - }); }); }); diff --git a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts index c43216643205f..9d20a6ea40b24 100644 --- a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts +++ b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts @@ -17,7 +17,6 @@ import type { LegacyUrlAliasTarget, Space, } from '../../../spaces/server'; -import { ALL_SPACES_ID } from '../../common/constants'; import type { AuditLogger } from '../audit'; import { SavedObjectAction, savedObjectEvent, SpaceAuditAction, spaceAuditEvent } from '../audit'; import type { AuthorizationServiceSetup } from '../authorization'; @@ -247,10 +246,6 @@ export class SecureSpacesClientWrapper implements ISpacesClient { return this.spacesClient.update(id, space); } - public createSavedObjectFinder(id: string) { - return this.spacesClient.createSavedObjectFinder(id); - } - public async delete(id: string) { if (this.useRbac) { try { @@ -270,35 +265,6 @@ export class SecureSpacesClientWrapper implements ISpacesClient { } } - // Fetch saved objects to be removed for audit logging - if (this.auditLogger.enabled) { - const finder = this.spacesClient.createSavedObjectFinder(id); - try { - for await (const response of finder.find()) { - response.saved_objects.forEach((savedObject) => { - const { namespaces = [] } = savedObject; - const isOnlySpace = namespaces.length === 1; // We can always rely on the `namespaces` field having >=1 element - if (namespaces.includes(ALL_SPACES_ID) && !namespaces.includes(id)) { - // This object exists in All Spaces and its `namespaces` field isn't going to change; there's nothing to audit - return; - } - this.auditLogger.log( - savedObjectEvent({ - action: isOnlySpace - ? SavedObjectAction.DELETE - : SavedObjectAction.UPDATE_OBJECTS_SPACES, - outcome: 'unknown', - savedObject: { type: savedObject.type, id: savedObject.id }, - deleteFromSpaces: [id], - }) - ); - }); - } - } finally { - await finder.close(); - } - } - this.auditLogger.log( spaceAuditEvent({ action: SpaceAuditAction.DELETE, diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts index 97a26447ad297..ed47aed72fba2 100644 --- a/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts @@ -5,15 +5,12 @@ * 2.0. */ -import { savedObjectsRepositoryMock } from 'src/core/server/mocks'; - import type { Space } from '../../common'; import { DEFAULT_SPACE_ID } from '../../common/constants'; import type { SpacesClient } from './spaces_client'; -const createSpacesClientMock = () => { - const repositoryMock = savedObjectsRepositoryMock.create(); - return { +const createSpacesClientMock = () => + ({ getAll: jest.fn().mockResolvedValue([ { id: DEFAULT_SPACE_ID, @@ -31,11 +28,10 @@ const createSpacesClientMock = () => { }), create: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)), update: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)), - createSavedObjectFinder: repositoryMock.createPointInTimeFinder, delete: jest.fn(), disableLegacyUrlAliases: jest.fn(), - } as unknown as jest.Mocked; -}; + } as unknown as jest.Mocked); + export const spacesClientMock = { create: createSpacesClientMock, }; diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts index 86b2886d2461a..e594306f5ee3a 100644 --- a/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts @@ -77,7 +77,7 @@ describe('#getAll', () => { } as any); const mockConfig = createMockConfig({ maxSpaces: 1234 }); - const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []); + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); const actualSpaces = await client.getAll(); expect(actualSpaces).toEqual(expectedSpaces); @@ -90,10 +90,7 @@ describe('#getAll', () => { }); test(`throws Boom.badRequest when an invalid purpose is provided'`, async () => { - const mockDebugLogger = createMockDebugLogger(); - const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); - const mockConfig = createMockConfig(); - const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []); + const client = new SpacesClient(null as any, null as any, null as any); await expect( client.getAll({ purpose: 'invalid_purpose' as GetAllSpacesPurpose }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"unsupported space purpose: invalid_purpose"`); @@ -125,7 +122,7 @@ describe('#get', () => { const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); mockCallWithRequestRepository.get.mockResolvedValue(savedObject); - const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []); + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); const id = savedObject.id; const actualSpace = await client.get(id); @@ -184,7 +181,7 @@ describe('#create', () => { const mockConfig = createMockConfig({ maxSpaces }); - const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []); + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); const actualSpace = await client.create(spaceToCreate); @@ -210,7 +207,7 @@ describe('#create', () => { const mockConfig = createMockConfig({ maxSpaces }); - const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []); + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); expect(client.create(spaceToCreate)).rejects.toThrowErrorMatchingInlineSnapshot( `"Unable to create Space, this exceeds the maximum number of spaces set by the xpack.spaces.maxSpaces setting"` @@ -270,7 +267,7 @@ describe('#update', () => { const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); mockCallWithRequestRepository.get.mockResolvedValue(savedObject); - const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []); + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); const id = savedObject.id; const actualSpace = await client.update(id, spaceToUpdate); @@ -312,7 +309,7 @@ describe('#delete', () => { const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); mockCallWithRequestRepository.get.mockResolvedValue(reservedSavedObject); - const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []); + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); expect(client.delete(id)).rejects.toThrowErrorMatchingInlineSnapshot( `"The foo space cannot be deleted because it is reserved."` @@ -327,7 +324,7 @@ describe('#delete', () => { const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); mockCallWithRequestRepository.get.mockResolvedValue(notReservedSavedObject); - const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []); + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); await client.delete(id); @@ -342,12 +339,7 @@ describe('#delete', () => { const mockConfig = createMockConfig(); const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); - const client = new SpacesClient( - mockDebugLogger, - mockConfig, - mockCallWithRequestRepository, - [] - ); + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); const aliases = [ { targetSpace: 'space1', targetType: 'foo', sourceId: '123' }, { targetSpace: 'space2', targetType: 'bar', sourceId: '456' }, diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts index 2bd51a13bc642..0a91c7aff1a08 100644 --- a/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts @@ -8,11 +8,7 @@ import Boom from '@hapi/boom'; import { omit } from 'lodash'; -import type { - ISavedObjectsPointInTimeFinder, - ISavedObjectsRepository, - SavedObject, -} from 'src/core/server'; +import type { ISavedObjectsRepository, SavedObject } from 'src/core/server'; import type { GetAllSpacesOptions, @@ -62,13 +58,6 @@ export interface ISpacesClient { */ update(id: string, space: Space): Promise; - /** - * Returns a {@link ISavedObjectsPointInTimeFinder} to help page through - * saved objects within the specified space. - * @param id the id of the space to search. - */ - createSavedObjectFinder(id: string): ISavedObjectsPointInTimeFinder; - /** * Deletes a space, and all saved objects belonging to that space. * @param id the id of the space to delete. @@ -89,8 +78,7 @@ export class SpacesClient implements ISpacesClient { constructor( private readonly debugLogger: (message: string) => void, private readonly config: ConfigType, - private readonly repository: ISavedObjectsRepository, - private readonly nonGlobalTypeNames: string[] + private readonly repository: ISavedObjectsRepository ) {} public async getAll(options: GetAllSpacesOptions = {}): Promise { @@ -148,13 +136,6 @@ export class SpacesClient implements ISpacesClient { return this.transformSavedObjectToSpace(updatedSavedObject); } - public createSavedObjectFinder(id: string) { - return this.repository.createPointInTimeFinder({ - type: this.nonGlobalTypeNames, - namespaces: [id], - }); - } - public async delete(id: string) { const existingSavedObject = await this.repository.get('space', id); if (isReservedSpace(this.transformSavedObjectToSpace(existingSavedObject))) { diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.ts index cf7766decc2dd..6580a2d57f040 100644 --- a/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.ts +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client_service.ts @@ -96,28 +96,20 @@ export class SpacesClientService { } public start(coreStart: CoreStart): SpacesClientServiceStart { - const nonGlobalTypes = coreStart.savedObjects - .getTypeRegistry() - .getAllTypes() - .filter((x) => x.namespaceType !== 'agnostic'); - const nonGlobalTypeNames = nonGlobalTypes.map((x) => x.name); - if (!this.repositoryFactory) { - const hiddenTypeNames = nonGlobalTypes.filter((x) => x.hidden).map((x) => x.name); this.repositoryFactory = (request, savedObjectsStart) => - savedObjectsStart.createScopedRepository(request, [...hiddenTypeNames, 'space']); + savedObjectsStart.createScopedRepository(request, ['space']); } - return { createSpacesClient: (request: KibanaRequest) => { if (!this.config) { throw new Error('Initialization error: spaces config is not available'); } + const baseClient = new SpacesClient( this.debugLogger, this.config, - this.repositoryFactory!(request, coreStart.savedObjects), - nonGlobalTypeNames + this.repositoryFactory!(request, coreStart.savedObjects) ); if (this.clientWrapper) { return this.clientWrapper(request, baseClient); From c2bd8d127fbbbe8f99c58fa2c7d7438d4bc08418 Mon Sep 17 00:00:00 2001 From: Peter Pisljar Date: Tue, 25 Jan 2022 18:58:55 +0100 Subject: [PATCH 20/46] updating persistable state types (#123340) --- examples/locator_explorer/public/app.tsx | 6 +++++- .../common/lib/get_all_migrations.test.ts | 14 ++++++++++++-- .../embeddable/common/lib/get_all_migrations.ts | 12 ++++++------ src/plugins/embeddable/common/lib/migrate.ts | 14 ++++++++++---- .../expressions/common/executor/executor.ts | 6 ++++-- .../expression_functions/expression_function.ts | 11 ++++++----- src/plugins/kibana_utils/common/index.ts | 1 + .../kibana_utils/common/persistable_state/index.ts | 1 + .../kibana_utils/common/persistable_state/types.ts | 4 +++- .../common/url_service/locators/locator_client.ts | 3 ++- .../url_service/redirect/redirect_manager.ts | 4 +++- .../functions/server/filters.test.ts | 3 ++- .../saved_object_types/migrations/comments.ts | 8 ++++---- .../make_lens_embeddable_factory.test.ts | 6 +++++- .../public/dynamic_actions/action_factory.ts | 7 +++++-- 15 files changed, 69 insertions(+), 31 deletions(-) diff --git a/examples/locator_explorer/public/app.tsx b/examples/locator_explorer/public/app.tsx index 923944833e9e0..c1e3da665d63a 100644 --- a/examples/locator_explorer/public/app.tsx +++ b/examples/locator_explorer/public/app.tsx @@ -84,7 +84,11 @@ const ActionsExplorer = ({ share }: Props) => { if (!locator) return; let params: HelloLocatorV1Params | HelloLocatorV2Params = savedLink.params; if (savedLink.version === '0.0.1') { - const migration = locator.migrations['0.0.2']; + const migrations = + typeof locator.migrations === 'function' + ? locator.migrations() + : locator.migrations || {}; + const migration = migrations['0.0.2']; if (migration) { params = migration(params) as HelloLocatorV2Params; } diff --git a/src/plugins/embeddable/common/lib/get_all_migrations.test.ts b/src/plugins/embeddable/common/lib/get_all_migrations.test.ts index 03f60b3da73c4..edd754fe3be9d 100644 --- a/src/plugins/embeddable/common/lib/get_all_migrations.test.ts +++ b/src/plugins/embeddable/common/lib/get_all_migrations.test.ts @@ -9,8 +9,14 @@ import { getAllMigrations } from './get_all_migrations'; describe('embeddable getAllMigratons', () => { - const factories = [{ migrations: { '7.11.0': (state: unknown) => state } }]; - const enhacements = [{ migrations: { '7.12.0': (state: unknown) => state } }]; + const factories = [ + { migrations: { '7.11.0': (state: unknown) => state } }, + { migrations: () => ({ '7.13.0': (state: unknown) => state }) }, + ]; + const enhacements = [ + { migrations: { '7.12.0': (state: unknown) => state } }, + { migrations: () => ({ '7.14.0': (state: unknown) => state }) }, + ]; const migrateFn = jest.fn(); test('returns base migrations', () => { @@ -19,16 +25,20 @@ describe('embeddable getAllMigratons', () => { test('returns embeddable factory migrations', () => { expect(getAllMigrations(factories, [], migrateFn)).toHaveProperty(['7.11.0']); + expect(getAllMigrations(factories, [], migrateFn)).toHaveProperty(['7.13.0']); }); test('returns enhancement migrations', () => { const migrations = getAllMigrations([], enhacements, migrateFn); expect(migrations).toHaveProperty(['7.12.0']); + expect(migrations).toHaveProperty(['7.14.0']); }); test('returns all migrations', () => { const migrations = getAllMigrations(factories, enhacements, migrateFn); expect(migrations).toHaveProperty(['7.11.0']); expect(migrations).toHaveProperty(['7.12.0']); + expect(migrations).toHaveProperty(['7.13.0']); + expect(migrations).toHaveProperty(['7.14.0']); }); }); diff --git a/src/plugins/embeddable/common/lib/get_all_migrations.ts b/src/plugins/embeddable/common/lib/get_all_migrations.ts index 8e3233f447a3d..7ab11089f2ef0 100644 --- a/src/plugins/embeddable/common/lib/get_all_migrations.ts +++ b/src/plugins/embeddable/common/lib/get_all_migrations.ts @@ -23,14 +23,14 @@ export const getAllMigrations = ( uniqueVersions.add(baseMigrationVersion); } for (const factory of factories) { - Object.keys((factory as PersistableState).migrations).forEach((version) => - uniqueVersions.add(version) - ); + const migrations = (factory as PersistableState).migrations; + const factoryMigrations = typeof migrations === 'function' ? migrations() : migrations; + Object.keys(factoryMigrations).forEach((version) => uniqueVersions.add(version)); } for (const enhancement of enhancements) { - Object.keys((enhancement as PersistableState).migrations).forEach((version) => - uniqueVersions.add(version) - ); + const migrations = (enhancement as PersistableState).migrations; + const enhancementMigrations = typeof migrations === 'function' ? migrations() : migrations; + Object.keys(enhancementMigrations).forEach((version) => uniqueVersions.add(version)); } const migrations: MigrateFunctionsObject = {}; diff --git a/src/plugins/embeddable/common/lib/migrate.ts b/src/plugins/embeddable/common/lib/migrate.ts index a57f058242717..f71adb21270fe 100644 --- a/src/plugins/embeddable/common/lib/migrate.ts +++ b/src/plugins/embeddable/common/lib/migrate.ts @@ -21,8 +21,10 @@ export const getMigrateFunction = (embeddables: CommonEmbeddableStartContract) = ? baseEmbeddableMigrations[version](state) : state; - if (factory?.migrations[version]) { - updatedInput = factory.migrations[version](updatedInput); + const factoryMigrations = + typeof factory?.migrations === 'function' ? factory?.migrations() : factory?.migrations || {}; + if (factoryMigrations[version]) { + updatedInput = factoryMigrations[version](updatedInput); } if (factory?.isContainerType) { @@ -35,8 +37,12 @@ export const getMigrateFunction = (embeddables: CommonEmbeddableStartContract) = Object.keys(enhancements).forEach((key) => { if (!enhancements[key]) return; const enhancementDefinition = embeddables.getEnhancement(key); - const migratedEnhancement = enhancementDefinition?.migrations?.[version] - ? enhancementDefinition.migrations[version](enhancements[key] as SerializableRecord) + const enchantmentMigrations = + typeof enhancementDefinition?.migrations === 'function' + ? enhancementDefinition?.migrations() + : enhancementDefinition?.migrations || {}; + const migratedEnhancement = enchantmentMigrations[version] + ? enchantmentMigrations[version](enhancements[key] as SerializableRecord) : enhancements[key]; (updatedInput.enhancements! as Record)[key] = migratedEnhancement; }); diff --git a/src/plugins/expressions/common/executor/executor.ts b/src/plugins/expressions/common/executor/executor.ts index 86516344031a0..722083e1c739a 100644 --- a/src/plugins/expressions/common/executor/executor.ts +++ b/src/plugins/expressions/common/executor/executor.ts @@ -352,11 +352,13 @@ export class Executor = Record { - if (!fn.migrations[version]) { + const migrations = + typeof fn.migrations === 'function' ? fn.migrations() : fn.migrations || {}; + if (!migrations[version]) { return link; } - return fn.migrations[version](link) as ExpressionAstExpression; + return migrations[version](link) as ExpressionAstExpression; }); } diff --git a/src/plugins/expressions/common/expression_functions/expression_function.ts b/src/plugins/expressions/common/expression_functions/expression_function.ts index 8154534b32ab1..4ae51ff018f1b 100644 --- a/src/plugins/expressions/common/expression_functions/expression_function.ts +++ b/src/plugins/expressions/common/expression_functions/expression_function.ts @@ -7,13 +7,16 @@ */ import { identity } from 'lodash'; -import type { SerializableRecord } from '@kbn/utility-types'; import { AnyExpressionFunctionDefinition } from './types'; import { ExpressionFunctionParameter } from './expression_function_parameter'; import { ExpressionValue } from '../expression_types/types'; import { ExpressionAstFunction } from '../ast'; import { SavedObjectReference } from '../../../../core/types'; -import { PersistableState } from '../../../kibana_utils/common'; +import { + MigrateFunctionsObject, + GetMigrationFunctionObjectFn, + PersistableState, +} from '../../../kibana_utils/common'; export class ExpressionFunction implements PersistableState { /** @@ -70,9 +73,7 @@ export class ExpressionFunction implements PersistableState ExpressionAstFunction['arguments']; - migrations: { - [key: string]: (state: SerializableRecord) => SerializableRecord; - }; + migrations: MigrateFunctionsObject | GetMigrationFunctionObjectFn; constructor(functionDefinition: AnyExpressionFunctionDefinition) { const { diff --git a/src/plugins/kibana_utils/common/index.ts b/src/plugins/kibana_utils/common/index.ts index 1862bf207813d..8a7d9bfaf4aa4 100644 --- a/src/plugins/kibana_utils/common/index.ts +++ b/src/plugins/kibana_utils/common/index.ts @@ -60,6 +60,7 @@ export type { PersistableStateMigrateFn, MigrateFunction, MigrateFunctionsObject, + GetMigrationFunctionObjectFn, PersistableState, PersistableStateDefinition, } from './persistable_state'; diff --git a/src/plugins/kibana_utils/common/persistable_state/index.ts b/src/plugins/kibana_utils/common/persistable_state/index.ts index 3d7d78cd5daac..6bafe07e8bcaf 100644 --- a/src/plugins/kibana_utils/common/persistable_state/index.ts +++ b/src/plugins/kibana_utils/common/persistable_state/index.ts @@ -14,6 +14,7 @@ export type { PersistableStateService, MigrateFunctionsObject, MigrateFunction, + GetMigrationFunctionObjectFn, } from './types'; export { migrateToLatest } from './migrate_to_latest'; export { mergeMigrationFunctionMaps } from './merge_migration_function_map'; diff --git a/src/plugins/kibana_utils/common/persistable_state/types.ts b/src/plugins/kibana_utils/common/persistable_state/types.ts index db757ac662686..5491396b19615 100644 --- a/src/plugins/kibana_utils/common/persistable_state/types.ts +++ b/src/plugins/kibana_utils/common/persistable_state/types.ts @@ -82,9 +82,11 @@ export interface PersistableState

MigrateFunctionsObject; + /** * Collection of migrations that a given type of persistable state object has * accumulated over time. Migration functions are keyed using semver version diff --git a/src/plugins/share/common/url_service/locators/locator_client.ts b/src/plugins/share/common/url_service/locators/locator_client.ts index 7dd69165be5dd..13e051a7c5128 100644 --- a/src/plugins/share/common/url_service/locators/locator_client.ts +++ b/src/plugins/share/common/url_service/locators/locator_client.ts @@ -58,7 +58,8 @@ export class LocatorClient implements ILocatorClient { const migrations: { [locatorId: string]: MigrateFunctionsObject } = {}; for (const locator of this.locators.values()) { - migrations[locator.id] = locator.migrations; + migrations[locator.id] = + typeof locator.migrations === 'function' ? locator.migrations() : locator.migrations; } return migrations; diff --git a/src/plugins/share/public/url_service/redirect/redirect_manager.ts b/src/plugins/share/public/url_service/redirect/redirect_manager.ts index 9d7357eab310c..b7b2d39589c33 100644 --- a/src/plugins/share/public/url_service/redirect/redirect_manager.ts +++ b/src/plugins/share/public/url_service/redirect/redirect_manager.ts @@ -60,7 +60,9 @@ export class RedirectManager { throw error; } - const migratedParams = migrateToLatest(locator.migrations, { + const locatorMigrations = + typeof locator.migrations === 'function' ? locator.migrations() : locator.migrations; + const migratedParams = migrateToLatest(locatorMigrations, { state: options.params, version: options.version, }); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/filters.test.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/filters.test.ts index 75bd97421e58e..5ff1f8773979e 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/filters.test.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/filters.test.ts @@ -14,7 +14,8 @@ describe('filters migrations', () => { const expression = 'filters group="1" group="3" ungrouped=true'; const ast = fromExpression(expression); it('8.1.0. Should migrate `filters` expression to `kibana | selectFilter`', () => { - const migratedAst = migrations?.['8.1.0'](ast.chain[0]); + const migrationObj = typeof migrations === 'function' ? migrations() : migrations || {}; + const migratedAst = migrationObj['8.1.0'](ast.chain[0]); expect(migratedAst !== null && typeof migratedAst === 'object').toBeTruthy(); expect(migratedAst.type).toBe('expression'); expect(Array.isArray(migratedAst.chain)).toBeTruthy(); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/comments.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/comments.ts index 5ab1dab784f56..4ca28cedb494b 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/comments.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/comments.ts @@ -52,13 +52,13 @@ export interface CreateCommentsMigrationsDeps { export const createCommentsMigrations = ( migrationDeps: CreateCommentsMigrationsDeps ): SavedObjectMigrationMap => { + const lensMigrations = migrationDeps.lensEmbeddableFactory().migrations; + const lensMigrationObject = + typeof lensMigrations === 'function' ? lensMigrations() : lensMigrations || {}; const embeddableMigrations = mapValues< MigrateFunctionsObject, SavedObjectMigrationFn<{ comment?: string }> - >( - migrationDeps.lensEmbeddableFactory().migrations, - migrateByValueLensVisualizations - ) as MigrateFunctionsObject; + >(lensMigrationObject, migrateByValueLensVisualizations) as MigrateFunctionsObject; const commentsMigrations = { '7.11.0': ( diff --git a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.test.ts b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.test.ts index 5f4c69593d270..a1773c5c482c8 100644 --- a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.test.ts +++ b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.test.ts @@ -52,7 +52,11 @@ describe('embeddable migrations', () => { }, })()?.migrations; - const migratedLensDoc = embeddableMigrationVersions?.[migrationVersion](lensVisualizationDoc); + const migrations = + typeof embeddableMigrationVersions === 'function' + ? embeddableMigrationVersions() + : embeddableMigrationVersions || {}; + const migratedLensDoc = migrations[migrationVersion](lensVisualizationDoc); expect(migratedLensDoc).toEqual({ attributes: { diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts index 4115e3febe2bd..21e053c3e5c36 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts @@ -6,7 +6,10 @@ */ import type { UiComponent, CollectConfigProps } from 'src/plugins/kibana_utils/public'; -import type { MigrateFunctionsObject } from 'src/plugins/kibana_utils/common'; +import type { + MigrateFunctionsObject, + GetMigrationFunctionObjectFn, +} from 'src/plugins/kibana_utils/common'; import { uiToReactComponent } from '../../../../../src/plugins/kibana_react/public'; import type { UiActionsPresentable as Presentable, @@ -51,7 +54,7 @@ export class ActionFactory< public readonly ReactCollectConfig: React.FC>; public readonly createConfig: (context: FactoryContext) => Config; public readonly isConfigValid: (config: Config, context: FactoryContext) => boolean; - public readonly migrations: MigrateFunctionsObject; + public readonly migrations: MigrateFunctionsObject | GetMigrationFunctionObjectFn; constructor( protected readonly def: ActionFactoryDefinition, From 607feecb203add42f1eca66f2a3177919ff947ee Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Tue, 25 Jan 2022 11:03:01 -0700 Subject: [PATCH 21/46] Added type fixes for case cache in case it's null/undefined (#123643) ## Summary See this PR from here: https://github.com/elastic/kibana/pull/123094 Where `"rule": { "id": null, "name": null },` can be null. This just adds guards around it to prevent possible errors. Note, I tested it first and there aren't errors with this even if we don't merge but that is not a guarantee that later NodeJS wouldn't cause errors if the implementation details of [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) change. Note, I don't try to do any additional lookups if these are `null` as the release is coming very quickly and I do not want to overcomplicate telemetry and we don't have dashboards around the cases feature. Over time I would expect the telemetry to become more accurate again even if cases are `nulled` out. **Manual testing** Either create a true upgrade where all the id's changed by going to 7.16 and making a new space, then within that space outside of default creating cases and alerts and then do an upgrade to 8.0.0 ... or ... Downgrade a `case-comments` like so manually in dev tools: ```ts # Get all case-comments to choose an id GET .kibana/_search { "query": { "term": { "type": "cases-comments" } } } ``` ```ts # Downgrades a case comment of id "25554290-7a36-11ec-8d37-0d0e30a77b60" POST .kibana/_update/cases-comments:25554290-7a36-11ec-8d37-0d0e30a77b60 { "script" : { "source": """ ctx._source.migrationVersion['cases-comments'] = "7.16.3"; """, "lang": "painless" } } ``` Restart Kibana and you should query the same `case-comments` and see that the `"rule": { "id": null, "name": null },` are all null. Either way once you have a null rule go to `Advanced Settings -> cluster data` and ensure that you still get metrics flowing and that one is no longer counted but if you create a new one everything still works as expected: Screen Shot 2022-01-24 at 11 48 39 AM ### Checklist - [ ] [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 We still don't have this for the existing telemetry and are running out of time for 8.0.0 to add them. We might re-write this part of telemetry as well so I am not adding tests just yet. --- .../usage/detections/detection_rule_helpers.ts | 13 +++++++------ .../server/usage/detections/types.ts | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts index 8163a73669674..446fc956c0c65 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts @@ -434,12 +434,13 @@ export const getDetectionRuleMetrics = async ( const casesCache = cases.saved_objects.reduce((cache, { attributes: casesObject }) => { const ruleId = casesObject.rule.id; - - const cacheCount = cache.get(ruleId); - if (cacheCount === undefined) { - cache.set(ruleId, 1); - } else { - cache.set(ruleId, cacheCount + 1); + if (ruleId != null) { + const cacheCount = cache.get(ruleId); + if (cacheCount === undefined) { + cache.set(ruleId, 1); + } else { + cache.set(ruleId, cacheCount + 1); + } } return cache; }, new Map()); diff --git a/x-pack/plugins/security_solution/server/usage/detections/types.ts b/x-pack/plugins/security_solution/server/usage/detections/types.ts index b2a9cf7af4861..6b189a1fefb71 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/types.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/types.ts @@ -154,8 +154,8 @@ export interface CasesSavedObject { alertId: string; index: string; rule: { - id: string; - name: string; + id: string | null; + name: string | null; }; } From 57d507c12140fd491e5d60755c795d901c249d39 Mon Sep 17 00:00:00 2001 From: Kristof C Date: Tue, 25 Jan 2022 12:30:50 -0600 Subject: [PATCH 22/46] [Security Solution] add ability for network map to be toggable, prevent map from displaying without permissions (#123336) * add ability for network map to be toggable, prevent map from displaying without permissions * PR & test additions * Found rogue semicolon Co-authored-by: Kristof-Pierre Cummings Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../embeddable_header.test.tsx.snap | 22 ----- .../embeddables/embeddable_header.test.tsx | 56 ------------ .../embeddables/embeddable_header.tsx | 42 --------- .../embeddables/embedded_map.test.tsx | 49 ++++++++++- .../components/embeddables/embedded_map.tsx | 85 +++++++++++++------ .../public/network/pages/network.test.tsx | 68 +++++++++++++++ .../public/network/pages/network.tsx | 31 ++++--- 7 files changed, 197 insertions(+), 156 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/embeddable_header.test.tsx.snap delete mode 100644 x-pack/plugins/security_solution/public/network/components/embeddables/embeddable_header.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/network/components/embeddables/embeddable_header.tsx diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/embeddable_header.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/embeddable_header.test.tsx.snap deleted file mode 100644 index 6d02ccb1c6eb9..0000000000000 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/embeddable_header.test.tsx.snap +++ /dev/null @@ -1,22 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EmbeddableHeader it renders 1`] = ` -

- - - -
- Test title -
-
-
-
-
-`; diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/embeddable_header.test.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embeddable_header.test.tsx deleted file mode 100644 index 3dd949f1b7fef..0000000000000 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/embeddable_header.test.tsx +++ /dev/null @@ -1,56 +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 { mount, shallow } from 'enzyme'; -import React from 'react'; - -import { TestProviders } from '../../../common/mock'; -import { EmbeddableHeader } from './embeddable_header'; - -describe('EmbeddableHeader', () => { - test('it renders', () => { - const wrapper = shallow(); - - expect(wrapper).toMatchSnapshot(); - }); - - test('it renders the title', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="header-embeddable-title"]').first().exists()).toBe(true); - }); - - test('it renders supplements when children provided', () => { - const wrapper = mount( - - -

{'Test children'}

-
-
- ); - - expect(wrapper.find('[data-test-subj="header-embeddable-supplements"]').first().exists()).toBe( - true - ); - }); - - test('it DOES NOT render supplements when children not provided', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="header-embeddable-supplements"]').first().exists()).toBe( - false - ); - }); -}); diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/embeddable_header.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embeddable_header.tsx deleted file mode 100644 index 371a7c145605c..0000000000000 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/embeddable_header.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; -import React from 'react'; -import styled from 'styled-components'; - -const Header = styled.header.attrs(({ className }) => ({ - className: `siemEmbeddable__header ${className}`, -}))` - border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; - padding: ${({ theme }) => theme.eui.paddingSizes.m}; -`; -Header.displayName = 'Header'; - -export interface EmbeddableHeaderProps { - children?: React.ReactNode; - title: string | React.ReactNode; -} - -export const EmbeddableHeader = React.memo(({ children, title }) => ( -
- - - -
{title}
-
-
- - {children && ( - - {children} - - )} -
-
-)); -EmbeddableHeader.displayName = 'EmbeddableHeader'; diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.test.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.test.tsx index 0eecec296c523..4b8a5b6dd9940 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.test.tsx @@ -25,9 +25,13 @@ jest.mock('../../../common/lib/kibana'); jest.mock('./embedded_map_helpers', () => ({ createEmbeddable: jest.fn(), })); + +const mockGetStorage = jest.fn(); +const mockSetStorage = jest.fn(); + jest.mock('../../../common/lib/kibana', () => { return { - useKibana: jest.fn().mockReturnValue({ + useKibana: () => ({ services: { embeddable: { EmbeddablePanel: jest.fn(() =>
), @@ -38,6 +42,10 @@ jest.mock('../../../common/lib/kibana', () => { siem: { networkMap: '' }, }, }, + storage: { + get: mockGetStorage, + set: mockSetStorage, + }, }, }), }; @@ -101,6 +109,11 @@ describe('EmbeddedMapComponent', () => { beforeEach(() => { setQuery.mockClear(); + mockGetStorage.mockReturnValue(false); + }); + + afterEach(() => { + jest.clearAllMocks(); }); test('renders correctly against snapshot', () => { @@ -175,4 +188,38 @@ describe('EmbeddedMapComponent', () => { expect(wrapper.find('[data-test-subj="loading-panel"]').exists()).toEqual(true); }); }); + + test('map hidden on close', async () => { + const wrapper = mount( + + + + ); + + const container = wrapper.find('[data-test-subj="false-toggle-network-map"]').at(0); + container.simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(mockSetStorage).toHaveBeenNthCalledWith(1, 'network_map_visbile', true); + }); + }); + + test('map visible on open', async () => { + mockGetStorage.mockReturnValue(true); + + const wrapper = mount( + + + + ); + + const container = wrapper.find('[data-test-subj="true-toggle-network-map"]').at(0); + container.simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(mockSetStorage).toHaveBeenNthCalledWith(1, 'network_map_visbile', false); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx index 9aa39b9cfda27..803688bf21343 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import { EuiLink, EuiText } from '@elastic/eui'; +import { EuiAccordion, EuiLink, EuiText } from '@elastic/eui'; import deepEqual from 'fast-deep-equal'; -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useCallback, useEffect, useState, useMemo } from 'react'; import { createPortalNode, InPortal } from 'react-reverse-portal'; import styled, { css } from 'styled-components'; @@ -20,7 +20,6 @@ import { Loader } from '../../../common/components/loader'; import { displayErrorToast, useStateToaster } from '../../../common/components/toasters'; import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; import { Embeddable } from './embeddable'; -import { EmbeddableHeader } from './embeddable_header'; import { createEmbeddable } from './embedded_map_helpers'; import { IndexPatternsMissingPrompt } from './index_patterns_missing_prompt'; import { MapToolTip } from './map_tool_tip/map_tool_tip'; @@ -34,6 +33,8 @@ import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { useSourcererDataView } from '../../../common/containers/sourcerer'; +export const NETWORK_MAP_VISIBLE = 'network_map_visbile'; + interface EmbeddableMapProps { maintainRatio?: boolean; } @@ -73,6 +74,17 @@ const EmbeddableMap = styled.div.attrs(() => ({ } `} `; + +const StyledEuiText = styled(EuiText)` + margin-right: 16px; +`; + +const StyledEuiAccordion = styled(EuiAccordion)` + & .euiAccordion__triggerWrapper { + padding: 16px; + } +`; + EmbeddableMap.displayName = 'EmbeddableMap'; export interface EmbeddedMapProps { @@ -93,8 +105,13 @@ export const EmbeddedMapComponent = ({ const [embeddable, setEmbeddable] = React.useState( undefined ); + + const { services } = useKibana(); + const { storage } = services; + const [isError, setIsError] = useState(false); const [isIndexError, setIsIndexError] = useState(false); + const [storageValue, setStorageValue] = useState(storage.get(NETWORK_MAP_VISIBLE) ?? true); const [, dispatchToaster] = useStateToaster(); @@ -115,8 +132,6 @@ export const EmbeddedMapComponent = ({ // Search InPortal/OutPortal for implementation touch points const portalNode = React.useMemo(() => createPortalNode(), []); - const { services } = useKibana(); - useEffect(() => { setMapIndexPatterns((prevMapIndexPatterns) => { const newIndexPatterns = kibanaDataViews.filter((dataView) => @@ -222,30 +237,50 @@ export const EmbeddedMapComponent = ({ } }, [embeddable, startDate, endDate]); + const setDefaultMapVisibility = useCallback( + (isOpen: boolean) => { + storage.set(NETWORK_MAP_VISIBLE, isOpen); + setStorageValue(isOpen); + }, + [storage] + ); + return isError ? null : ( - - - + {i18n.EMBEDDABLE_HEADER_TITLE}} + extraAction={ + {i18n.EMBEDDABLE_HEADER_HELP} - - - - - - - - - {isIndexError ? ( - - ) : embeddable != null ? ( - - ) : ( - - )} - - + + } + paddingSize="none" + initialIsOpen={storageValue} + > + + + + + + + {isIndexError ? ( + + ) : embeddable != null ? ( + + ) : ( + + )} + + + ); }; diff --git a/x-pack/plugins/security_solution/public/network/pages/network.test.tsx b/x-pack/plugins/security_solution/public/network/pages/network.test.tsx index 014beb28c374d..e7f0d415b194b 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.test.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.test.tsx @@ -70,8 +70,41 @@ const mockProps = { capabilitiesFetched: true, hasMlUserPermissions: true, }; + +const mockMapVisibility = jest.fn(); +jest.mock('../../common/lib/kibana', () => { + const original = jest.requireActual('../../common/lib/kibana'); + + return { + ...original, + useKibana: () => ({ + services: { + ...original.useKibana().services, + application: { + ...original.useKibana().services.application, + capabilities: { + siem: { crud_alerts: true, read_alerts: true }, + maps: mockMapVisibility(), + }, + }, + storage: { + get: () => true, + }, + }, + }), + useToasts: jest.fn().mockReturnValue({ + addError: jest.fn(), + addSuccess: jest.fn(), + addWarning: jest.fn(), + }), + }; +}); + const mockUseSourcererDataView = useSourcererDataView as jest.Mock; describe('Network page - rendering', () => { + beforeAll(() => { + mockMapVisibility.mockReturnValue({ show: true }); + }); test('it renders the Setup Instructions text when no index is available', () => { mockUseSourcererDataView.mockReturnValue({ selectedPatterns: [], @@ -106,6 +139,41 @@ describe('Network page - rendering', () => { }); }); + test('it renders the network map if user has permissions', () => { + mockUseSourcererDataView.mockReturnValue({ + selectedPatterns: [], + indicesExist: true, + indexPattern: {}, + }); + + const wrapper = mount( + + + + + + ); + expect(wrapper.find('[data-test-subj="conditional-embeddable-map"]').exists()).toBe(true); + }); + + test('it does not render the network map if user does not have permissions', () => { + mockMapVisibility.mockReturnValue({ show: false }); + mockUseSourcererDataView.mockReturnValue({ + selectedPatterns: [], + indicesExist: true, + indexPattern: {}, + }); + + const wrapper = mount( + + + + + + ); + expect(wrapper.find('[data-test-subj="conditional-embeddable-map"]').exists()).toBe(false); + }); + test('it should add the new filters after init', async () => { const newFilters: Filter[] = [ { diff --git a/x-pack/plugins/security_solution/public/network/pages/network.tsx b/x-pack/plugins/security_solution/public/network/pages/network.tsx index d1d686bfe09df..cee068975b19b 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiSpacer, EuiWindowEvent } from '@elastic/eui'; +import { EuiPanel, EuiSpacer, EuiWindowEvent } from '@elastic/eui'; import { noop } from 'lodash/fp'; import React, { useCallback, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; @@ -85,6 +85,8 @@ const NetworkComponent = React.memo( const kibana = useKibana(); const { tabName } = useParams<{ tabName: string }>(); + const canUseMaps = kibana.services.application.capabilities.maps.show; + const tabsFilters = useMemo(() => { if (tabName === NetworkRouteType.alerts) { return filters.length > 0 ? [...filters, ...filterNetworkData] : filterNetworkData; @@ -173,15 +175,24 @@ const NetworkComponent = React.memo( border /> - - - + {canUseMaps && ( + <> + + + + + + )} Date: Tue, 25 Jan 2022 20:38:32 +0100 Subject: [PATCH 23/46] [Cases] Enhancement: Add createAppMockRenderer method for easier unit testing (#123595) --- .../public/common/mock/test_providers.tsx | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx index 5f0c87168375d..fb38b5833d82c 100644 --- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -10,11 +10,17 @@ import { euiDarkVars } from '@kbn/ui-theme'; import { I18nProvider } from '@kbn/i18n-react'; import { ThemeProvider } from 'styled-components'; +import { render as reactRender, RenderOptions, RenderResult } from '@testing-library/react'; +import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { CasesFeatures } from '../../../common/ui/types'; import { CasesProvider } from '../../components/cases_context'; -import { createKibanaContextProviderMock } from '../lib/kibana/kibana_react.mock'; +import { + createKibanaContextProviderMock, + createStartServicesMock, +} from '../lib/kibana/kibana_react.mock'; import { FieldHook } from '../shared_imports'; +import { StartServices } from '../../types'; interface Props { children: React.ReactNode; @@ -22,6 +28,7 @@ interface Props { features?: CasesFeatures; owner?: string[]; } +type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; window.scrollTo = jest.fn(); const MockKibanaContextProvider = createKibanaContextProviderMock(); @@ -47,6 +54,43 @@ TestProvidersComponent.displayName = 'TestProviders'; export const TestProviders = React.memo(TestProvidersComponent); +export interface AppMockRenderer { + render: UiRender; + coreStart: StartServices; +} + +export const createAppMockRenderer = ({ + features, + owner = [SECURITY_SOLUTION_OWNER], + userCanCrud = true, +}: { + features?: CasesFeatures; + owner?: string[]; + userCanCrud?: boolean; +} = {}): AppMockRenderer => { + const services = createStartServicesMock(); + + const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => ( + + + ({ eui: euiDarkVars, darkMode: true })}> + {children} + + + + ); + const render: UiRender = (ui, options) => { + return reactRender(ui, { + wrapper: AppWrapper as React.ComponentType, + ...options, + }); + }; + return { + coreStart: services, + render, + }; +}; + export const useFormFieldMock = (options?: Partial>): FieldHook => ({ path: 'path', type: 'type', From 09aac9e42de2a122b86c023debb9f7ff8613b8ca Mon Sep 17 00:00:00 2001 From: mgiota Date: Tue, 25 Jan 2022 20:53:28 +0100 Subject: [PATCH 24/46] [RAC][Uptime] Status check reason messages (#123189) * testing out * update getStatusMessage * format interval * temp: keep only statusMessage for now * update translation message * availability check message (previous below threshold) * add availability interval * availability & status check message * i18n check * fix failing tests * fix failing tests * more failing tests * finalize reason format and continue with fixing failing tests * fix more failing tests * fix monitor_status tests * fix uptime functional tests * fix more failing tests * clean up unused stuff * fix failing tests * fix failing tests * more failing tests (pfff) * fix failing tests * move all status check translations in the translations file * a bit of refactoring * temp * rename monitor params to monitor status message params * remove unused PingType * refactoring: move getInterval to another file and create getMonitorDownStatusMessageParams * separate alerts table reason message from alert message in the rule flyout * bring back old alert message format that is used in rule flyout * fix failing tests * remove unused file * refactor status message so that an extra dot does not appear on alert connector message Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - .../runtime_types/alerts/status_check.ts | 1 - x-pack/plugins/uptime/common/translations.ts | 4 +- .../lib/alert_types/monitor_status.test.ts | 2 +- .../public/state/api/alert_actions.test.ts | 8 +- .../uptime/public/state/api/alert_actions.ts | 2 +- .../server/lib/alerts/status_check.test.ts | 108 +++++++++++------- .../uptime/server/lib/alerts/status_check.ts | 105 ++++++++++------- .../uptime/server/lib/alerts/translations.ts | 39 ++++++- .../server/lib/requests/get_monitor_status.ts | 43 +++++++ .../apps/uptime/simple_down_alert.ts | 2 +- 12 files changed, 218 insertions(+), 104 deletions(-) diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4ed57d7b8aaf7..a8c62110c8e47 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -27162,11 +27162,8 @@ "xpack.uptime.alerts.durationAnomaly.description": "アップタイム監視期間が異常なときにアラートを発行します。", "xpack.uptime.alerts.monitorExpression.label": "フィルター{title}を削除", "xpack.uptime.alerts.monitorStatus": "稼働状況の監視ステータス", - "xpack.uptime.alerts.monitorStatus.actionVariables.availabilityMessage": "{availabilityRatio}%のしきい値を下回ります。想定される可用性は{expectedAvailability}%です", "xpack.uptime.alerts.monitorStatus.actionVariables.context.downMonitorsWithGeo.description": "アラートによって「ダウン」と検知された一部またはすべてのモニターを示す、生成された概要。", "xpack.uptime.alerts.monitorStatus.actionVariables.context.message.description": "現在ダウンしているモニターを要約する生成されたメッセージ。", - "xpack.uptime.alerts.monitorStatus.actionVariables.down": "ダウン", - "xpack.uptime.alerts.monitorStatus.actionVariables.downAndAvailabilityMessage": "{statusMessage}と{availabilityMessage}", "xpack.uptime.alerts.monitorStatus.actionVariables.state.currentTriggerStarted": "アラートがトリガーされた場合、現在のトリガー状態が開始するときを示すタイムスタンプ", "xpack.uptime.alerts.monitorStatus.actionVariables.state.firstCheckedAt": "このアラートが最初に確認されるときを示すタイムスタンプ", "xpack.uptime.alerts.monitorStatus.actionVariables.state.firstTriggeredAt": "このアラートが最初にトリガーされたときを示すタイムスタンプ", @@ -27199,7 +27196,6 @@ "xpack.uptime.alerts.monitorStatus.availability.unit.headline": "時間範囲単位を選択します", "xpack.uptime.alerts.monitorStatus.availability.unit.selectable": "この選択を使用して、このアラートの可用性範囲単位を設定", "xpack.uptime.alerts.monitorStatus.clientName": "稼働状況の監視ステータス", - "xpack.uptime.alerts.monitorStatus.defaultActionMessage": "URL {monitorUrl}のモニター{monitorName}は{observerLocation}から{statusMessage}です。最新のエラーメッセージは{latestErrorMessage}です", "xpack.uptime.alerts.monitorStatus.description": "監視が停止しているか、可用性しきい値に違反したときにアラートを発行します。", "xpack.uptime.alerts.monitorStatus.filterBar.ariaLabel": "監視状態アラートのフィルター基準を許可するインプット", "xpack.uptime.alerts.monitorStatus.filters.anyLocation": "任意の場所", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7129a804f921f..390c3551935da 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -27632,11 +27632,8 @@ "xpack.uptime.alerts.durationAnomaly.description": "运行时间监测持续时间异常时告警。", "xpack.uptime.alerts.monitorExpression.label": "移除筛选 {title}", "xpack.uptime.alerts.monitorStatus": "运行时间监测状态", - "xpack.uptime.alerts.monitorStatus.actionVariables.availabilityMessage": "低于阈值,{availabilityRatio}% 可用性应为 {expectedAvailability}%", "xpack.uptime.alerts.monitorStatus.actionVariables.context.downMonitorsWithGeo.description": "生成的摘要,显示告警已检测为“关闭”的部分或所有监测", "xpack.uptime.alerts.monitorStatus.actionVariables.context.message.description": "生成的消息,汇总当前关闭的监测", - "xpack.uptime.alerts.monitorStatus.actionVariables.down": "关闭", - "xpack.uptime.alerts.monitorStatus.actionVariables.downAndAvailabilityMessage": "{statusMessage} 以及 {availabilityMessage}", "xpack.uptime.alerts.monitorStatus.actionVariables.state.currentTriggerStarted": "表示告警触发时当前触发状况开始的时间戳", "xpack.uptime.alerts.monitorStatus.actionVariables.state.firstCheckedAt": "表示此告警首次检查的时间戳", "xpack.uptime.alerts.monitorStatus.actionVariables.state.firstTriggeredAt": "表示告警首次触发的时间戳", @@ -27669,7 +27666,6 @@ "xpack.uptime.alerts.monitorStatus.availability.unit.headline": "选择时间范围单位", "xpack.uptime.alerts.monitorStatus.availability.unit.selectable": "使用此选择来设置此告警的可用性范围单位", "xpack.uptime.alerts.monitorStatus.clientName": "运行时间监测状态", - "xpack.uptime.alerts.monitorStatus.defaultActionMessage": "在 {observerLocation},URL 为 {monitorUrl} 的监测 {monitorName} 是 {statusMessage}。最新错误消息是 {latestErrorMessage}", "xpack.uptime.alerts.monitorStatus.description": "监测关闭或超出可用性阈值时告警。", "xpack.uptime.alerts.monitorStatus.filterBar.ariaLabel": "允许对监测状态告警使用筛选条件的输入", "xpack.uptime.alerts.monitorStatus.filters.anyLocation": "任意位置", diff --git a/x-pack/plugins/uptime/common/runtime_types/alerts/status_check.ts b/x-pack/plugins/uptime/common/runtime_types/alerts/status_check.ts index 29347d40aaf83..1a9eb33714408 100644 --- a/x-pack/plugins/uptime/common/runtime_types/alerts/status_check.ts +++ b/x-pack/plugins/uptime/common/runtime_types/alerts/status_check.ts @@ -6,7 +6,6 @@ */ import * as t from 'io-ts'; - export const StatusCheckFiltersType = t.type({ 'monitor.type': t.array(t.string), 'observer.geo.name': t.array(t.string), diff --git a/x-pack/plugins/uptime/common/translations.ts b/x-pack/plugins/uptime/common/translations.ts index 2b414f22f8c19..aa2574c9cbe90 100644 --- a/x-pack/plugins/uptime/common/translations.ts +++ b/x-pack/plugins/uptime/common/translations.ts @@ -21,11 +21,11 @@ export const VALUE_MUST_BE_AN_INTEGER = i18n.translate('xpack.uptime.settings.in export const MonitorStatusTranslations = { defaultActionMessage: i18n.translate('xpack.uptime.alerts.monitorStatus.defaultActionMessage', { defaultMessage: - 'Monitor {monitorName} with url {monitorUrl} is {statusMessage} from {observerLocation}. The latest error message is {latestErrorMessage}', + 'Monitor {monitorName} with url {monitorUrl} from {observerLocation} {statusMessage}. The latest error message is {latestErrorMessage}', values: { monitorName: '{{state.monitorName}}', monitorUrl: '{{{state.monitorUrl}}}', - statusMessage: '{{state.statusMessage}}', + statusMessage: '{{{state.statusMessage}}}', latestErrorMessage: '{{{state.latestErrorMessage}}}', observerLocation: '{{state.observerLocation}}', }, diff --git a/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.test.ts b/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.test.ts index 201464a2425a7..af2fec78098a7 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.test.ts +++ b/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.test.ts @@ -202,7 +202,7 @@ describe('monitor status alert type', () => { }) ).toMatchInlineSnapshot(` Object { - "defaultActionMessage": "Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} is {{state.statusMessage}} from {{state.observerLocation}}. The latest error message is {{{state.latestErrorMessage}}}", + "defaultActionMessage": "Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} from {{state.observerLocation}} {{{state.statusMessage}}}. The latest error message is {{{state.latestErrorMessage}}}", "description": "Alert when a monitor is down or an availability threshold is breached.", "documentationUrl": [Function], "format": [Function], diff --git a/x-pack/plugins/uptime/public/state/api/alert_actions.test.ts b/x-pack/plugins/uptime/public/state/api/alert_actions.test.ts index 0a6c93e2041d6..8ca934392fd43 100644 --- a/x-pack/plugins/uptime/public/state/api/alert_actions.test.ts +++ b/x-pack/plugins/uptime/public/state/api/alert_actions.test.ts @@ -50,7 +50,7 @@ describe('Alert Actions factory', () => { eventAction: 'trigger', severity: 'error', summary: - 'Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} is {{state.statusMessage}} from {{state.observerLocation}}. The latest error message is {{{state.latestErrorMessage}}}', + 'Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} from {{state.observerLocation}} {{{state.statusMessage}}}. The latest error message is {{{state.latestErrorMessage}}}', }, id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9', }, @@ -75,7 +75,7 @@ describe('Alert Actions factory', () => { eventAction: 'trigger', severity: 'error', summary: - 'Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} is {{state.statusMessage}} from {{state.observerLocation}}. The latest error message is {{{state.latestErrorMessage}}}', + 'Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} from {{state.observerLocation}} {{{state.statusMessage}}}. The latest error message is {{{state.latestErrorMessage}}}', }, }, ]); @@ -93,7 +93,7 @@ describe('Alert Actions factory', () => { eventAction: 'trigger', severity: 'error', summary: - 'Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} is {{state.statusMessage}} from {{state.observerLocation}}. The latest error message is {{{state.latestErrorMessage}}}', + 'Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} from {{state.observerLocation}} {{{state.statusMessage}}}. The latest error message is {{{state.latestErrorMessage}}}', }, id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9', }, @@ -118,7 +118,7 @@ describe('Alert Actions factory', () => { eventAction: 'trigger', severity: 'error', summary: - 'Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} is {{state.statusMessage}} from {{state.observerLocation}}. The latest error message is {{{state.latestErrorMessage}}}', + 'Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} from {{state.observerLocation}} {{{state.statusMessage}}}. The latest error message is {{{state.latestErrorMessage}}}', }, }, ]); diff --git a/x-pack/plugins/uptime/public/state/api/alert_actions.ts b/x-pack/plugins/uptime/public/state/api/alert_actions.ts index 40a7af18ac906..af2dbec02ed89 100644 --- a/x-pack/plugins/uptime/public/state/api/alert_actions.ts +++ b/x-pack/plugins/uptime/public/state/api/alert_actions.ts @@ -130,7 +130,7 @@ function getIndexActionParams(selectedMonitor: Ping, recovery = false): IndexAct { monitorName: '{{state.monitorName}}', monitorUrl: '{{{state.monitorUrl}}}', - statusMessage: '{{state.statusMessage}}', + statusMessage: '{{{state.statusMessage}}}', latestErrorMessage: '{{{state.latestErrorMessage}}}', observerLocation: '{{state.observerLocation}}', }, diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts index 73f4501ace591..cea34b6daad96 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts @@ -28,6 +28,7 @@ const mockMonitors = [ monitorInfo: { ...makePing({ id: 'first', + name: 'First', location: 'harrisburg', url: 'localhost:8080', }), @@ -44,6 +45,7 @@ const mockMonitors = [ monitorInfo: { ...makePing({ id: 'first', + name: 'First', location: 'fairbanks', url: 'localhost:5601', }), @@ -66,15 +68,16 @@ const mockCommonAlertDocumentFields = (monitorInfo: GetMonitorStatusResult['moni const mockStatusAlertDocument = ( monitor: GetMonitorStatusResult, - isAutoGenerated: boolean = false + isAutoGenerated: boolean = false, + count: number, + interval: string, + numTimes: number ) => { const { monitorInfo } = monitor; return { fields: { ...mockCommonAlertDocumentFields(monitor.monitorInfo), - [ALERT_REASON]: `Monitor first with url ${monitorInfo?.url?.full} is down from ${ - monitorInfo.observer?.geo?.name - }. The latest error message is ${monitorInfo.error?.message || ''}`, + [ALERT_REASON]: `First from ${monitor.monitorInfo.observer?.geo?.name} failed ${count} times in the last ${interval}. Alert when > ${numTimes}.`, }, id: getInstanceId( monitorInfo, @@ -88,13 +91,11 @@ const mockAvailabilityAlertDocument = (monitor: GetMonitorAvailabilityResult) => return { fields: { ...mockCommonAlertDocumentFields(monitor.monitorInfo), - [ALERT_REASON]: `Monitor ${monitorInfo.monitor.name || monitorInfo.monitor.id} with url ${ - monitorInfo?.url?.full - } is below threshold with ${(monitor.availabilityRatio! * 100).toFixed( - 2 - )}% availability expected is 99.34% from ${ + [ALERT_REASON]: `${monitorInfo.monitor.name || monitorInfo.monitor.id} from ${ monitorInfo.observer?.geo?.name - }. The latest error message is ${monitorInfo.error?.message || ''}`, + } 35 days availability is ${(monitor.availabilityRatio! * 100).toFixed( + 2 + )}%. Alert when < 99.34%.`, }, id: getInstanceId(monitorInfo, `${monitorInfo?.monitor.id}-${monitorInfo.observer?.geo?.name}`), }; @@ -183,7 +184,12 @@ describe('status check alert', () => { mockGetter.mockReturnValue(mockMonitors); const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter }); const alert = statusCheckAlertFactory(server, libs, plugins); - const options = mockOptions(); + const options = mockOptions({ + numTimes: 5, + count: 234, + timerangeUnit: 'm', + timerangeCount: 15, + }); const { services: { alertWithLifecycle }, } = options; @@ -192,7 +198,9 @@ describe('status check alert', () => { expect(mockGetter).toHaveBeenCalledTimes(1); expect(alertWithLifecycle).toHaveBeenCalledTimes(2); mockMonitors.forEach((monitor) => { - expect(alertWithLifecycle).toBeCalledWith(mockStatusAlertDocument(monitor)); + expect(alertWithLifecycle).toBeCalledWith( + mockStatusAlertDocument(monitor, false, 234, '15 mins', 5) + ); }); expect(mockGetter.mock.calls[0][0]).toEqual( expect.objectContaining({ @@ -219,13 +227,13 @@ describe('status check alert', () => { "lastTriggeredAt": "foo date string", "latestErrorMessage": "error message 1", "monitorId": "first", - "monitorName": "first", + "monitorName": "First", "monitorType": "myType", "monitorUrl": "localhost:8080", "observerHostname": undefined, "observerLocation": "harrisburg", - "reason": "Monitor first with url localhost:8080 is down from harrisburg. The latest error message is error message 1", - "statusMessage": "down", + "reason": "First from harrisburg failed 234 times in the last 15 mins. Alert when > 5.", + "statusMessage": "failed 234 times in the last 15 mins. Alert when > 5.", }, ] `); @@ -257,7 +265,9 @@ describe('status check alert', () => { expect(mockGetter).toHaveBeenCalledTimes(1); expect(alertWithLifecycle).toHaveBeenCalledTimes(2); mockMonitors.forEach((monitor) => { - expect(alertWithLifecycle).toBeCalledWith(mockStatusAlertDocument(monitor, true)); + expect(alertWithLifecycle).toBeCalledWith( + mockStatusAlertDocument(monitor, true, 234, '15m', 5) + ); }); expect(mockGetter.mock.calls[0][0]).toEqual( expect.objectContaining({ @@ -284,13 +294,13 @@ describe('status check alert', () => { "lastTriggeredAt": "foo date string", "latestErrorMessage": "error message 1", "monitorId": "first", - "monitorName": "first", + "monitorName": "First", "monitorType": "myType", "monitorUrl": "localhost:8080", "observerHostname": undefined, "observerLocation": "harrisburg", - "reason": "Monitor first with url localhost:8080 is down from harrisburg. The latest error message is error message 1", - "statusMessage": "down", + "reason": "First from harrisburg failed 234 times in the last 15m. Alert when > 5.", + "statusMessage": "failed 234 times in the last 15m. Alert when > 5.", }, ] `); @@ -314,7 +324,7 @@ describe('status check alert', () => { const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ numTimes: 4, - timespanRange: { from: 'now-14h', to: 'now' }, + timerange: { from: 'now-14h', to: 'now' }, locations: ['fairbanks'], filters: '', }); @@ -325,7 +335,9 @@ describe('status check alert', () => { const [{ value: alertInstanceMock }] = alertWithLifecycle.mock.results; mockMonitors.forEach((monitor) => { - expect(alertWithLifecycle).toBeCalledWith(mockStatusAlertDocument(monitor)); + expect(alertWithLifecycle).toBeCalledWith( + mockStatusAlertDocument(monitor, false, 234, '14h', 4) + ); }); expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(2); expect(alertInstanceMock.replaceState.mock.calls[0]).toMatchInlineSnapshot(` @@ -340,13 +352,13 @@ describe('status check alert', () => { "lastTriggeredAt": "7.7 date", "latestErrorMessage": "error message 1", "monitorId": "first", - "monitorName": "first", + "monitorName": "First", "monitorType": "myType", "monitorUrl": "localhost:8080", "observerHostname": undefined, "observerLocation": "harrisburg", - "reason": "Monitor first with url localhost:8080 is down from harrisburg. The latest error message is error message 1", - "statusMessage": "down", + "reason": "First from harrisburg failed 234 times in the last 14h. Alert when > 4.", + "statusMessage": "failed 234 times in the last 14h. Alert when > 4.", }, ] `); @@ -392,7 +404,9 @@ describe('status check alert', () => { } = options; const [{ value: alertInstanceMock }] = alertWithLifecycle.mock.results; mockMonitors.forEach((monitor) => { - expect(alertWithLifecycle).toBeCalledWith(mockStatusAlertDocument(monitor)); + expect(alertWithLifecycle).toBeCalledWith( + mockStatusAlertDocument(monitor, false, 234, '15 mins', 3) + ); }); expect(mockGetter).toHaveBeenCalledTimes(1); expect(mockGetter.mock.calls[0][0]).toEqual( @@ -554,13 +568,13 @@ describe('status check alert', () => { "lastTriggeredAt": "foo date string", "latestErrorMessage": "error message 1", "monitorId": "first", - "monitorName": "first", + "monitorName": "First", "monitorType": "myType", "monitorUrl": "localhost:8080", "observerHostname": undefined, "observerLocation": "harrisburg", - "reason": "Monitor first with url localhost:8080 is down from harrisburg. The latest error message is error message 1", - "statusMessage": "down", + "reason": "First from harrisburg failed 234 times in the last 15 mins. Alert when > 3.", + "statusMessage": "failed 234 times in the last 15 mins. Alert when > 3.", }, ] `); @@ -752,8 +766,8 @@ describe('status check alert', () => { "monitorUrl": "https://foo.com", "observerHostname": undefined, "observerLocation": "harrisburg", - "reason": "Monitor Foo with url https://foo.com is below threshold with 99.28% availability expected is 99.34% from harrisburg. The latest error message is ", - "statusMessage": "below threshold with 99.28% availability expected is 99.34%", + "reason": "Foo from harrisburg 35 days availability is 99.28%. Alert when < 99.34%.", + "statusMessage": "35 days availability is 99.28%. Alert when < 99.34%.", }, ] `); @@ -1281,15 +1295,18 @@ describe('status check alert', () => { describe('statusMessage', () => { it('creates message for down item', () => { expect( - getStatusMessage( - makePing({ + getStatusMessage({ + info: makePing({ id: 'test-node-service', location: 'fairbanks', name: 'Test Node Service', url: 'http://localhost:12349', - }) - ) - ).toMatchInlineSnapshot(`"down"`); + }), + count: 235, + numTimes: 10, + interval: '30 days', + }) + ).toMatchInlineSnapshot(`"failed 235 times in the last 30 days. Alert when > 10."`); }); it('creates message for availability item', () => { @@ -1315,18 +1332,23 @@ describe('status check alert', () => { rangeUnit: 'm', } ) - ).toMatchInlineSnapshot(`"below threshold with 58.04% availability expected is 90%"`); + ).toMatchInlineSnapshot(`"5 mins availability is 58.04%. Alert when < 90%."`); }); it('creates message for down and availability item', () => { expect( getStatusMessage( - makePing({ - id: 'test-node-service', - location: 'fairbanks', - name: 'Test Node Service', - url: 'http://localhost:12349', - }), + { + info: makePing({ + id: 'test-node-service', + location: 'fairbanks', + name: 'Test Node Service', + url: 'http://localhost:12349', + }), + count: 235, + numTimes: 10, + interval: '30 days', + }, { monitorId: 'test-node-service', location: 'harrisburg', @@ -1347,7 +1369,7 @@ describe('status check alert', () => { } ) ).toMatchInlineSnapshot( - `"down and also below threshold with 58.04% availability expected is 90%"` + `"failed 235 times in the last 30 days. Alert when > 10. The 5 mins availability is 58.04%. Alert when < 90%."` ); }); }); diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts index bf8c0176122f0..f1f5dbe6cad6a 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts @@ -5,6 +5,8 @@ * 2.0. */ import { min } from 'lodash'; +import * as moment from 'moment'; +import momentDurationFormatSetup from 'moment-duration-format'; import datemath from '@elastic/datemath'; import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; @@ -18,19 +20,27 @@ import { GetMonitorAvailabilityParams, } from '../../../common/runtime_types'; import { MONITOR_STATUS } from '../../../common/constants/alerts'; -import { updateState, generateAlertMessage } from './common'; -import { commonMonitorStateI18, commonStateTranslations, DOWN_LABEL } from './translations'; +import { updateState } from './common'; +import { + commonMonitorStateI18, + commonStateTranslations, + statusCheckTranslations, +} from './translations'; import { stringifyKueries, combineFiltersAndUserSearch } from '../../../common/lib'; import { GetMonitorAvailabilityResult } from '../requests/get_monitor_availability'; -import { GetMonitorStatusResult } from '../requests/get_monitor_status'; +import { + GetMonitorStatusResult, + GetMonitorDownStatusMessageParams, + getMonitorDownStatusMessageParams, + getInterval, +} from '../requests/get_monitor_status'; import { UNNAMED_LOCATION } from '../../../common/constants'; -import { MonitorStatusTranslations } from '../../../common/translations'; import { getUptimeIndexPattern, IndexPatternTitleAndFields } from '../requests/get_index_pattern'; import { UMServerLibs, UptimeESClient, createUptimeESClient } from '../lib'; import { ActionGroupIdsOf } from '../../../../alerting/common'; +momentDurationFormatSetup(moment); export type ActionGroupIds = ActionGroupIdsOf; - /** * Returns the appropriate range for filtering the documents by `@timestamp`. * @@ -131,6 +141,8 @@ export const formatFilterString = async ( ); export const getMonitorSummary = (monitorInfo: Ping, statusMessage: string) => { + const monitorName = monitorInfo.monitor?.name ?? monitorInfo.monitor?.id; + const observerLocation = monitorInfo.observer?.geo?.name ?? UNNAMED_LOCATION; const summary = { monitorUrl: monitorInfo.url?.full, monitorId: monitorInfo.monitor?.id, @@ -140,13 +152,10 @@ export const getMonitorSummary = (monitorInfo: Ping, statusMessage: string) => { observerLocation: monitorInfo.observer?.geo?.name ?? UNNAMED_LOCATION, observerHostname: monitorInfo.agent?.name, }; - const reason = generateAlertMessage(MonitorStatusTranslations.defaultActionMessage, { - ...summary, - statusMessage, - }); + return { ...summary, - reason, + reason: `${monitorName} from ${observerLocation} ${statusMessage}`, }; }; @@ -162,40 +171,32 @@ export const getMonitorAlertDocument = (monitorSummary: Record { let statusMessage = ''; - if (downMonInfo) { - statusMessage = DOWN_LABEL; + if (downMonParams?.info) { + statusMessage = `${statusCheckTranslations.downMonitorsLabel( + downMonParams.count!, + downMonParams.interval!, + downMonParams.numTimes + )}.`; } let availabilityMessage = ''; if (availMonInfo) { - availabilityMessage = i18n.translate( - 'xpack.uptime.alerts.monitorStatus.actionVariables.availabilityMessage', - { - defaultMessage: - 'below threshold with {availabilityRatio}% availability expected is {expectedAvailability}%', - values: { - availabilityRatio: (availMonInfo.availabilityRatio! * 100).toFixed(2), - expectedAvailability: availability?.threshold, - }, - } - ); + availabilityMessage = `${statusCheckTranslations.availabilityBreachLabel( + (availMonInfo.availabilityRatio! * 100).toFixed(2), + availability?.threshold!, + getInterval(availability?.range!, availability?.rangeUnit!) + )}.`; } - if (availMonInfo && downMonInfo) { - return i18n.translate( - 'xpack.uptime.alerts.monitorStatus.actionVariables.downAndAvailabilityMessage', - { - defaultMessage: '{statusMessage} and also {availabilityMessage}', - values: { - statusMessage, - availabilityMessage, - }, - } - ); + if (availMonInfo && downMonParams?.info) { + return `${statusCheckTranslations.downMonitorsAndAvailabilityBreachLabel( + statusMessage, + availabilityMessage + )}`; } return statusMessage + availabilityMessage; }; @@ -314,6 +315,7 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( isAutoGenerated, timerange: oldVersionTimeRange, } = rawParams; + const uptimeEsClient = createUptimeESClient({ esClient: scopedClusterClient.asCurrentUser, savedObjectsClient, @@ -322,7 +324,6 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( const filterString = await formatFilterString(uptimeEsClient, filters, search, libs); const timespanInterval = `${String(timerangeCount)}${timerangeUnit}`; - // Range filter for `monitor.timespan`, the range of time the ping is valid const timespanRange = oldVersionTimeRange || { from: `now-${timespanInterval}`, @@ -354,9 +355,17 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( for (const monitorLoc of downMonitorsByLocation) { const monitorInfo = monitorLoc.monitorInfo; - const statusMessage = getStatusMessage(monitorInfo); - const monitorSummary = getMonitorSummary(monitorInfo, statusMessage); + const monitorStatusMessageParams = getMonitorDownStatusMessageParams( + monitorInfo, + monitorLoc.count, + numTimes, + timerangeCount, + timerangeUnit, + oldVersionTimeRange + ); + const statusMessage = getStatusMessage(monitorStatusMessageParams); + const monitorSummary = getMonitorSummary(monitorInfo, statusMessage); const alert = alertWithLifecycle({ id: getInstanceId(monitorInfo, monitorLoc.location), fields: getMonitorAlertDocument(monitorSummary), @@ -394,11 +403,27 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( ({ monitorId, location }) => getMonIdByLoc(monitorId, location) === monIdByLoc )?.monitorInfo; + const downMonCount = downMonitorsByLocation.find( + ({ monitorId, location }) => getMonIdByLoc(monitorId, location) === monIdByLoc + )?.count; + const monitorInfo = downMonInfo || availMonInfo?.monitorInfo!; - const statusMessage = getStatusMessage(downMonInfo!, availMonInfo!, availability); - const monitorSummary = getMonitorSummary(monitorInfo, statusMessage); + const monitorStatusMessageParams = getMonitorDownStatusMessageParams( + downMonInfo!, + downMonCount!, + numTimes, + timerangeCount, + timerangeUnit, + oldVersionTimeRange + ); + const statusMessage = getStatusMessage( + monitorStatusMessageParams, + availMonInfo!, + availability + ); + const monitorSummary = getMonitorSummary(monitorInfo, statusMessage); const alert = alertWithLifecycle({ id: getInstanceId(monitorInfo, monIdByLoc), fields: getMonitorAlertDocument(monitorSummary), diff --git a/x-pack/plugins/uptime/server/lib/alerts/translations.ts b/x-pack/plugins/uptime/server/lib/alerts/translations.ts index ee356eb68a626..1dcadb3db29cb 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/translations.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/translations.ts @@ -328,6 +328,39 @@ export const durationAnomalyTranslations = { ], }; -export const DOWN_LABEL = i18n.translate('xpack.uptime.alerts.monitorStatus.actionVariables.down', { - defaultMessage: 'down', -}); +export const statusCheckTranslations = { + downMonitorsLabel: (count: number, interval: string, numTimes: number) => + i18n.translate('xpack.uptime.alerts.monitorStatus.actionVariables.down', { + defaultMessage: `failed {count} times in the last {interval}. Alert when > {numTimes}`, + values: { + count, + interval, + numTimes, + }, + }), + availabilityBreachLabel: ( + availabilityRatio: string, + expectedAvailability: string, + interval: string + ) => + i18n.translate('xpack.uptime.alerts.monitorStatus.actionVariables.availabilityMessage', { + defaultMessage: + '{interval} availability is {availabilityRatio}%. Alert when < {expectedAvailability}%', + values: { + availabilityRatio, + expectedAvailability, + interval, + }, + }), + downMonitorsAndAvailabilityBreachLabel: ( + downMonitorsMessage: string, + availabilityBreachMessage: string + ) => + i18n.translate('xpack.uptime.alerts.monitorStatus.actionVariables.downAndAvailabilityMessage', { + defaultMessage: '{downMonitorsMessage} The {availabilityBreachMessage}', + values: { + downMonitorsMessage, + availabilityBreachMessage, + }, + }), +}; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts index 4fe7e94803fd7..6e755bee0ac71 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts @@ -8,12 +8,15 @@ import { JsonObject } from '@kbn/utility-types'; import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { PromiseType } from 'utility-types'; +import * as moment from 'moment'; +import momentDurationFormatSetup from 'moment-duration-format'; import { asMutableArray } from '../../../common/utils/as_mutable_array'; import { UMElasticsearchQueryFn } from '../adapters'; import { Ping } from '../../../common/runtime_types/ping'; import { createEsQuery } from '../../../common/utils/es_search'; import { UptimeESClient } from '../lib'; import { UNNAMED_LOCATION } from '../../../common/constants'; +momentDurationFormatSetup(moment); export interface GetMonitorStatusParams { filters?: JsonObject; @@ -31,6 +34,46 @@ export interface GetMonitorStatusResult { monitorInfo: Ping; } +export const getInterval = (timerangeCount: number, timerangeUnit: string): string => { + switch (timerangeUnit) { + case 's': + return moment.duration(timerangeCount, 'seconds').format('s [sec]'); + case 'm': + return moment.duration(timerangeCount, 'minutes').format('m [min]'); + case 'h': + return moment.duration(timerangeCount, 'hours').format('h [hr]'); + case 'd': + return moment.duration(timerangeCount, 'days').format('d [day]'); + default: + return `${timerangeCount} ${timerangeUnit}`; + } +}; + +export interface GetMonitorDownStatusMessageParams { + info: Ping; + count: number; + interval?: string; + numTimes: number; +} + +export const getMonitorDownStatusMessageParams = ( + info: Ping, + count: number, + numTimes: number, + timerangeCount: number, + timerangeUnit: string, + oldVersionTimeRange: { from: string; to: string } +) => { + return { + info, + count, + interval: oldVersionTimeRange + ? oldVersionTimeRange.from.slice(-3) + : getInterval(timerangeCount, timerangeUnit), + numTimes, + }; +}; + const getLocationClause = (locations: string[]) => ({ bool: { should: [ diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/simple_down_alert.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/simple_down_alert.ts index 52e602989afd3..af3cc52627970 100644 --- a/x-pack/test/functional_with_es_ssl/apps/uptime/simple_down_alert.ts +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/simple_down_alert.ts @@ -107,7 +107,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { group: 'xpack.uptime.alerts.actionGroups.monitorStatus', params: { message: - 'Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} is {{state.statusMessage}} from {{state.observerLocation}}. The latest error message is {{{state.latestErrorMessage}}}', + 'Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} from {{state.observerLocation}} {{{state.statusMessage}}}. The latest error message is {{{state.latestErrorMessage}}}', }, id: 'my-slack1', }, From 1438e97783e314596f3f4852ff0a08fcb29c0656 Mon Sep 17 00:00:00 2001 From: Kellen <9484709+goodroot@users.noreply.github.com> Date: Tue, 25 Jan 2022 12:07:09 -0800 Subject: [PATCH 25/46] Adds builder, preps for vercel (#122871) * Adds builder, preps for vercel * bumps builder version * updates tokens * Updates to target to enable on PRs * Update builder.yml fixes fork-based workflows * Update builder.yml * Renames to make intent more clear Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .github/workflows/dev-docs-builder.yml | 81 ++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 .github/workflows/dev-docs-builder.yml diff --git a/.github/workflows/dev-docs-builder.yml b/.github/workflows/dev-docs-builder.yml new file mode 100644 index 0000000000000..418df8bd7d8ac --- /dev/null +++ b/.github/workflows/dev-docs-builder.yml @@ -0,0 +1,81 @@ +name: Elastic Builder +on: + pull_request_target: + paths: + - '**.mdx' + - '**.docnav.json' + - '**.png' + - '**.gif' + types: [closed, opened, synchronize, reopened] + +jobs: + preview: + name: Do the magic + runs-on: ubuntu-latest + steps: + - name: Setup workspace + uses: actions/checkout@v2 + + - name: Checkout current branch into temp + if: github.event.pull_request.merged == false + uses: actions/checkout@v2 + with: + path: 'temp' + fetch-depth: 2 + ref: refs/pull/${{ github.event.pull_request.number }}/merge + + - name: Checkout current branch into temp + if: github.event.pull_request.merged == true + uses: actions/checkout@v2 + with: + path: 'temp' + + - name: Checkout essential repos + uses: actions/checkout@v2 + with: + repository: elastic/docs.elastic.dev + token: ${{ secrets.VERCEL_GITHUB_TOKEN }} + path: ${{ github.workspace }}/docs.elastic.dev + + - name: Checkout Wordlake + uses: actions/checkout@v2 + with: + repository: elastic/wordlake-dev + token: ${{ secrets.VERCEL_GITHUB_TOKEN }} + path: ${{ github.workspace }}/wordlake-dev + + - name: Temp sources override + shell: bash + run: cp -f ${{ github.workspace }}/wordlake-dev/.scaffold/sources.json ${{ github.workspace }}/docs.elastic.dev/. + + - name: Show workspace + shell: bash + run: ls -lat ${{ github.workspace }} + + - name: Portal + shell: bash + run: | + mkdir -p ${{ github.workspace }}/wordlake-dev/${{ github.event.repository.name }} + rsync --ignore-missing-args -zavpm --include='*.docnav.json' --include='*.mdx' --include='*.png' --include='*.gif' --include='*/' --exclude='*' ${{ github.workspace }}/temp/ ${{ github.workspace }}/wordlake-dev/${{ github.event.repository.name }}/ + + - name: Generate preview + if: github.event.pull_request.merged == false + uses: elastic/builder@v21.1.0 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} # Required + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} #Required + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_DOCS_DEV}} #Required + vercel-project-name: docs-elastic-dev + github-token: ${{ secrets.VERCEL_GITHUB_TOKEN }} #Optional + working-directory: ./ + + - name: Portal for deploy + if: github.event.pull_request.merged == true + shell: bash + run: | + cd ${{ github.workspace }}/wordlake-dev + git config user.name count-docula + git config user.email github-actions@github.com + git add . + git commit -m "New content from https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" + git push From 101acd1838d7b1f8aeea662ae8bd651744d1e0f7 Mon Sep 17 00:00:00 2001 From: Michael Dokolin Date: Tue, 25 Jan 2022 21:08:39 +0100 Subject: [PATCH 26/46] [Reporting] Update immediate CSV export type to support streaming (#123067) --- .../reporting_api_client.ts | 5 +- .../get_csv_panel_action.test.ts | 2 + .../panel_actions/get_csv_panel_action.tsx | 6 +- x-pack/plugins/reporting/server/lib/index.ts | 1 + .../server/lib/passthrough_stream.test.ts | 30 +++++++++ .../server/lib/passthrough_stream.ts | 33 ++++++++++ .../generate/csv_searchsource_immediate.ts | 62 +++++++------------ 7 files changed, 98 insertions(+), 41 deletions(-) create mode 100644 x-pack/plugins/reporting/server/lib/passthrough_stream.test.ts create mode 100644 x-pack/plugins/reporting/server/lib/passthrough_stream.ts diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts index 66ff429ade437..dab62ea97b7e8 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts @@ -192,7 +192,10 @@ export class ReportingAPIClient implements IReportingAPI { public async createImmediateReport(baseParams: BaseParams) { const { objectType: _objectType, ...params } = baseParams; // objectType is not needed for immediate download api - return this.http.post(`${API_GENERATE_IMMEDIATE}`, { body: JSON.stringify(params) }); + return this.http.post(`${API_GENERATE_IMMEDIATE}`, { + asResponse: true, + body: JSON.stringify(params), + }); } public getDecoratedJobParams(baseParams: T): BaseParams { diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts index 268ac144d2b58..1ca76de5439f9 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts @@ -46,6 +46,8 @@ describe('GetCsvReportPanelAction', () => { value: () => {}, }); } + + core.http.post.mockResolvedValue({}); }); beforeEach(() => { diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx index 49e693fc8e87e..b9bb529e93268 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx @@ -144,11 +144,13 @@ export class ReportingCsvPanelAction implements ActionDefinition await this.apiClient .createImmediateReport(immediateJobParams) - .then((rawResponse) => { + .then(({ body, response }) => { this.isDownloading = false; const download = `${savedSearch.title}.csv`; - const blob = new Blob([rawResponse as BlobPart], { type: 'text/csv;charset=utf-8;' }); + const blob = new Blob([body as BlobPart], { + type: response?.headers.get('content-type') || undefined, + }); // Hack for IE11 Support // @ts-expect-error diff --git a/x-pack/plugins/reporting/server/lib/index.ts b/x-pack/plugins/reporting/server/lib/index.ts index cc61f5eb96616..682f547380ba0 100644 --- a/x-pack/plugins/reporting/server/lib/index.ts +++ b/x-pack/plugins/reporting/server/lib/index.ts @@ -11,6 +11,7 @@ export { ContentStream, getContentStream } from './content_stream'; export { cryptoFactory } from './crypto'; export { ExportTypesRegistry, getExportTypesRegistry } from './export_types_registry'; export { LevelLogger } from './level_logger'; +export { PassThroughStream } from './passthrough_stream'; export { statuses } from './statuses'; export { ReportingStore, IlmPolicyManager } from './store'; export { startTrace } from './trace'; diff --git a/x-pack/plugins/reporting/server/lib/passthrough_stream.test.ts b/x-pack/plugins/reporting/server/lib/passthrough_stream.test.ts new file mode 100644 index 0000000000000..3f6ecd5ff4b0c --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/passthrough_stream.test.ts @@ -0,0 +1,30 @@ +/* + * 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 { PassThroughStream } from './passthrough_stream'; + +describe('PassThroughStream', () => { + let stream: PassThroughStream; + + beforeEach(() => { + stream = new PassThroughStream(); + }); + + describe('write', () => { + it('should track number of written bytes', () => { + stream.write('something'); + + expect(stream.bytesWritten).toBe(9); + }); + + it('should resolve promise when the first byte is written', async () => { + stream.write('a'); + + await expect(stream.firstBytePromise).resolves.toBe(undefined); + }); + }); +}); diff --git a/x-pack/plugins/reporting/server/lib/passthrough_stream.ts b/x-pack/plugins/reporting/server/lib/passthrough_stream.ts new file mode 100644 index 0000000000000..ec57b09a2456e --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/passthrough_stream.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PassThrough } from 'stream'; + +export class PassThroughStream extends PassThrough { + private onFirstByte?(): void; + + bytesWritten = 0; + + firstBytePromise = new Promise((resolve) => { + this.onFirstByte = resolve; + }); + + _write( + chunk: Buffer | string, + encoding: BufferEncoding, + callback: (error?: Error | null) => void + ) { + const size = Buffer.isBuffer(chunk) ? chunk.byteLength : chunk.length; + + if (!this.bytesWritten && size) { + this.onFirstByte?.(); + } + this.bytesWritten += size; + + return super._write(chunk, encoding, callback); + } +} diff --git a/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts b/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts index 23f27230b1842..bbc2aaf27375a 100644 --- a/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts +++ b/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts @@ -7,13 +7,11 @@ import { schema } from '@kbn/config-schema'; import { KibanaRequest } from 'src/core/server'; -import { Writable } from 'stream'; import { ReportingCore } from '../../'; import { CSV_SEARCHSOURCE_IMMEDIATE_TYPE } from '../../../common/constants'; import { runTaskFnFactory } from '../../export_types/csv_searchsource_immediate/execute_job'; import { JobParamsDownloadCSV } from '../../export_types/csv_searchsource_immediate/types'; -import { LevelLogger as Logger } from '../../lib'; -import { TaskRunResult } from '../../lib/tasks'; +import { LevelLogger as Logger, PassThroughStream } from '../../lib'; import { BaseParams } from '../../types'; import { authorizedUserPreRouting } from '../lib/authorized_user_pre_routing'; import { RequestHandler } from '../lib/request_handler'; @@ -69,58 +67,46 @@ export function registerGenerateCsvFromSavedObjectImmediate( const logger = parentLogger.clone([CSV_SEARCHSOURCE_IMMEDIATE_TYPE]); const runTaskFn = runTaskFnFactory(reporting, logger); const requestHandler = new RequestHandler(reporting, user, context, req, res, logger); - + const stream = new PassThroughStream(); const eventLog = reporting.getEventLogger({ jobtype: CSV_SEARCHSOURCE_IMMEDIATE_TYPE, created_by: user && user.username, payload: { browserTimezone: (req.params as BaseParams).browserTimezone }, }); - - eventLog.logExecutionStart(); + const logError = (error: Error) => { + logger.error(error); + eventLog.logError(error); + }; try { - let buffer = Buffer.from(''); - const stream = new Writable({ - write(chunk, encoding, callback) { - buffer = Buffer.concat([ - buffer, - Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding), - ]); - callback(); - }, - }); + eventLog.logExecutionStart(); + const taskPromise = runTaskFn(null, req.body, context, stream, req) + .then(() => { + logger.info(`Job output size: ${stream.bytesWritten} bytes.`); - const { content_type: jobOutputContentType }: TaskRunResult = await runTaskFn( - null, - req.body, - context, - stream, - req - ); - stream.end(); - const jobOutputContent = buffer.toString(); - const jobOutputSize = buffer.byteLength; + if (!stream.bytesWritten) { + logger.warn('CSV Job Execution created empty content result'); + } - logger.info(`Job output size: ${jobOutputSize} bytes.`); + eventLog.logExecutionComplete({ byteSize: stream.bytesWritten }); + }) + .finally(() => stream.end()); - // convert null to undefined so the value can be sent to h.response() - if (jobOutputContent === null) { - logger.warn('CSV Job Execution created empty content result'); - } + await Promise.race([stream.firstBytePromise, taskPromise]); - eventLog.logExecutionComplete({ byteSize: jobOutputSize }); + taskPromise.catch(logError); return res.ok({ - body: jobOutputContent || '', + body: stream, headers: { - 'content-type': jobOutputContentType ? jobOutputContentType : [], + 'content-type': 'text/csv;charset=utf-8', 'accept-ranges': 'none', }, }); - } catch (err) { - logger.error(err); - eventLog.logError(err); - return requestHandler.handleError(err); + } catch (error) { + logError(error); + + return requestHandler.handleError(error); } } ) From e9350afe0b828fe2ee62f1c5d56fde9d4ad553df Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Tue, 25 Jan 2022 14:20:04 -0600 Subject: [PATCH 27/46] [ML] Transforms: Add call out warning & delete option if a task exists for a transform without a config (#123407) * Add callout warning and delete action if dangling tasks exist * Remove modal altogether, fix texts Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/api_schemas/transforms.ts | 1 + .../transform/public/app/common/transform.ts | 3 + .../public/app/hooks/use_get_transforms.ts | 23 ++++++- .../transform_management_section.tsx | 61 ++++++++++++++++++- .../transform/server/routes/api/transforms.ts | 49 ++++++++------- 5 files changed, 110 insertions(+), 27 deletions(-) diff --git a/x-pack/plugins/transform/common/api_schemas/transforms.ts b/x-pack/plugins/transform/common/api_schemas/transforms.ts index 55ea326069f0d..6ce1de912a53f 100644 --- a/x-pack/plugins/transform/common/api_schemas/transforms.ts +++ b/x-pack/plugins/transform/common/api_schemas/transforms.ts @@ -34,6 +34,7 @@ export type GetTransformsRequestSchema = TypeOf; } // schemas shared by parts of the preview, create and update endpoint diff --git a/x-pack/plugins/transform/public/app/common/transform.ts b/x-pack/plugins/transform/public/app/common/transform.ts index 09fc1c24303d8..35ead5691a866 100644 --- a/x-pack/plugins/transform/public/app/common/transform.ts +++ b/x-pack/plugins/transform/public/app/common/transform.ts @@ -20,6 +20,9 @@ export function isTransformIdValid(transformId: TransformId) { return /^[a-z0-9](?:[a-z0-9_\-\.]*[a-z0-9])?$/g.test(transformId); } +export const TRANSFORM_ERROR_TYPE = { + DANGLING_TASK: 'dangling_task', +} as const; export enum REFRESH_TRANSFORM_LIST_STATE { ERROR = 'error', IDLE = 'idle', 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 7879e15118a33..d39edb87432ef 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 @@ -6,7 +6,6 @@ */ import { HttpFetchError } from 'src/core/public'; - import { isGetTransformNodesResponseSchema, isGetTransformsResponseSchema, @@ -18,6 +17,8 @@ import { isTransformStats } from '../../../common/types/transform_stats'; import { TransformListRow, refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../common'; import { useApi } from './use_api'; +import { TRANSFORM_ERROR_TYPE } from '../common/transform'; +import { isDefined } from '../../../common/types/common'; export type GetTransforms = (forceRefresh?: boolean) => void; @@ -25,6 +26,7 @@ export const useGetTransforms = ( setTransforms: React.Dispatch>, setTransformNodes: React.Dispatch>, setErrorMessage: React.Dispatch>, + setTransformIdsWithoutConfig: React.Dispatch>, setIsInitialized: React.Dispatch>, blockRefresh: boolean ): GetTransforms => { @@ -69,6 +71,25 @@ export const useGetTransforms = ( return; } + // There might be some errors with fetching certain transforms + // For example, when task exists and is running but the config is deleted + if (Array.isArray(transformConfigs.errors) && transformConfigs.errors.length > 0) { + const danglingTaskIdMatches = transformConfigs.errors + .filter((e) => e.type === TRANSFORM_ERROR_TYPE.DANGLING_TASK) + .map((e) => { + // Getting the transform id from the ES error message + const matches = /\[([^)]+)\]/.exec(e.reason); + return Array.isArray(matches) && matches.length >= 1 ? matches[1] : undefined; + }) + .filter(isDefined); + + setTransformIdsWithoutConfig( + danglingTaskIdMatches.length > 0 ? danglingTaskIdMatches : undefined + ); + } else { + setTransformIdsWithoutConfig(undefined); + } + const tableRows = transformConfigs.transforms.reduce((reducedtableRows, config) => { const stats = isGetTransformsStatsResponseSchema(transformStats) ? transformStats.transforms.find((d) => config.id === d.id) diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx index 5a4ae26e8d4e5..066a72c807956 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx @@ -20,13 +20,17 @@ import { EuiPageContentBody, EuiPageHeader, EuiSpacer, + EuiCallOut, + EuiButton, } from '@elastic/eui'; - -import { APP_GET_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants'; +import { + APP_GET_TRANSFORM_CLUSTER_PRIVILEGES, + TRANSFORM_STATE, +} from '../../../../common/constants'; import { useRefreshTransformList, TransformListRow } from '../../common'; import { useDocumentationLinks } from '../../hooks/use_documentation_links'; -import { useGetTransforms } from '../../hooks'; +import { useDeleteTransforms, useGetTransforms } from '../../hooks'; import { RedirectToCreateTransform } from '../../common/navigation'; import { PrivilegesWrapper } from '../../lib/authorization'; import { breadcrumbService, docTitleService, BREADCRUMB_SECTION } from '../../services/navigation'; @@ -50,11 +54,17 @@ export const TransformManagement: FC = () => { const [transforms, setTransforms] = useState([]); const [transformNodes, setTransformNodes] = useState(0); const [errorMessage, setErrorMessage] = useState(undefined); + const [transformIdsWithoutConfig, setTransformIdsWithoutConfig] = useState< + string[] | undefined + >(); + + const deleteTransforms = useDeleteTransforms(); const getTransforms = useGetTransforms( setTransforms, setTransformNodes, setErrorMessage, + setTransformIdsWithoutConfig, setIsInitialized, blockRefresh ); @@ -155,6 +165,51 @@ export const TransformManagement: FC = () => { )} {typeof errorMessage === 'undefined' && ( + {transformIdsWithoutConfig ? ( + <> + +

+ +

+ { + await deleteTransforms( + // If transform task doesn't have any corresponding config + // we won't know what the destination index or data view would be + // and should be force deleted + { + transformsInfo: transformIdsWithoutConfig.map((id) => ({ + id, + state: TRANSFORM_STATE.FAILED, + })), + deleteDestIndex: false, + deleteDestIndexPattern: false, + forceDelete: true, + } + ); + }} + > + + +
+ + + ) : null} Date: Tue, 25 Jan 2022 21:45:01 +0100 Subject: [PATCH 28/46] [RAC][Rule Registry] Adds test for required and optional alert fields (#120475) * Adds failing test case * Adds test for required fields * removing kibana.alert.instance.id since it's experimental Co-authored-by: Chris Cowan --- .../common/parse_technical_fields.test.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 x-pack/plugins/rule_registry/common/parse_technical_fields.test.ts diff --git a/x-pack/plugins/rule_registry/common/parse_technical_fields.test.ts b/x-pack/plugins/rule_registry/common/parse_technical_fields.test.ts new file mode 100644 index 0000000000000..dfc351099b444 --- /dev/null +++ b/x-pack/plugins/rule_registry/common/parse_technical_fields.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { parseTechnicalFields } from './parse_technical_fields'; + +describe('parseTechnicalFields', () => { + it('parses an alert with default fields without error', () => { + const ALERT_WITH_DEFAULT_FIELDS = { + '@timestamp': ['2021-12-06T12:30:59.411Z'], + 'kibana.alert.status': ['active'], + 'kibana.alert.duration.us': ['488935266000'], + 'kibana.alert.reason': ['host.uptime has reported no data over the past 1m for *'], + 'kibana.alert.workflow_status': ['open'], + 'kibana.alert.rule.uuid': ['c8ef4420-4604-11ec-b08c-c590e7b8c4cd'], + 'kibana.alert.rule.producer': ['infrastructure'], + 'kibana.alert.rule.consumer': ['alerts'], + 'kibana.alert.rule.category': ['Metric threshold'], + 'kibana.alert.start': ['2021-11-30T20:42:04.145Z'], + 'kibana.alert.rule.rule_type_id': ['metrics.alert.threshold'], + 'event.action': ['active'], + 'kibana.alert.rule.name': ['Uptime'], + 'kibana.alert.uuid': ['f31f5726-3c47-4c88-bc42-4e1fbde17e34'], + 'kibana.space_ids': ['default'], + 'kibana.version': ['8.1.0'], + 'event.kind': ['signal'], + }; + expect(() => parseTechnicalFields(ALERT_WITH_DEFAULT_FIELDS)).not.toThrow(); + }); + + it('parses an alert with missing required fields with error', () => { + const ALERT_WITH_MISSING_REQUIRED_FIELDS = { + '@timestamp': ['2021-12-06T12:30:59.411Z'], + 'kibana.alert.duration.us': ['488935266000'], + 'kibana.alert.reason': ['host.uptime has reported no data over the past 1m for *'], + 'kibana.alert.workflow_status': ['open'], + 'kibana.alert.start': ['2021-11-30T20:42:04.145Z'], + 'event.action': ['active'], + 'kibana.version': ['8.1.0'], + 'event.kind': ['signal'], + }; + expect(() => parseTechnicalFields(ALERT_WITH_MISSING_REQUIRED_FIELDS)).toThrow(); + }); + + it('parses an alert with missing optional fields without error', () => { + const ALERT_WITH_MISSING_OPTIONAL_FIELDS = { + '@timestamp': ['2021-12-06T12:30:59.411Z'], + 'kibana.alert.rule.uuid': ['c8ef4420-4604-11ec-b08c-c590e7b8c4cd'], + 'kibana.alert.status': ['active'], + 'kibana.alert.rule.producer': ['infrastructure'], + 'kibana.alert.rule.consumer': ['alerts'], + 'kibana.alert.rule.category': ['Metric threshold'], + 'kibana.alert.rule.rule_type_id': ['metrics.alert.threshold'], + 'kibana.alert.rule.name': ['Uptime'], + 'kibana.alert.uuid': ['f31f5726-3c47-4c88-bc42-4e1fbde17e34'], + 'kibana.space_ids': ['default'], + }; + + expect(() => parseTechnicalFields(ALERT_WITH_MISSING_OPTIONAL_FIELDS)).not.toThrow(); + }); +}); From 1e576042df823d71a9f6f22f52af3492346f7b3c Mon Sep 17 00:00:00 2001 From: James Rodewig Date: Tue, 25 Jan 2022 16:56:14 -0500 Subject: [PATCH 29/46] Azure, GCS, and AWS snapshot repos no longer require plugins (#123387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With https://github.com/elastic/elasticsearch/pull/81870, the Azure, GCS, and AWS snapshot repository types have built-in support in Elasticsearch and no longer require plugins in 8.0+. This PR updates step one of the **Register Repository** wizard to: - Include Azure, GCS, and AWS as default repository types - Tweak UI copy and links referring to repository plugins. Co-authored-by: Yulia Čech <6585477+yuliacech@users.noreply.github.com> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/doc_links/doc_links_service.ts | 2 +- x-pack/plugins/snapshot_restore/README.md | 7 ++-- .../snapshot_restore/common/constants.ts | 13 +++----- .../components/repository_form/step_one.tsx | 32 ++++++------------- .../server/routes/api/repositories.test.ts | 12 +++---- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 7 files changed, 22 insertions(+), 46 deletions(-) diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index b85572e650f26..3face209a90dc 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -500,7 +500,7 @@ export class DocLinksService { gcsRepo: `${ELASTICSEARCH_DOCS}repository-gcs.html`, hdfsRepo: `${PLUGIN_DOCS}repository-hdfs.html`, s3Repo: `${ELASTICSEARCH_DOCS}repository-s3.html`, - snapshotRestoreRepos: `${PLUGIN_DOCS}repository.html`, + snapshotRestoreRepos: `${ELASTICSEARCH_DOCS}snapshots-register-repository.html`, mapperSize: `${PLUGIN_DOCS}mapper-size-usage.html`, }, snapshotRestore: { diff --git a/x-pack/plugins/snapshot_restore/README.md b/x-pack/plugins/snapshot_restore/README.md index e11483785e958..b6b75631b07d9 100644 --- a/x-pack/plugins/snapshot_restore/README.md +++ b/x-pack/plugins/snapshot_restore/README.md @@ -67,12 +67,11 @@ PUT _snapshot/my_src_only_repository ### Plugin-based repositories: -There are four official repository plugins available: S3, GCS, HDFS, Azure. Available plugin repository settings can be found in the docs: https://www.elastic.co/guide/en/elasticsearch/plugins/master/repository.html. +There is one official repository plugin available: HDFS. You can find the repository settings in the docs: https://www.elastic.co/guide/en/elasticsearch/plugins/master/repository-hdfs-config.html. To run ES with plugins: 1. Run `yarn es snapshot` from the Kibana directory like normal, then exit out of process. 2. `cd .es/8.0.0` -3. `bin/elasticsearch-plugin install https://snapshots.elastic.co/downloads/elasticsearch-plugins/repository-s3/repository-s3-8.0.0-SNAPSHOT.zip` -4. Repeat step 3 for additional plugins, replacing occurrences of `repository-s3` with the plugin you want to install. -5. Run `bin/elasticsearch` from the `.es/8.0.0` directory. Otherwise, starting ES with `yarn es snapshot` would overwrite the plugins you just installed. \ No newline at end of file +3. `bin/elasticsearch-plugin install https://snapshots.elastic.co/downloads/elasticsearch-plugins/repository-hdfs/repository-hdfs-8.0.0-SNAPSHOT.zip` +4. Run `bin/elasticsearch` from the `.es/8.0.0` directory. Otherwise, starting ES with `yarn es snapshot` would overwrite the plugins you just installed. \ No newline at end of file diff --git a/x-pack/plugins/snapshot_restore/common/constants.ts b/x-pack/plugins/snapshot_restore/common/constants.ts index df13bd4c2f1f0..c4b64bb9395f8 100644 --- a/x-pack/plugins/snapshot_restore/common/constants.ts +++ b/x-pack/plugins/snapshot_restore/common/constants.ts @@ -36,22 +36,17 @@ export enum REPOSITORY_TYPES { // Deliberately do not include `source` as a default repository since we treat it as a flag export const DEFAULT_REPOSITORY_TYPES: RepositoryType[] = [ + REPOSITORY_TYPES.azure, + REPOSITORY_TYPES.gcs, + REPOSITORY_TYPES.s3, REPOSITORY_TYPES.fs, REPOSITORY_TYPES.url, ]; -export const PLUGIN_REPOSITORY_TYPES: RepositoryType[] = [ - REPOSITORY_TYPES.s3, - REPOSITORY_TYPES.hdfs, - REPOSITORY_TYPES.azure, - REPOSITORY_TYPES.gcs, -]; +export const PLUGIN_REPOSITORY_TYPES: RepositoryType[] = [REPOSITORY_TYPES.hdfs]; export const REPOSITORY_PLUGINS_MAP: { [key: string]: RepositoryType } = { - 'repository-s3': REPOSITORY_TYPES.s3, 'repository-hdfs': REPOSITORY_TYPES.hdfs, - 'repository-azure': REPOSITORY_TYPES.azure, - 'repository-gcs': REPOSITORY_TYPES.gcs, }; export const APP_REQUIRED_CLUSTER_PRIVILEGES = [ diff --git a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/step_one.tsx b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/step_one.tsx index 79a311395e5d7..75cf590dfcc94 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/step_one.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/step_one.tsx @@ -74,11 +74,11 @@ export const RepositoryFormStepOne: React.FunctionComponent = ({ } }; - const pluginDocLink = ( + const snapshotRepoDocLink = ( ); @@ -206,7 +206,7 @@ export const RepositoryFormStepOne: React.FunctionComponent = ({ id="xpack.snapshotRestore.repositoryForm.noRepositoryTypesErrorMessage" defaultMessage="You can install plugins to enable different repository types. {docLink}" values={{ - docLink: pluginDocLink, + docLink: snapshotRepoDocLink, }} /> @@ -233,25 +233,13 @@ export const RepositoryFormStepOne: React.FunctionComponent = ({ - {repositoryTypes.includes(REPOSITORY_TYPES.fs) && - repositoryTypes.includes(REPOSITORY_TYPES.url) ? ( - - ) : ( - - )} + { }); it(`doesn't return repository plugins that are not installed on all nodes`, async () => { - const dataNodePlugins = ['repository-s3', 'repository-azure']; - const masterNodePlugins = ['repository-azure']; + const dataNodePlugins = ['repository-hdfs']; + const masterNodePlugins: string[] = []; const mockEsResponse = { nodes: { dataNode: { plugins: [...dataNodePlugins.map((key) => ({ name: key }))] }, @@ -300,7 +296,7 @@ describe('[Snapshot and Restore API Routes] Repositories', () => { }; nodesInfoFn.mockResolvedValue({ body: mockEsResponse }); - const expectedResponse = [...DEFAULT_REPOSITORY_TYPES, REPOSITORY_TYPES.azure]; + const expectedResponse = [...DEFAULT_REPOSITORY_TYPES]; await expect(router.runRequest(mockRequest)).resolves.toEqual({ body: expectedResponse }); }); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a8c62110c8e47..eab63af4637ed 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -24965,7 +24965,6 @@ "xpack.snapshotRestore.repositoryForm.commonFields.maxSnapshotBytesTitle": "1 秒間の最高スナップショットバイト数", "xpack.snapshotRestore.repositoryForm.errorUnknownRepositoryTypesMessage": "レポジトリタイプ「{type}」はサポートされていません。", "xpack.snapshotRestore.repositoryForm.errorUnknownRepositoryTypesTitle": "不明なレポジトリタイプ", - "xpack.snapshotRestore.repositoryForm.fields.cloudTypeDescription": "Elasticsearch はカスタムレポジトリ用のコアプラグインを提供します。{docLink}", "xpack.snapshotRestore.repositoryForm.fields.defaultTypeDescription": "Elasticsearch はファイルシステムと読み取り専用の URL レポジトリをサポートします。他のタイプにはプラグインが必要です。{docLink}", "xpack.snapshotRestore.repositoryForm.fields.nameDescription": "レポジトリの固有の名前です。", "xpack.snapshotRestore.repositoryForm.fields.nameDescriptionTitle": "レポジトリ名", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 390c3551935da..e5b29b15c25f7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -25393,7 +25393,6 @@ "xpack.snapshotRestore.repositoryForm.commonFields.maxSnapshotBytesTitle": "每秒最大快照字节数", "xpack.snapshotRestore.repositoryForm.errorUnknownRepositoryTypesMessage": "存储库类型“{type}”不受支持。", "xpack.snapshotRestore.repositoryForm.errorUnknownRepositoryTypesTitle": "未知的存储库类型", - "xpack.snapshotRestore.repositoryForm.fields.cloudTypeDescription": "Elasticsearch 为定制存储库提供核心插件。{docLink}", "xpack.snapshotRestore.repositoryForm.fields.defaultTypeDescription": "Elasticsearch 支持文件系统和只读 URL 存储库。其他类型需要插件。{docLink}", "xpack.snapshotRestore.repositoryForm.fields.nameDescription": "存储库的唯一名称。", "xpack.snapshotRestore.repositoryForm.fields.nameDescriptionTitle": "存储库名称", From 7f7dbbb3cc0eb8d05a5c1d531ea9d56439375f72 Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Tue, 25 Jan 2022 23:01:09 +0100 Subject: [PATCH 30/46] [SecuritySolution][Timeline] Clean removed runtime fields (#122976) * remove unexisting fields from timeline * replicate clean logic in security timeline * tests updated due to mocks changes * solve action dipatches race condition * tests fixed * fix async dispatches Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../drag_drop_context_wrapper.test.tsx.snap | 131 ++++++++++++++ .../alert_summary_view.test.tsx.snap | 126 ++++++++----- .../common/containers/source/index.test.tsx | 22 +-- .../public/common/containers/source/mock.ts | 168 ++++++++++++++++++ .../containers/source/use_data_view.tsx | 110 ++++++------ .../components/create_field_button/index.tsx | 6 +- .../edit_data_provider/helpers.test.tsx | 23 ++- .../__snapshots__/index.test.tsx.snap | 131 ++++++++++++++ .../components/timeline/body/index.test.tsx | 28 ++- .../components/timeline/body/index.tsx | 18 +- .../suricata_row_renderer.test.tsx.snap | 131 ++++++++++++++ .../__snapshots__/zeek_details.test.tsx.snap | 131 ++++++++++++++ .../zeek_row_renderer.test.tsx.snap | 131 ++++++++++++++ .../__snapshots__/index.test.tsx.snap | 131 ++++++++++++++ .../components/t_grid/body/index.test.tsx | 28 ++- .../public/components/t_grid/body/index.tsx | 16 +- .../toolbar/fields_browser/helpers.test.tsx | 14 ++ .../toolbar/fields_browser/search.test.tsx | 4 +- .../timelines/public/mock/browser_fields.ts | 88 +++++++++ 19 files changed, 1315 insertions(+), 122 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/__snapshots__/drag_drop_context_wrapper.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/__snapshots__/drag_drop_context_wrapper.test.tsx.snap index 93c083eafbdf3..81169e33ec083 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/__snapshots__/drag_drop_context_wrapper.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/__snapshots__/drag_drop_context_wrapper.test.tsx.snap @@ -129,6 +129,35 @@ exports[`DragDropContextWrapper rendering it renders against the snapshot 1`] = "searchable": true, "type": "date", }, + "_id": Object { + "aggregatable": false, + "category": "base", + "description": "Each document has an _id that uniquely identifies it", + "example": "Y-6TfmcB0WOhS6qyMv3s", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "_id", + "searchable": true, + "type": "string", + }, + "message": Object { + "aggregatable": false, + "category": "base", + "description": "For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.", + "example": "Hello World", + "format": "string", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "message", + "searchable": true, + "type": "string", + }, }, }, "client": Object { @@ -359,6 +388,46 @@ exports[`DragDropContextWrapper rendering it renders against the snapshot 1`] = }, "event": Object { "fields": Object { + "event.action": Object { + "aggregatable": true, + "category": "event", + "description": "The action captured by the event. This describes the information in the event. It is more specific than \`event.category\`. Examples are \`group-add\`, \`process-started\`, \`file-created\`. The value is normally defined by the implementer.", + "example": "user-password-change", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.action", + "searchable": true, + "type": "string", + }, + "event.category": Object { + "aggregatable": true, + "category": "event", + "description": "This is one of four ECS Categorization Fields, and indicates the second level in the ECS category hierarchy. \`event.category\` represents the \\"big buckets\\" of ECS categories. For example, filtering on \`event.category:process\` yields all events relating to process activity. This field is closely related to \`event.type\`, which is used as a subcategory. This field is an array. This will allow proper categorization of some events that fall in multiple categories.", + "example": "authentication", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.category", + "searchable": true, + "type": "string", + }, "event.end": Object { "aggregatable": true, "category": "event", @@ -379,6 +448,49 @@ exports[`DragDropContextWrapper rendering it renders against the snapshot 1`] = "searchable": true, "type": "date", }, + "event.severity": Object { + "aggregatable": true, + "category": "event", + "description": "The numeric severity of the event according to your event source. What the different severity values mean can be different between sources and use cases. It's up to the implementer to make sure severities are consistent across events from the same source. The Syslog severity belongs in \`log.syslog.severity.code\`. \`event.severity\` is meant to represent the severity according to the event source (e.g. firewall, IDS). If the event source does not publish its own severity, you may optionally copy the \`log.syslog.severity.code\` to \`event.severity\`.", + "example": 7, + "format": "number", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.severity", + "searchable": true, + "type": "number", + }, + }, + }, + "host": Object { + "fields": Object { + "host.name": Object { + "aggregatable": true, + "category": "host", + "description": "Name of the host. It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "host.name", + "searchable": true, + "type": "string", + }, }, }, "nestedField": Object { @@ -459,6 +571,25 @@ exports[`DragDropContextWrapper rendering it renders against the snapshot 1`] = }, }, }, + "user": Object { + "fields": Object { + "user.name": Object { + "aggregatable": true, + "category": "user", + "description": "Short name or login of the user.", + "example": "albert", + "format": "string", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "user.name", + "searchable": true, + "type": "string", + }, + }, + }, } } > diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap index 2c7c820cdd7a3..88d76e5a9e43d 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap @@ -24,30 +24,40 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1` line-height: 1.7rem; } -.c2 { +.c2, +.c2 * { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: top; + white-space: nowrap; +} + +.c3 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; display: flex; } -.c2:focus-within .timelines__hoverActionButton, -.c2:focus-within .securitySolution__hoverActionButton { +.c3:focus-within .timelines__hoverActionButton, +.c3:focus-within .securitySolution__hoverActionButton { opacity: 1; } -.c2:hover .timelines__hoverActionButton, -.c2:hover .securitySolution__hoverActionButton { +.c3:hover .timelines__hoverActionButton, +.c3:hover .securitySolution__hoverActionButton { opacity: 1; } -.c2 .timelines__hoverActionButton, -.c2 .securitySolution__hoverActionButton { +.c3 .timelines__hoverActionButton, +.c3 .securitySolution__hoverActionButton { opacity: 0; } -.c2 .timelines__hoverActionButton:focus, -.c2 .securitySolution__hoverActionButton:focus { +.c3 .timelines__hoverActionButton:focus, +.c3 .securitySolution__hoverActionButton:focus { opacity: 1; } @@ -140,20 +150,27 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1` data-test-subj="event-field-host.name" >
-
- windows-native -
+ + windows-native + +

Overflow button @@ -213,20 +230,20 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1` data-test-subj="event-field-user.name" >

-
administrator -
+

Overflow button @@ -305,7 +322,7 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1` data-eui="EuiFocusTrap" >

-
- windows-native -
+ + windows-native + +

Overflow button @@ -554,20 +588,20 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`] data-test-subj="event-field-user.name" >

-
administrator -
+

Overflow button @@ -646,7 +680,7 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`] data-eui="EuiFocusTrap" >

{ await act(async () => { const { rerender, waitForNextUpdate, result } = renderHook< string, - { indexFieldsSearch: (id: string) => void } + { indexFieldsSearch: (id: string) => Promise } >(() => useDataView(), { wrapper: ({ children }) => {children}, }); await waitForNextUpdate(); rerender(); - act(() => result.current.indexFieldsSearch('neato')); - expect(mockDispatch.mock.calls[0][0]).toEqual({ - type: 'x-pack/security_solution/local/sourcerer/SET_DATA_VIEW_LOADING', - payload: { id: 'neato', loading: true }, - }); - const { type: sourceType, payload } = mockDispatch.mock.calls[1][0]; - expect(sourceType).toEqual('x-pack/security_solution/local/sourcerer/SET_DATA_VIEW'); - expect(payload.id).toEqual('neato'); - expect(Object.keys(payload.browserFields)).toHaveLength(10); - expect(payload.docValueFields).toEqual([{ field: '@timestamp' }]); + await result.current.indexFieldsSearch('neato'); + }); + expect(mockDispatch.mock.calls[0][0]).toEqual({ + type: 'x-pack/security_solution/local/sourcerer/SET_DATA_VIEW_LOADING', + payload: { id: 'neato', loading: true }, }); + const { type: sourceType, payload } = mockDispatch.mock.calls[1][0]; + expect(sourceType).toEqual('x-pack/security_solution/local/sourcerer/SET_DATA_VIEW'); + expect(payload.id).toEqual('neato'); + expect(Object.keys(payload.browserFields)).toHaveLength(12); + expect(payload.docValueFields).toEqual([{ field: '@timestamp' }]); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/containers/source/mock.ts b/x-pack/plugins/security_solution/public/common/containers/source/mock.ts index e34972f5226f3..a1b17484001d0 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/mock.ts +++ b/x-pack/plugins/security_solution/public/common/containers/source/mock.ts @@ -25,6 +25,28 @@ export const mocksSource = { aggregatable: true, readFromDocValues: true, }, + { + category: 'base', + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + name: '_id', + type: 'string', + searchable: true, + aggregatable: false, + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + }, + { + category: 'base', + description: + 'For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.', + example: 'Hello World', + name: 'message', + type: 'string', + searchable: true, + aggregatable: false, + format: 'string', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + }, { category: 'agent', description: @@ -296,6 +318,64 @@ export const mocksSource = { searchable: true, type: 'date', }, + { + category: 'event', + description: + 'The action captured by the event. This describes the information in the event. It is more specific than `event.category`. Examples are `group-add`, `process-started`, `file-created`. The value is normally defined by the implementer.', + example: 'user-password-change', + name: 'event.action', + type: 'string', + searchable: true, + aggregatable: true, + format: 'string', + indexes: DEFAULT_INDEX_PATTERN, + }, + { + category: 'event', + description: + 'This is one of four ECS Categorization Fields, and indicates the second level in the ECS category hierarchy. `event.category` represents the "big buckets" of ECS categories. For example, filtering on `event.category:process` yields all events relating to process activity. This field is closely related to `event.type`, which is used as a subcategory. This field is an array. This will allow proper categorization of some events that fall in multiple categories.', + example: 'authentication', + name: 'event.category', + type: 'string', + searchable: true, + aggregatable: true, + format: 'string', + indexes: DEFAULT_INDEX_PATTERN, + }, + { + category: 'event', + description: + "The numeric severity of the event according to your event source. What the different severity values mean can be different between sources and use cases. It's up to the implementer to make sure severities are consistent across events from the same source. The Syslog severity belongs in `log.syslog.severity.code`. `event.severity` is meant to represent the severity according to the event source (e.g. firewall, IDS). If the event source does not publish its own severity, you may optionally copy the `log.syslog.severity.code` to `event.severity`.", + example: 7, + name: 'event.severity', + type: 'number', + format: 'number', + searchable: true, + aggregatable: true, + indexes: DEFAULT_INDEX_PATTERN, + }, + { + category: 'host', + description: + 'Name of the host. It can contain what `hostname` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.', + name: 'host.name', + type: 'string', + searchable: true, + aggregatable: true, + format: 'string', + indexes: DEFAULT_INDEX_PATTERN, + }, + { + category: 'user', + description: 'Short name or login of the user.', + example: 'albert', + name: 'user.name', + type: 'string', + searchable: true, + aggregatable: true, + format: 'string', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + }, { aggregatable: false, category: 'nestedField', @@ -469,6 +549,28 @@ export const mockBrowserFields: BrowserFields = { type: 'date', readFromDocValues: true, }, + _id: { + category: 'base', + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + name: '_id', + type: 'string', + searchable: true, + aggregatable: false, + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + }, + message: { + category: 'base', + description: + 'For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.', + example: 'Hello World', + name: 'message', + type: 'string', + searchable: true, + aggregatable: false, + format: 'string', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + }, }, }, client: { @@ -659,6 +761,57 @@ export const mockBrowserFields: BrowserFields = { type: 'date', aggregatable: true, }, + 'event.action': { + category: 'event', + description: + 'The action captured by the event. This describes the information in the event. It is more specific than `event.category`. Examples are `group-add`, `process-started`, `file-created`. The value is normally defined by the implementer.', + example: 'user-password-change', + name: 'event.action', + type: 'string', + searchable: true, + aggregatable: true, + format: 'string', + indexes: DEFAULT_INDEX_PATTERN, + }, + 'event.category': { + category: 'event', + description: + 'This is one of four ECS Categorization Fields, and indicates the second level in the ECS category hierarchy. `event.category` represents the "big buckets" of ECS categories. For example, filtering on `event.category:process` yields all events relating to process activity. This field is closely related to `event.type`, which is used as a subcategory. This field is an array. This will allow proper categorization of some events that fall in multiple categories.', + example: 'authentication', + name: 'event.category', + type: 'string', + searchable: true, + aggregatable: true, + format: 'string', + indexes: DEFAULT_INDEX_PATTERN, + }, + 'event.severity': { + category: 'event', + description: + "The numeric severity of the event according to your event source. What the different severity values mean can be different between sources and use cases. It's up to the implementer to make sure severities are consistent across events from the same source. The Syslog severity belongs in `log.syslog.severity.code`. `event.severity` is meant to represent the severity according to the event source (e.g. firewall, IDS). If the event source does not publish its own severity, you may optionally copy the `log.syslog.severity.code` to `event.severity`.", + example: 7, + name: 'event.severity', + type: 'number', + format: 'number', + searchable: true, + aggregatable: true, + indexes: DEFAULT_INDEX_PATTERN, + }, + }, + }, + host: { + fields: { + 'host.name': { + category: 'host', + description: + 'Name of the host. It can contain what `hostname` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.', + name: 'host.name', + type: 'string', + searchable: true, + aggregatable: true, + format: 'string', + indexes: DEFAULT_INDEX_PATTERN, + }, }, }, source: { @@ -687,6 +840,21 @@ export const mockBrowserFields: BrowserFields = { }, }, }, + user: { + fields: { + 'user.name': { + category: 'user', + description: 'Short name or login of the user.', + example: 'albert', + name: 'user.name', + type: 'string', + searchable: true, + aggregatable: true, + format: 'string', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + }, + }, + }, nestedField: { fields: { 'nestedField.firstAttributes': { diff --git a/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx b/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx index 1b07fd2f391ac..b3a9466e43505 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx @@ -44,7 +44,7 @@ export const useDataView = (): { selectedDataViewId: string, scopeId?: SourcererScopeName, needToBeInit?: boolean - ) => void; + ) => Promise; } => { const { data } = useKibana().services; const abortCtrl = useRef>({}); @@ -82,63 +82,67 @@ export const useDataView = (): { ); } - const subscription = data.search - .search, IndexFieldsStrategyResponse>( - { - dataViewId: selectedDataViewId, - onlyCheckIfIndicesExist: false, - }, - { - abortSignal: abortCtrl.current[selectedDataViewId].signal, - strategy: 'indexFields', - } - ) - .subscribe({ - next: (response) => { - if (isCompleteResponse(response)) { - const patternString = response.indicesExist.sort().join(); - if (needToBeInit && scopeId) { + return new Promise((resolve) => { + const subscription = data.search + .search, IndexFieldsStrategyResponse>( + { + dataViewId: selectedDataViewId, + onlyCheckIfIndicesExist: false, + }, + { + abortSignal: abortCtrl.current[selectedDataViewId].signal, + strategy: 'indexFields', + } + ) + .subscribe({ + next: async (response) => { + if (isCompleteResponse(response)) { + const patternString = response.indicesExist.sort().join(); + if (needToBeInit && scopeId) { + dispatch( + sourcererActions.setSelectedDataView({ + id: scopeId, + selectedDataViewId, + selectedPatterns: response.indicesExist, + }) + ); + } dispatch( - sourcererActions.setSelectedDataView({ - id: scopeId, - selectedDataViewId, - selectedPatterns: response.indicesExist, + sourcererActions.setDataView({ + browserFields: getBrowserFields(patternString, response.indexFields), + docValueFields: getDocValueFields(patternString, response.indexFields), + id: selectedDataViewId, + indexFields: getEsFields(response.indexFields), + loading: false, + runtimeMappings: response.runtimeMappings, }) ); + searchSubscription$.current[selectedDataViewId]?.unsubscribe(); + } else if (isErrorResponse(response)) { + setLoading({ id: selectedDataViewId, loading: false }); + addWarning(i18n.ERROR_BEAT_FIELDS); + searchSubscription$.current[selectedDataViewId]?.unsubscribe(); + } + resolve(); + }, + error: (msg) => { + if (msg.message === DELETED_SECURITY_SOLUTION_DATA_VIEW) { + // reload app if security solution data view is deleted + return location.reload(); } - dispatch( - sourcererActions.setDataView({ - browserFields: getBrowserFields(patternString, response.indexFields), - docValueFields: getDocValueFields(patternString, response.indexFields), - id: selectedDataViewId, - indexFields: getEsFields(response.indexFields), - loading: false, - runtimeMappings: response.runtimeMappings, - }) - ); - searchSubscription$.current[selectedDataViewId]?.unsubscribe(); - } else if (isErrorResponse(response)) { setLoading({ id: selectedDataViewId, loading: false }); - addWarning(i18n.ERROR_BEAT_FIELDS); + addError(msg, { + title: i18n.FAIL_BEAT_FIELDS, + }); searchSubscription$.current[selectedDataViewId]?.unsubscribe(); - } - }, - error: (msg) => { - if (msg.message === DELETED_SECURITY_SOLUTION_DATA_VIEW) { - // reload app if security solution data view is deleted - return location.reload(); - } - setLoading({ id: selectedDataViewId, loading: false }); - addError(msg, { - title: i18n.FAIL_BEAT_FIELDS, - }); - searchSubscription$.current[selectedDataViewId]?.unsubscribe(); - }, - }); - searchSubscription$.current = { - ...searchSubscription$.current, - [selectedDataViewId]: subscription, - }; + resolve(); + }, + }); + searchSubscription$.current = { + ...searchSubscription$.current, + [selectedDataViewId]: subscription, + }; + }); }; if (searchSubscription$.current[selectedDataViewId]) { searchSubscription$.current[selectedDataViewId].unsubscribe(); @@ -146,7 +150,7 @@ export const useDataView = (): { if (abortCtrl.current[selectedDataViewId]) { abortCtrl.current[selectedDataViewId].abort(); } - asyncSearch(); + return asyncSearch(); }, [addError, addWarning, data.search, dispatch, setLoading] ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.tsx index e6c422ce809a4..04f23605efac5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.tsx @@ -54,11 +54,11 @@ export const CreateFieldButton = React.memo( if (dataView) { dataViewFieldEditor?.openEditor({ ctx: { dataView }, - onSave: (field: DataViewField) => { + onSave: async (field: DataViewField) => { // Fetch the updated list of fields - indexFieldsSearch(selectedDataViewId); + await indexFieldsSearch(selectedDataViewId); - // Add the new field to the event table + // Add the new field to the event table, after waiting for browserFields to be stored dispatch( upsertColumn({ column: { diff --git a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.test.tsx index c8e5764462f61..e890b724f78b5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.test.tsx @@ -49,7 +49,10 @@ describe('helpers', () => { { label: 'auditd.data.a2' }, ], }, - { label: 'base', options: [{ label: '@timestamp' }] }, + { + label: 'base', + options: [{ label: '@timestamp' }, { label: '_id' }, { label: 'message' }], + }, { label: 'client', options: [ @@ -81,7 +84,19 @@ describe('helpers', () => { { label: 'destination.port' }, ], }, - { label: 'event', options: [{ label: 'event.end' }] }, + { + label: 'event', + options: [ + { label: 'event.end' }, + { label: 'event.action' }, + { label: 'event.category' }, + { label: 'event.severity' }, + ], + }, + { + label: 'host', + options: [{ label: 'host.name' }], + }, { label: 'nestedField', options: [ @@ -94,6 +109,10 @@ describe('helpers', () => { ], }, { label: 'source', options: [{ label: 'source.ip' }, { label: 'source.port' }] }, + { + label: 'user', + options: [{ label: 'user.name' }], + }, ]); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap index 58513c7621c1b..644a3c95baf08 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap @@ -130,6 +130,35 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` "searchable": true, "type": "date", }, + "_id": Object { + "aggregatable": false, + "category": "base", + "description": "Each document has an _id that uniquely identifies it", + "example": "Y-6TfmcB0WOhS6qyMv3s", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "_id", + "searchable": true, + "type": "string", + }, + "message": Object { + "aggregatable": false, + "category": "base", + "description": "For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.", + "example": "Hello World", + "format": "string", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "message", + "searchable": true, + "type": "string", + }, }, }, "client": Object { @@ -360,6 +389,46 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` }, "event": Object { "fields": Object { + "event.action": Object { + "aggregatable": true, + "category": "event", + "description": "The action captured by the event. This describes the information in the event. It is more specific than \`event.category\`. Examples are \`group-add\`, \`process-started\`, \`file-created\`. The value is normally defined by the implementer.", + "example": "user-password-change", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.action", + "searchable": true, + "type": "string", + }, + "event.category": Object { + "aggregatable": true, + "category": "event", + "description": "This is one of four ECS Categorization Fields, and indicates the second level in the ECS category hierarchy. \`event.category\` represents the \\"big buckets\\" of ECS categories. For example, filtering on \`event.category:process\` yields all events relating to process activity. This field is closely related to \`event.type\`, which is used as a subcategory. This field is an array. This will allow proper categorization of some events that fall in multiple categories.", + "example": "authentication", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.category", + "searchable": true, + "type": "string", + }, "event.end": Object { "aggregatable": true, "category": "event", @@ -380,6 +449,49 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` "searchable": true, "type": "date", }, + "event.severity": Object { + "aggregatable": true, + "category": "event", + "description": "The numeric severity of the event according to your event source. What the different severity values mean can be different between sources and use cases. It's up to the implementer to make sure severities are consistent across events from the same source. The Syslog severity belongs in \`log.syslog.severity.code\`. \`event.severity\` is meant to represent the severity according to the event source (e.g. firewall, IDS). If the event source does not publish its own severity, you may optionally copy the \`log.syslog.severity.code\` to \`event.severity\`.", + "example": 7, + "format": "number", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.severity", + "searchable": true, + "type": "number", + }, + }, + }, + "host": Object { + "fields": Object { + "host.name": Object { + "aggregatable": true, + "category": "host", + "description": "Name of the host. It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "host.name", + "searchable": true, + "type": "string", + }, }, }, "nestedField": Object { @@ -460,6 +572,25 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` }, }, }, + "user": Object { + "fields": Object { + "user.name": Object { + "aggregatable": true, + "category": "user", + "description": "Short name or login of the user.", + "example": "albert", + "format": "string", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "user.name", + "searchable": true, + "type": "string", + }, + }, + }, } } columnHeaders={ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 0fdbc172adce8..5467dbab9845c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -22,7 +22,7 @@ import { Sort } from './sort'; import { getDefaultControlColumn } from './control_columns'; import { useMountAppended } from '../../../../common/utils/use_mount_appended'; import { timelineActions } from '../../../store/timeline'; -import { TimelineTabs } from '../../../../../common/types/timeline'; +import { ColumnHeaderOptions, TimelineTabs } from '../../../../../common/types/timeline'; import { defaultRowRenderers } from './renderers'; jest.mock('../../../../common/lib/kibana/hooks'); @@ -154,6 +154,10 @@ describe('Body', () => { }; describe('rendering', () => { + beforeEach(() => { + mockDispatch.mockClear(); + }); + test('it renders the column headers', () => { const wrapper = mount( @@ -206,6 +210,28 @@ describe('Body', () => { }); }); }, 20000); + + test('it dispatches the `REMOVE_COLUMN` action when there is a field removed from the custom fields', async () => { + const customFieldId = 'my.custom.runtimeField'; + const extraFieldProps = { + ...props, + columnHeaders: [ + ...defaultHeaders, + { id: customFieldId, category: 'my' } as ColumnHeaderOptions, + ], + }; + mount( + + + + ); + + expect(mockDispatch).toBeCalledTimes(1); + expect(mockDispatch).toBeCalledWith({ + payload: { columnId: customFieldId, id: 'timeline-test' }, + type: 'x-pack/timelines/t-grid/REMOVE_COLUMN', + }); + }); }); describe('action on event', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 2c0ae1daec235..7e7192610a222 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -5,10 +5,10 @@ * 2.0. */ -import { noop } from 'lodash/fp'; +import { noop, isEmpty } from 'lodash/fp'; import memoizeOne from 'memoize-one'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import { connect, ConnectedProps, useDispatch } from 'react-redux'; import deepEqual from 'fast-deep-equal'; import { @@ -99,6 +99,7 @@ export const BodyComponent = React.memo( leadingControlColumns = [], trailingControlColumns = [], }) => { + const dispatch = useDispatch(); const containerRef = useRef(null); const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []); const { queryFields, selectAll } = useDeepEqualSelector((state) => @@ -143,6 +144,19 @@ export const BodyComponent = React.memo( } }, [isSelectAllChecked, onSelectAll, selectAll]); + useEffect(() => { + if (!isEmpty(browserFields) && !isEmpty(columnHeaders)) { + columnHeaders.forEach(({ id: columnId }) => { + if (browserFields.base?.fields?.[columnId] == null) { + const [category] = columnId.split('.'); + if (browserFields[category]?.fields?.[columnId] == null) { + dispatch(timelineActions.removeColumn({ id, columnId })); + } + } + }); + } + }, [browserFields, columnHeaders, dispatch, id]); + const enabledRowRenderers = useMemo(() => { if ( excludedRowRendererIds && diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/__snapshots__/suricata_row_renderer.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/__snapshots__/suricata_row_renderer.test.tsx.snap index 990f3a0af87c0..d6fccadf02e9d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/__snapshots__/suricata_row_renderer.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/__snapshots__/suricata_row_renderer.test.tsx.snap @@ -131,6 +131,35 @@ exports[`suricata_row_renderer renders correctly against snapshot 1`] = ` "searchable": true, "type": "date", }, + "_id": Object { + "aggregatable": false, + "category": "base", + "description": "Each document has an _id that uniquely identifies it", + "example": "Y-6TfmcB0WOhS6qyMv3s", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "_id", + "searchable": true, + "type": "string", + }, + "message": Object { + "aggregatable": false, + "category": "base", + "description": "For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.", + "example": "Hello World", + "format": "string", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "message", + "searchable": true, + "type": "string", + }, }, }, "client": Object { @@ -361,6 +390,46 @@ exports[`suricata_row_renderer renders correctly against snapshot 1`] = ` }, "event": Object { "fields": Object { + "event.action": Object { + "aggregatable": true, + "category": "event", + "description": "The action captured by the event. This describes the information in the event. It is more specific than \`event.category\`. Examples are \`group-add\`, \`process-started\`, \`file-created\`. The value is normally defined by the implementer.", + "example": "user-password-change", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.action", + "searchable": true, + "type": "string", + }, + "event.category": Object { + "aggregatable": true, + "category": "event", + "description": "This is one of four ECS Categorization Fields, and indicates the second level in the ECS category hierarchy. \`event.category\` represents the \\"big buckets\\" of ECS categories. For example, filtering on \`event.category:process\` yields all events relating to process activity. This field is closely related to \`event.type\`, which is used as a subcategory. This field is an array. This will allow proper categorization of some events that fall in multiple categories.", + "example": "authentication", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.category", + "searchable": true, + "type": "string", + }, "event.end": Object { "aggregatable": true, "category": "event", @@ -381,6 +450,49 @@ exports[`suricata_row_renderer renders correctly against snapshot 1`] = ` "searchable": true, "type": "date", }, + "event.severity": Object { + "aggregatable": true, + "category": "event", + "description": "The numeric severity of the event according to your event source. What the different severity values mean can be different between sources and use cases. It's up to the implementer to make sure severities are consistent across events from the same source. The Syslog severity belongs in \`log.syslog.severity.code\`. \`event.severity\` is meant to represent the severity according to the event source (e.g. firewall, IDS). If the event source does not publish its own severity, you may optionally copy the \`log.syslog.severity.code\` to \`event.severity\`.", + "example": 7, + "format": "number", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.severity", + "searchable": true, + "type": "number", + }, + }, + }, + "host": Object { + "fields": Object { + "host.name": Object { + "aggregatable": true, + "category": "host", + "description": "Name of the host. It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "host.name", + "searchable": true, + "type": "string", + }, }, }, "nestedField": Object { @@ -461,6 +573,25 @@ exports[`suricata_row_renderer renders correctly against snapshot 1`] = ` }, }, }, + "user": Object { + "fields": Object { + "user.name": Object { + "aggregatable": true, + "category": "user", + "description": "Short name or login of the user.", + "example": "albert", + "format": "string", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "user.name", + "searchable": true, + "type": "string", + }, + }, + }, } } data={ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/__snapshots__/zeek_details.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/__snapshots__/zeek_details.test.tsx.snap index 546b9a31843ac..107239a9aa52f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/__snapshots__/zeek_details.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/__snapshots__/zeek_details.test.tsx.snap @@ -129,6 +129,35 @@ exports[`ZeekDetails rendering it renders the default ZeekDetails 1`] = ` "searchable": true, "type": "date", }, + "_id": Object { + "aggregatable": false, + "category": "base", + "description": "Each document has an _id that uniquely identifies it", + "example": "Y-6TfmcB0WOhS6qyMv3s", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "_id", + "searchable": true, + "type": "string", + }, + "message": Object { + "aggregatable": false, + "category": "base", + "description": "For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.", + "example": "Hello World", + "format": "string", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "message", + "searchable": true, + "type": "string", + }, }, }, "client": Object { @@ -359,6 +388,46 @@ exports[`ZeekDetails rendering it renders the default ZeekDetails 1`] = ` }, "event": Object { "fields": Object { + "event.action": Object { + "aggregatable": true, + "category": "event", + "description": "The action captured by the event. This describes the information in the event. It is more specific than \`event.category\`. Examples are \`group-add\`, \`process-started\`, \`file-created\`. The value is normally defined by the implementer.", + "example": "user-password-change", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.action", + "searchable": true, + "type": "string", + }, + "event.category": Object { + "aggregatable": true, + "category": "event", + "description": "This is one of four ECS Categorization Fields, and indicates the second level in the ECS category hierarchy. \`event.category\` represents the \\"big buckets\\" of ECS categories. For example, filtering on \`event.category:process\` yields all events relating to process activity. This field is closely related to \`event.type\`, which is used as a subcategory. This field is an array. This will allow proper categorization of some events that fall in multiple categories.", + "example": "authentication", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.category", + "searchable": true, + "type": "string", + }, "event.end": Object { "aggregatable": true, "category": "event", @@ -379,6 +448,49 @@ exports[`ZeekDetails rendering it renders the default ZeekDetails 1`] = ` "searchable": true, "type": "date", }, + "event.severity": Object { + "aggregatable": true, + "category": "event", + "description": "The numeric severity of the event according to your event source. What the different severity values mean can be different between sources and use cases. It's up to the implementer to make sure severities are consistent across events from the same source. The Syslog severity belongs in \`log.syslog.severity.code\`. \`event.severity\` is meant to represent the severity according to the event source (e.g. firewall, IDS). If the event source does not publish its own severity, you may optionally copy the \`log.syslog.severity.code\` to \`event.severity\`.", + "example": 7, + "format": "number", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.severity", + "searchable": true, + "type": "number", + }, + }, + }, + "host": Object { + "fields": Object { + "host.name": Object { + "aggregatable": true, + "category": "host", + "description": "Name of the host. It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "host.name", + "searchable": true, + "type": "string", + }, }, }, "nestedField": Object { @@ -459,6 +571,25 @@ exports[`ZeekDetails rendering it renders the default ZeekDetails 1`] = ` }, }, }, + "user": Object { + "fields": Object { + "user.name": Object { + "aggregatable": true, + "category": "user", + "description": "Short name or login of the user.", + "example": "albert", + "format": "string", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "user.name", + "searchable": true, + "type": "string", + }, + }, + }, } } data={ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/__snapshots__/zeek_row_renderer.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/__snapshots__/zeek_row_renderer.test.tsx.snap index e20f63a7b9e79..938f047f7014e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/__snapshots__/zeek_row_renderer.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/__snapshots__/zeek_row_renderer.test.tsx.snap @@ -131,6 +131,35 @@ exports[`zeek_row_renderer renders correctly against snapshot 1`] = ` "searchable": true, "type": "date", }, + "_id": Object { + "aggregatable": false, + "category": "base", + "description": "Each document has an _id that uniquely identifies it", + "example": "Y-6TfmcB0WOhS6qyMv3s", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "_id", + "searchable": true, + "type": "string", + }, + "message": Object { + "aggregatable": false, + "category": "base", + "description": "For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.", + "example": "Hello World", + "format": "string", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "message", + "searchable": true, + "type": "string", + }, }, }, "client": Object { @@ -361,6 +390,46 @@ exports[`zeek_row_renderer renders correctly against snapshot 1`] = ` }, "event": Object { "fields": Object { + "event.action": Object { + "aggregatable": true, + "category": "event", + "description": "The action captured by the event. This describes the information in the event. It is more specific than \`event.category\`. Examples are \`group-add\`, \`process-started\`, \`file-created\`. The value is normally defined by the implementer.", + "example": "user-password-change", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.action", + "searchable": true, + "type": "string", + }, + "event.category": Object { + "aggregatable": true, + "category": "event", + "description": "This is one of four ECS Categorization Fields, and indicates the second level in the ECS category hierarchy. \`event.category\` represents the \\"big buckets\\" of ECS categories. For example, filtering on \`event.category:process\` yields all events relating to process activity. This field is closely related to \`event.type\`, which is used as a subcategory. This field is an array. This will allow proper categorization of some events that fall in multiple categories.", + "example": "authentication", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.category", + "searchable": true, + "type": "string", + }, "event.end": Object { "aggregatable": true, "category": "event", @@ -381,6 +450,49 @@ exports[`zeek_row_renderer renders correctly against snapshot 1`] = ` "searchable": true, "type": "date", }, + "event.severity": Object { + "aggregatable": true, + "category": "event", + "description": "The numeric severity of the event according to your event source. What the different severity values mean can be different between sources and use cases. It's up to the implementer to make sure severities are consistent across events from the same source. The Syslog severity belongs in \`log.syslog.severity.code\`. \`event.severity\` is meant to represent the severity according to the event source (e.g. firewall, IDS). If the event source does not publish its own severity, you may optionally copy the \`log.syslog.severity.code\` to \`event.severity\`.", + "example": 7, + "format": "number", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.severity", + "searchable": true, + "type": "number", + }, + }, + }, + "host": Object { + "fields": Object { + "host.name": Object { + "aggregatable": true, + "category": "host", + "description": "Name of the host. It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "host.name", + "searchable": true, + "type": "string", + }, }, }, "nestedField": Object { @@ -461,6 +573,25 @@ exports[`zeek_row_renderer renders correctly against snapshot 1`] = ` }, }, }, + "user": Object { + "fields": Object { + "user.name": Object { + "aggregatable": true, + "category": "user", + "description": "Short name or login of the user.", + "example": "albert", + "format": "string", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "user.name", + "searchable": true, + "type": "string", + }, + }, + }, } } data={ diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/__snapshots__/index.test.tsx.snap index 56242d420d546..233e1c921cd50 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/__snapshots__/index.test.tsx.snap @@ -129,6 +129,35 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` "searchable": true, "type": "date", }, + "_id": Object { + "aggregatable": false, + "category": "base", + "description": "Each document has an _id that uniquely identifies it", + "example": "Y-6TfmcB0WOhS6qyMv3s", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "_id", + "searchable": true, + "type": "string", + }, + "message": Object { + "aggregatable": false, + "category": "base", + "description": "For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.", + "example": "Hello World", + "format": "string", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "message", + "searchable": true, + "type": "string", + }, }, }, "client": Object { @@ -359,6 +388,46 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` }, "event": Object { "fields": Object { + "event.action": Object { + "aggregatable": true, + "category": "event", + "description": "The action captured by the event. This describes the information in the event. It is more specific than \`event.category\`. Examples are \`group-add\`, \`process-started\`, \`file-created\`. The value is normally defined by the implementer.", + "example": "user-password-change", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.action", + "searchable": true, + "type": "string", + }, + "event.category": Object { + "aggregatable": true, + "category": "event", + "description": "This is one of four ECS Categorization Fields, and indicates the second level in the ECS category hierarchy. \`event.category\` represents the \\"big buckets\\" of ECS categories. For example, filtering on \`event.category:process\` yields all events relating to process activity. This field is closely related to \`event.type\`, which is used as a subcategory. This field is an array. This will allow proper categorization of some events that fall in multiple categories.", + "example": "authentication", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.category", + "searchable": true, + "type": "string", + }, "event.end": Object { "aggregatable": true, "category": "event", @@ -379,6 +448,49 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` "searchable": true, "type": "date", }, + "event.severity": Object { + "aggregatable": true, + "category": "event", + "description": "The numeric severity of the event according to your event source. What the different severity values mean can be different between sources and use cases. It's up to the implementer to make sure severities are consistent across events from the same source. The Syslog severity belongs in \`log.syslog.severity.code\`. \`event.severity\` is meant to represent the severity according to the event source (e.g. firewall, IDS). If the event source does not publish its own severity, you may optionally copy the \`log.syslog.severity.code\` to \`event.severity\`.", + "example": 7, + "format": "number", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.severity", + "searchable": true, + "type": "number", + }, + }, + }, + "host": Object { + "fields": Object { + "host.name": Object { + "aggregatable": true, + "category": "host", + "description": "Name of the host. It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.", + "format": "string", + "indexes": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "host.name", + "searchable": true, + "type": "string", + }, }, }, "nestedField": Object { @@ -459,6 +571,25 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` }, }, }, + "user": Object { + "fields": Object { + "user.name": Object { + "aggregatable": true, + "category": "user", + "description": "Short name or login of the user.", + "example": "albert", + "format": "string", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "user.name", + "searchable": true, + "type": "string", + }, + }, + }, } } columnHeaders={ diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx index 9e85ff5f6cde9..0e39ca272d4b9 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx @@ -14,7 +14,7 @@ import { REMOVE_COLUMN } from './column_headers/translations'; import { Direction } from '../../../../common/search_strategy'; import { useMountAppended } from '../../utils/use_mount_appended'; import { defaultHeaders, mockBrowserFields, mockTimelineData, TestProviders } from '../../../mock'; -import { TimelineTabs } from '../../../../common/types/timeline'; +import { ColumnHeaderOptions, TimelineTabs } from '../../../../common/types/timeline'; import { TestCellRenderer } from '../../../mock/cell_renderer'; import { mockGlobalState } from '../../../mock/global_state'; import { EuiDataGridColumn } from '@elastic/eui'; @@ -95,6 +95,10 @@ describe('Body', () => { indexNames: [''], }; + beforeEach(() => { + mockDispatch.mockReset(); + }); + describe('rendering', () => { test('it renders the body data grid', () => { const wrapper = mount( @@ -330,4 +334,26 @@ describe('Body', () => { type: 'x-pack/timelines/t-grid/UPDATE_COLUMN_WIDTH', }); }); + + test('it dispatches the `REMOVE_COLUMN` action when there is a field removed from the custom fields', async () => { + const customFieldId = 'my.custom.runtimeField'; + const extraFieldProps = { + ...props, + columnHeaders: [ + ...defaultHeaders, + { id: customFieldId, category: 'my' } as ColumnHeaderOptions, + ], + }; + render( + + + + ); + + expect(mockDispatch).toBeCalledTimes(1); + expect(mockDispatch).toBeCalledWith({ + payload: { columnId: customFieldId, id: 'timeline-test' }, + type: 'x-pack/timelines/t-grid/REMOVE_COLUMN', + }); + }); }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx index 0abe40405c4ef..d2c8f22b1d3ac 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx @@ -17,7 +17,7 @@ import { EuiFlexItem, EuiProgress, } from '@elastic/eui'; -import { getOr } from 'lodash/fp'; +import { getOr, isEmpty } from 'lodash/fp'; import memoizeOne from 'memoize-one'; import React, { ComponentType, @@ -393,6 +393,20 @@ export const BodyComponent = React.memo( } }, [isSelectAllChecked, onSelectPage, selectAll]); + // Clean any removed custom field that may still be present in stored columnHeaders + useEffect(() => { + if (!isEmpty(browserFields) && !isEmpty(columnHeaders)) { + columnHeaders.forEach(({ id: columnId }) => { + if (browserFields.base?.fields?.[columnId] == null) { + const [category] = columnId.split('.'); + if (browserFields[category]?.fields?.[columnId] == null) { + dispatch(tGridActions.removeColumn({ id, columnId })); + } + } + }); + } + }, [browserFields, columnHeaders, dispatch, id]); + const onAlertStatusActionSuccess = useMemo(() => { if (bulkActions && bulkActions !== true) { return bulkActions.onAlertStatusActionSuccess; diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.test.tsx index 75166bba5b111..239d7c726e286 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.test.tsx @@ -232,6 +232,20 @@ describe('helpers', () => { }, }, }, + base: { + fields: { + _id: { + category: 'base', + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + name: '_id', + type: 'string', + searchable: true, + aggregatable: false, + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + }, + }, + }, cloud: { fields: { 'cloud.account.id': { diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/search.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/search.test.tsx index 8d2c3d4714541..f5668b1bdc08d 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/search.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/search.test.tsx @@ -121,7 +121,7 @@ describe('Search', () => { ); expect(wrapper.find('[data-test-subj="categories-count"]').first().text()).toEqual( - '10 categories' + '12 categories' ); }); @@ -154,6 +154,6 @@ describe('Search', () => { ); - expect(wrapper.find('[data-test-subj="fields-count"]').first().text()).toEqual('27 fields'); + expect(wrapper.find('[data-test-subj="fields-count"]').first().text()).toEqual('34 fields'); }); }); diff --git a/x-pack/plugins/timelines/public/mock/browser_fields.ts b/x-pack/plugins/timelines/public/mock/browser_fields.ts index dd7d211058bad..948d1e1f68081 100644 --- a/x-pack/plugins/timelines/public/mock/browser_fields.ts +++ b/x-pack/plugins/timelines/public/mock/browser_fields.ts @@ -471,6 +471,28 @@ export const mockBrowserFields: BrowserFields = { searchable: true, type: 'date', }, + _id: { + category: 'base', + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + name: '_id', + type: 'string', + searchable: true, + aggregatable: false, + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + }, + message: { + category: 'base', + description: + 'For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.', + example: 'Hello World', + name: 'message', + type: 'string', + searchable: true, + aggregatable: false, + format: 'string', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + }, }, }, client: { @@ -661,6 +683,57 @@ export const mockBrowserFields: BrowserFields = { type: 'date', aggregatable: true, }, + 'event.action': { + category: 'event', + description: + 'The action captured by the event. This describes the information in the event. It is more specific than `event.category`. Examples are `group-add`, `process-started`, `file-created`. The value is normally defined by the implementer.', + example: 'user-password-change', + name: 'event.action', + type: 'string', + searchable: true, + aggregatable: true, + format: 'string', + indexes: DEFAULT_INDEX_PATTERN, + }, + 'event.category': { + category: 'event', + description: + 'This is one of four ECS Categorization Fields, and indicates the second level in the ECS category hierarchy. `event.category` represents the "big buckets" of ECS categories. For example, filtering on `event.category:process` yields all events relating to process activity. This field is closely related to `event.type`, which is used as a subcategory. This field is an array. This will allow proper categorization of some events that fall in multiple categories.', + example: 'authentication', + name: 'event.category', + type: 'string', + searchable: true, + aggregatable: true, + format: 'string', + indexes: DEFAULT_INDEX_PATTERN, + }, + 'event.severity': { + category: 'event', + description: + "The numeric severity of the event according to your event source. What the different severity values mean can be different between sources and use cases. It's up to the implementer to make sure severities are consistent across events from the same source. The Syslog severity belongs in `log.syslog.severity.code`. `event.severity` is meant to represent the severity according to the event source (e.g. firewall, IDS). If the event source does not publish its own severity, you may optionally copy the `log.syslog.severity.code` to `event.severity`.", + example: 7, + name: 'event.severity', + type: 'number', + format: 'number', + searchable: true, + aggregatable: true, + indexes: DEFAULT_INDEX_PATTERN, + }, + }, + }, + host: { + fields: { + 'host.name': { + category: 'host', + description: + 'Name of the host. It can contain what `hostname` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.', + name: 'host.name', + type: 'string', + searchable: true, + aggregatable: true, + format: 'string', + indexes: DEFAULT_INDEX_PATTERN, + }, }, }, source: { @@ -689,6 +762,21 @@ export const mockBrowserFields: BrowserFields = { }, }, }, + user: { + fields: { + 'user.name': { + category: 'user', + description: 'Short name or login of the user.', + example: 'albert', + name: 'user.name', + type: 'string', + searchable: true, + aggregatable: true, + format: 'string', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + }, + }, + }, nestedField: { fields: { 'nestedField.firstAttributes': { From dfd8bfbd90933e66f8cc92cf25edf62163a245e4 Mon Sep 17 00:00:00 2001 From: Ashokaditya <1849116+ashokaditya@users.noreply.github.com> Date: Tue, 25 Jan 2022 23:22:33 +0100 Subject: [PATCH 31/46] [Security Solution][Endpoint] Search responses without a specific namespace to show pending actions (#123741) * search responses without a specific namespace to show pending actions fixes elastic/kibana/issues/123707 * search over all endpoint response indices irrespective of namespace suffix fixes elastic/kibana/issues/123707 * match namespace suffixes for endpoint action and response indices fixes elastic/kibana/issues/123707 --- .../security_solution/common/endpoint/constants.ts | 2 ++ .../server/endpoint/services/actions.ts | 4 ++-- .../server/endpoint/utils/audit_log_helpers.ts | 10 ++++++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index c8af729ec3a68..cf579f7ea5a90 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -11,6 +11,8 @@ export const ENDPOINT_ACTIONS_DS = '.logs-endpoint.actions'; export const ENDPOINT_ACTIONS_INDEX = `${ENDPOINT_ACTIONS_DS}-default`; export const ENDPOINT_ACTION_RESPONSES_DS = '.logs-endpoint.action.responses'; export const ENDPOINT_ACTION_RESPONSES_INDEX = `${ENDPOINT_ACTION_RESPONSES_DS}-default`; +// search in all namespaces and not only in default +export const ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN = `${ENDPOINT_ACTION_RESPONSES_DS}-*`; export const eventsIndexPattern = 'logs-endpoint.events.*'; export const alertsIndexPattern = 'logs-endpoint.alerts-*'; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions.ts index fb9348d3f05bc..93a3da27a7d5a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions.ts @@ -9,7 +9,7 @@ import { ElasticsearchClient, Logger } from 'kibana/server'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { TransportResult } from '@elastic/elasticsearch'; import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../../fleet/common'; -import { ENDPOINT_ACTION_RESPONSES_INDEX } from '../../../common/endpoint/constants'; +import { ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN } from '../../../common/endpoint/constants'; import { SecuritySolutionRequestHandlerContext } from '../../types'; import { ActivityLog, @@ -293,7 +293,7 @@ const hasEndpointResponseDoc = async ({ const response = await esClient .search( { - index: ENDPOINT_ACTION_RESPONSES_INDEX, + index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, size: 10000, body: { query: { diff --git a/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts b/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts index df28c7ca02dc2..4d548141e9819 100644 --- a/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts @@ -12,7 +12,7 @@ import { TransportResult } from '@elastic/elasticsearch'; import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../../fleet/common'; import { ENDPOINT_ACTIONS_INDEX, - ENDPOINT_ACTION_RESPONSES_INDEX, + ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, failedFleetActionErrorCode, } from '../../../common/endpoint/constants'; import { SecuritySolutionRequestHandlerContext } from '../../types'; @@ -32,10 +32,12 @@ import { import { doesLogsEndpointActionsIndexExist } from '../utils'; const actionsIndices = [AGENT_ACTIONS_INDEX, ENDPOINT_ACTIONS_INDEX]; -const responseIndices = [AGENT_ACTIONS_RESULTS_INDEX, ENDPOINT_ACTION_RESPONSES_INDEX]; +// search all responses indices irrelevant of namespace +const responseIndices = [AGENT_ACTIONS_RESULTS_INDEX, ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN]; export const logsEndpointActionsRegex = new RegExp(`(^\.ds-\.logs-endpoint\.actions-default-).+`); +// matches index names like .ds-.logs-endpoint.action.responses-name_space---suffix-2022.01.25-000001 export const logsEndpointResponsesRegex = new RegExp( - `(^\.ds-\.logs-endpoint\.action\.responses-default-).+` + `(^\.ds-\.logs-endpoint\.action\.responses-\\w+-).+` ); const queryOptions = { headers: { @@ -231,7 +233,7 @@ export const getActionResponsesResult = async ({ const hasLogsEndpointActionResponsesIndex = await doesLogsEndpointActionsIndexExist({ context, logger, - indexName: ENDPOINT_ACTION_RESPONSES_INDEX, + indexName: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, }); const responsesSearchQuery: SearchRequest = { From 54500ff091dc898ae0172bec24a25221fb5b3377 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Tue, 25 Jan 2022 16:25:14 -0600 Subject: [PATCH 32/46] [ci] Build and publish webpack bundle reports (#123659) * [ci] Build and publish webpack bundle reports * newline * indent * fix directory name * fixes * cleanup * uncomment build plugins --- .buildkite/pipelines/pull_request/base.yml | 7 ++ .../build_and_upload.sh | 15 ++++ .../steps/webpack_bundle_analyzer/upload.js | 79 +++++++++++++++++++ package.json | 1 + yarn.lock | 70 +++++++++++++++- 5 files changed, 170 insertions(+), 2 deletions(-) create mode 100755 .buildkite/scripts/steps/webpack_bundle_analyzer/build_and_upload.sh create mode 100644 .buildkite/scripts/steps/webpack_bundle_analyzer/upload.js diff --git a/.buildkite/pipelines/pull_request/base.yml b/.buildkite/pipelines/pull_request/base.yml index b99473c23d746..9b9d8ddfcde69 100644 --- a/.buildkite/pipelines/pull_request/base.yml +++ b/.buildkite/pipelines/pull_request/base.yml @@ -166,3 +166,10 @@ steps: queue: c2-4 key: storybooks timeout_in_minutes: 60 + + - command: .buildkite/scripts/steps/webpack_bundle_analyzer/build_and_upload.sh + label: 'Build Webpack Bundle Analyzer reports' + agents: + queue: n2-2 + key: webpack_bundle_analyzer + timeout_in_minutes: 60 diff --git a/.buildkite/scripts/steps/webpack_bundle_analyzer/build_and_upload.sh b/.buildkite/scripts/steps/webpack_bundle_analyzer/build_and_upload.sh new file mode 100755 index 0000000000000..8a978db6825d2 --- /dev/null +++ b/.buildkite/scripts/steps/webpack_bundle_analyzer/build_and_upload.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -euo pipefail + +.buildkite/scripts/bootstrap.sh + +node scripts/build_kibana_platform_plugins.js --dist --profile + +mkdir -p built_assets/webpack_bundle_analyzer +find . -path "*target/public/*" -name "stats.json" | while read line; do + PLUGIN=$(echo $line | xargs dirname | xargs dirname | xargs dirname | xargs basename) + ./node_modules/.bin/webpack-bundle-analyzer $line --report "built_assets/webpack_bundle_analyzer/$PLUGIN.html" --mode static --no-open +done + +node .buildkite/scripts/steps/webpack_bundle_analyzer/upload.js diff --git a/.buildkite/scripts/steps/webpack_bundle_analyzer/upload.js b/.buildkite/scripts/steps/webpack_bundle_analyzer/upload.js new file mode 100644 index 0000000000000..6d61608973cbc --- /dev/null +++ b/.buildkite/scripts/steps/webpack_bundle_analyzer/upload.js @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const execSync = require('child_process').execSync; +const fs = require('fs'); +const path = require('path'); + +const GITHUB_CONTEXT = 'Build and Publish Webpack bundle analyzer reports'; + +const WEBPACK_REPORTS = + process.env.BUILDKITE_PULL_REQUEST && process.env.BUILDKITE_PULL_REQUEST !== 'false' + ? `pr-${process.env.BUILDKITE_PULL_REQUEST}` + : process.env.BUILDKITE_BRANCH.replace('/', '__'); +const WEBPACK_REPORTS_BUCKET = 'ci-artifacts.kibana.dev/webpack_bundle_analyzer'; +const WEBPACK_REPORTS_BUCKET_URL = `https://${WEBPACK_REPORTS_BUCKET}/${WEBPACK_REPORTS}`; +const WEBPACK_REPORTS_BASE_URL = `${WEBPACK_REPORTS_BUCKET_URL}/${process.env.BUILDKITE_COMMIT}`; + +const exec = (...args) => execSync(args.join(' '), { stdio: 'inherit' }); + +const ghStatus = (state, description) => + exec( + `gh api "repos/elastic/kibana/statuses/${process.env.BUILDKITE_COMMIT}"`, + `-f state=${state}`, + `-f target_url="${process.env.BUILDKITE_BUILD_URL}"`, + `-f context="${GITHUB_CONTEXT}"`, + `-f description="${description}"`, + `--silent` + ); + +const upload = () => { + const originalDirectory = process.cwd(); + process.chdir(path.join('.', 'built_assets', 'webpack_bundle_analyzer')); + try { + const reports = execSync(`ls -1`).toString().trim().split('\n'); + const listHtml = reports + .map((report) => `

  • ${report}
  • `) + .join('\n'); + + const html = ` + + +

    Webpack Bundle Analyzer

    +
      + ${listHtml} +
    + + + `; + + fs.writeFileSync('index.html', html); + console.log('--- Uploading Webpack Bundle Analyzer reports'); + exec(` + gsutil -q -m cp -r -z html '*' 'gs://${WEBPACK_REPORTS_BUCKET}/${WEBPACK_REPORTS}/${process.env.BUILDKITE_COMMIT}/' + gsutil -h "Cache-Control:no-cache, max-age=0, no-transform" cp -z html 'index.html' 'gs://${WEBPACK_REPORTS_BUCKET}/${WEBPACK_REPORTS}/latest/' + `); + + if (process.env.BUILDKITE_PULL_REQUEST && process.env.BUILDKITE_PULL_REQUEST !== 'false') { + exec( + `buildkite-agent meta-data set pr_comment:webpack_bundle_reports:head '* [Webpack Bundle Analyzer](${WEBPACK_REPORTS_BASE_URL})'` + ); + } + } finally { + process.chdir(originalDirectory); + } +}; + +try { + ghStatus('pending', 'Building Webpack Bundle Analyzer reports'); + upload(); + ghStatus('success', 'Webpack bundle analyzer reports built'); +} catch (error) { + ghStatus('error', 'Building Webpack Bundle Analyzer reports failed'); + throw error; +} diff --git a/package.json b/package.json index 69a69bcbd5805..3c1284644a1ad 100644 --- a/package.json +++ b/package.json @@ -881,6 +881,7 @@ "wait-on": "^5.2.1", "watchpack": "^1.6.0", "webpack": "^4.41.5", + "webpack-bundle-analyzer": "^4.5.0", "webpack-cli": "^3.3.12", "webpack-dev-server": "^3.11.0", "webpack-merge": "^4.2.2", diff --git a/yarn.lock b/yarn.lock index b349030577ad4..b1def198f8042 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3823,6 +3823,11 @@ schema-utils "^2.6.5" source-map "^0.7.3" +"@polka/url@^1.0.0-next.20": + version "1.0.0-next.21" + resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1" + integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g== + "@popperjs/core@^2.4.0", "@popperjs/core@^2.5.4", "@popperjs/core@^2.6.0": version "2.10.2" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.2.tgz#0798c03351f0dea1a5a4cabddf26a55a7cbee590" @@ -7269,7 +7274,7 @@ acorn-walk@^7.0.0, acorn-walk@^7.1.1, acorn-walk@^7.2.0: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== -acorn-walk@^8.1.1: +acorn-walk@^8.0.0, acorn-walk@^8.1.1: version "8.2.0" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== @@ -7289,6 +7294,11 @@ acorn@^7.0.0, acorn@^7.1.1, acorn@^7.4.0, acorn@^7.4.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== +acorn@^8.0.4: + version "8.7.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf" + integrity sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ== + acorn@^8.4.1: version "8.5.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.5.0.tgz#4512ccb99b3698c752591e9bb4472e38ad43cee2" @@ -10242,7 +10252,7 @@ commander@^6.1.0, commander@^6.2.1: resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== -commander@^7.0.0: +commander@^7.0.0, commander@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== @@ -12470,6 +12480,11 @@ duplexer@^0.1.1: resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" integrity sha1-rOb/gIwc5mtX0ev5eXessCM0z8E= +duplexer@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" + integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== + duplexify@^3.2.0, duplexify@^3.4.2, duplexify@^3.5.3: version "3.7.1" resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309" @@ -15354,6 +15369,13 @@ gzip-size@5.1.1: duplexer "^0.1.1" pify "^4.0.1" +gzip-size@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462" + integrity sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q== + dependencies: + duplexer "^0.1.2" + handle-thing@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.0.tgz#0e039695ff50c93fc288557d696f3c1dc6776754" @@ -20096,6 +20118,11 @@ move-concurrently@^1.0.1: rimraf "^2.5.4" run-queue "^1.0.3" +mrmime@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.0.tgz#14d387f0585a5233d291baba339b063752a2398b" + integrity sha512-a70zx7zFfVO7XpnQ2IX1Myh9yY4UYvfld/dikWRnsXxbyvMcfz+u6UfgNAtH+k2QqtJuzVpv6eLTx1G2+WKZbQ== + ms-chromium-edge-driver@^0.4.3: version "0.4.3" resolved "https://registry.yarnpkg.com/ms-chromium-edge-driver/-/ms-chromium-edge-driver-0.4.3.tgz#808723efaf24da086ebc2a2feb0975162164d2ff" @@ -21066,6 +21093,11 @@ opener@^1.4.2: resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.1.tgz#6d2f0e77f1a0af0032aca716c2c1fbb8e7e8abed" integrity sha512-goYSy5c2UXE4Ra1xixabeVh1guIX/ZV/YokJksb6q2lubWu6UbvPQ20p542/sFIll1nl8JnCyK9oBaOcCWXwvA== +opener@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" + integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== + opentracing@^0.14.3: version "0.14.4" resolved "https://registry.yarnpkg.com/opentracing/-/opentracing-0.14.4.tgz#a113408ea740da3a90fde5b3b0011a375c2e4268" @@ -25613,6 +25645,15 @@ sinon@^7.4.2: nise "^1.5.2" supports-color "^5.5.0" +sirv@^1.0.7: + version "1.0.19" + resolved "https://registry.yarnpkg.com/sirv/-/sirv-1.0.19.tgz#1d73979b38c7fe91fcba49c85280daa9c2363b49" + integrity sha512-JuLThK3TnZG1TAKDwNIqNq6QA2afLOCcm+iE8D1Kj3GA40pSPsxQjjJl0J8X3tsR7T+CP1GavpzLwYkgVLWrZQ== + dependencies: + "@polka/url" "^1.0.0-next.20" + mrmime "^1.0.0" + totalist "^1.0.0" + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -27547,6 +27588,11 @@ topojson-client@^3.1.0: dependencies: commander "2" +totalist@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df" + integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g== + touch@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/touch/-/touch-2.0.2.tgz#ca0b2a3ae3211246a61b16ba9e6cbf1596287164" @@ -29372,6 +29418,21 @@ webidl-conversions@^6.1.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== +webpack-bundle-analyzer@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.5.0.tgz#1b0eea2947e73528754a6f9af3e91b2b6e0f79d5" + integrity sha512-GUMZlM3SKwS8Z+CKeIFx7CVoHn3dXFcUAjT/dcZQQmfSZGvitPfMob2ipjai7ovFFqPvTqkEZ/leL4O0YOdAYQ== + dependencies: + acorn "^8.0.4" + acorn-walk "^8.0.0" + chalk "^4.1.0" + commander "^7.2.0" + gzip-size "^6.0.0" + lodash "^4.17.20" + opener "^1.5.2" + sirv "^1.0.7" + ws "^7.3.1" + webpack-cli@^3.3.12: version "3.3.12" resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.12.tgz#94e9ada081453cd0aa609c99e500012fd3ad2d4a" @@ -29856,6 +29917,11 @@ ws@^6.1.2, ws@^6.2.1: dependencies: async-limiter "~1.0.0" +ws@^7.3.1: + version "7.5.6" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.6.tgz#e59fc509fb15ddfb65487ee9765c5a51dec5fe7b" + integrity sha512-6GLgCqo2cy2A2rjCNFlxQS6ZljG/coZfZXclldI8FB/1G3CCI36Zd8xy2HrFVACi8tfk5XrgLQEk+P0Tnz9UcA== + x-is-function@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/x-is-function/-/x-is-function-1.0.4.tgz#5d294dc3d268cbdd062580e0c5df77a391d1fa1e" From 70d68d8e0f73a77dbc619da838163dffa137f051 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Tue, 25 Jan 2022 17:56:47 -0500 Subject: [PATCH 33/46] [Security Solution][Endpoint] Register NOOP callbacks for all Lists extension points (#123761) --- .../exceptions_pre_delete_item_handler.ts | 19 +++++++++ .../handlers/exceptions_pre_export_handler.ts | 19 +++++++++ .../exceptions_pre_get_one_handler.ts | 19 +++++++++ .../exceptions_pre_multi_list_find_handler.ts | 19 +++++++++ ...exceptions_pre_single_list_find_handler.ts | 19 +++++++++ .../exceptions_pre_summary_handler.ts | 19 +++++++++ .../register_endpoint_extension_points.ts | 42 +++++++++++++++++++ 7 files changed, 156 insertions(+) create mode 100644 x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts create mode 100644 x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts create mode 100644 x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts create mode 100644 x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts create mode 100644 x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts create mode 100644 x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts new file mode 100644 index 0000000000000..17502d5d2af74 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; +import { ExtensionPoint } from '../../../../../lists/server'; + +export const getExceptionsPreDeleteItemHandler = ( + endpointAppContext: EndpointAppContextService +): (ExtensionPoint & { type: 'exceptionsListPreDeleteItem' })['callback'] => { + return async function ({ data }) { + // Individual validators here + + return data; + }; +}; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts new file mode 100644 index 0000000000000..32e9c51d4241b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; +import { ExtensionPoint } from '../../../../../lists/server'; + +export const getExceptionsPreExportHandler = ( + endpointAppContext: EndpointAppContextService +): (ExtensionPoint & { type: 'exceptionsListPreExport' })['callback'] => { + return async function ({ data }) { + // Individual validators here + + return data; + }; +}; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts new file mode 100644 index 0000000000000..0a74aeceb734c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; +import { ExtensionPoint } from '../../../../../lists/server'; + +export const getExceptionsPreGetOneHandler = ( + endpointAppContext: EndpointAppContextService +): (ExtensionPoint & { type: 'exceptionsListPreGetOneItem' })['callback'] => { + return async function ({ data }) { + // Individual validators here + + return data; + }; +}; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts new file mode 100644 index 0000000000000..e167b6df72e8a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; +import { ExtensionPoint } from '../../../../../lists/server'; + +export const getExceptionsPreMultiListFindHandler = ( + endpointAppContext: EndpointAppContextService +): (ExtensionPoint & { type: 'exceptionsListPreMultiListFind' })['callback'] => { + return async function ({ data }) { + // Individual validators here + + return data; + }; +}; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts new file mode 100644 index 0000000000000..5fd3fa08ec321 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; +import { ExtensionPoint } from '../../../../../lists/server'; + +export const getExceptionsPreSingleListFindHandler = ( + endpointAppContext: EndpointAppContextService +): (ExtensionPoint & { type: 'exceptionsListPreSingleListFind' })['callback'] => { + return async function ({ data }) { + // Individual validators here + + return data; + }; +}; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts new file mode 100644 index 0000000000000..d98fbff5471d3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; +import { ExtensionPoint } from '../../../../../lists/server'; + +export const getExceptionsPreSummaryHandler = ( + endpointAppContext: EndpointAppContextService +): (ExtensionPoint & { type: 'exceptionsListPreSummary' })['callback'] => { + return async function ({ data }) { + // Individual validators here + + return data; + }; +}; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/register_endpoint_extension_points.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/register_endpoint_extension_points.ts index bc0c59f44be13..f076270cd8503 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/register_endpoint_extension_points.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/register_endpoint_extension_points.ts @@ -9,6 +9,12 @@ import { EndpointAppContextService } from '../../endpoint/endpoint_app_context_s import { getExceptionsPreCreateItemHandler } from './handlers/exceptions_pre_create_handler'; import { getExceptionsPreUpdateItemHandler } from './handlers/exceptions_pre_update_handler'; import type { ListsServerExtensionRegistrar } from '../../../../lists/server'; +import { getExceptionsPreGetOneHandler } from './handlers/exceptions_pre_get_one_handler'; +import { getExceptionsPreSummaryHandler } from './handlers/exceptions_pre_summary_handler'; +import { getExceptionsPreDeleteItemHandler } from './handlers/exceptions_pre_delete_item_handler'; +import { getExceptionsPreExportHandler } from './handlers/exceptions_pre_export_handler'; +import { getExceptionsPreMultiListFindHandler } from './handlers/exceptions_pre_multi_list_find_handler'; +import { getExceptionsPreSingleListFindHandler } from './handlers/exceptions_pre_single_list_find_handler'; export const registerListsPluginEndpointExtensionPoints = ( registerListsExtensionPoint: ListsServerExtensionRegistrar, @@ -25,4 +31,40 @@ export const registerListsPluginEndpointExtensionPoints = ( type: 'exceptionsListPreUpdateItem', callback: getExceptionsPreUpdateItemHandler(endpointAppContextService), }); + + // PRE-GET ONE + registerListsExtensionPoint({ + type: 'exceptionsListPreGetOneItem', + callback: getExceptionsPreGetOneHandler(endpointAppContextService), + }); + + // PRE-SUMMARY + registerListsExtensionPoint({ + type: 'exceptionsListPreSummary', + callback: getExceptionsPreSummaryHandler(endpointAppContextService), + }); + + // PRE-DELETE item + registerListsExtensionPoint({ + type: 'exceptionsListPreDeleteItem', + callback: getExceptionsPreDeleteItemHandler(endpointAppContextService), + }); + + // PRE-EXPORT + registerListsExtensionPoint({ + type: 'exceptionsListPreExport', + callback: getExceptionsPreExportHandler(endpointAppContextService), + }); + + // PRE-MULTI-LIST-FIND + registerListsExtensionPoint({ + type: 'exceptionsListPreMultiListFind', + callback: getExceptionsPreMultiListFindHandler(endpointAppContextService), + }); + + // PRE-SINGLE-LIST-FIND + registerListsExtensionPoint({ + type: 'exceptionsListPreSingleListFind', + callback: getExceptionsPreSingleListFindHandler(endpointAppContextService), + }); }; From 797f07bf7d3f82949e71ca3eb4449c1760e9ac2b Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Tue, 25 Jan 2022 19:18:00 -0500 Subject: [PATCH 34/46] [Fleet] Fix race condition edit package policy (#123762) --- .../step_define_package_policy.tsx | 5 ++++- .../sections/agent_policy/edit_package_policy_page/index.tsx | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx index 2ac6d91ea35d0..5afe87901d1b2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx @@ -50,12 +50,14 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ updatePackagePolicy: (fields: Partial) => void; validationResults: PackagePolicyValidationResults; submitAttempted: boolean; + isUpdate?: boolean; }> = memo( ({ agentPolicy, packageInfo, packagePolicy, integrationToEnable, + isUpdate, updatePackagePolicy, validationResults, submitAttempted, @@ -88,7 +90,7 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ // Update package policy's package and agent policy info useEffect(() => { - if (isLoadingPackagePolicies) { + if (isUpdate || isLoadingPackagePolicies) { return; } const pkg = packagePolicy.package; @@ -133,6 +135,7 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ }); } }, [ + isUpdate, packagePolicy, agentPolicy, packageInfo, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index 87af3955a9a5d..c953b480b8066 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -504,6 +504,7 @@ export const EditPackagePolicyForm = memo<{ updatePackagePolicy={updatePackagePolicy} validationResults={validationResults!} submitAttempted={formState === 'INVALID'} + isUpdate={true} /> )} From 49d0f2eafc1b38c03cb7fbdba453322c1793e168 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Tue, 25 Jan 2022 16:32:14 -0800 Subject: [PATCH 35/46] Remove support for console.ssl setting. (#123754) --- src/plugins/console/server/config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plugins/console/server/config.ts b/src/plugins/console/server/config.ts index 024777aa8d252..ee2a642ed5e4b 100644 --- a/src/plugins/console/server/config.ts +++ b/src/plugins/console/server/config.ts @@ -21,7 +21,6 @@ const kibanaVersion = new SemVer(MAJOR_VERSION); // ------------------------------- const schemaLatest = schema.object( { - ssl: schema.object({ verify: schema.boolean({ defaultValue: false }) }, {}), ui: schema.object({ enabled: schema.boolean({ defaultValue: true }), }), From 5dbe2737cb3e9a37c19f1457aee0e5b73b64d027 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Wed, 26 Jan 2022 08:02:33 +0100 Subject: [PATCH 36/46] Remove default value for deprecation level (#123411) * Remove default value for deprecation level * add missing levels * update tsdoc * fix test levels * another missing level * fix more types * change console.ssl deprecation level to critical --- .../kbn-config/src/config_service.test.ts | 11 ++- .../deprecation/apply_deprecations.test.ts | 12 +-- .../deprecation/deprecation_factory.test.ts | 89 ++++++++++++------- .../src/deprecation/deprecation_factory.ts | 20 ++--- packages/kbn-config/src/deprecation/types.ts | 40 +++++---- .../deprecations/deprecations_service.test.ts | 1 + .../deprecations/deprecations_service.ts | 2 +- .../elasticsearch/elasticsearch_config.ts | 1 + src/core/server/http/http_config.ts | 2 +- .../server/plugins/plugins_service.test.ts | 5 +- .../server/ui_settings/ui_settings_config.ts | 4 +- src/plugins/console/server/config.ts | 4 +- .../core_plugin_deprecations/server/config.ts | 5 +- .../test_suites/core/deprecations.ts | 4 +- .../plugins/reporting/server/config/index.ts | 2 +- x-pack/plugins/rule_registry/server/config.ts | 2 +- .../screenshotting/server/config/index.ts | 22 +++-- 17 files changed, 139 insertions(+), 87 deletions(-) diff --git a/packages/kbn-config/src/config_service.test.ts b/packages/kbn-config/src/config_service.test.ts index 7600fc526be96..32b2d8969d0cc 100644 --- a/packages/kbn-config/src/config_service.test.ts +++ b/packages/kbn-config/src/config_service.test.ts @@ -434,11 +434,13 @@ test('logs deprecation warning during validation', async () => { const addDeprecation = createAddDeprecation!(''); addDeprecation({ configPath: 'test1', + level: 'warning', message: 'some deprecation message', correctiveActions: { manualSteps: ['do X'] }, }); addDeprecation({ configPath: 'test2', + level: 'warning', message: 'another deprecation message', correctiveActions: { manualSteps: ['do Y'] }, }); @@ -505,12 +507,14 @@ test('does not log warnings for silent deprecations during validation', async () const addDeprecation = createAddDeprecation!(''); addDeprecation({ configPath: 'test1', + level: 'warning', message: 'some deprecation message', correctiveActions: { manualSteps: ['do X'] }, silent: true, }); addDeprecation({ configPath: 'test2', + level: 'warning', message: 'another deprecation message', correctiveActions: { manualSteps: ['do Y'] }, }); @@ -520,6 +524,7 @@ test('does not log warnings for silent deprecations during validation', async () const addDeprecation = createAddDeprecation!(''); addDeprecation({ configPath: 'silent', + level: 'warning', message: 'I am silent', silent: true, correctiveActions: { manualSteps: ['do Z'] }, @@ -597,13 +602,16 @@ describe('getHandledDeprecatedConfigs', () => { const rawConfig = getRawConfigProvider({ base: { unused: 'unusedConfig' } }); const configService = new ConfigService(rawConfig, defaultEnv, logger); - configService.addDeprecationProvider('base', ({ unused }) => [unused('unused')]); + configService.addDeprecationProvider('base', ({ unused }) => [ + unused('unused', { level: 'warning' }), + ]); mockApplyDeprecations.mockImplementationOnce((config, deprecations, createAddDeprecation) => { deprecations.forEach((deprecation) => { const addDeprecation = createAddDeprecation!(deprecation.path); addDeprecation({ configPath: 'test1', + level: 'warning', message: `some deprecation message`, documentationUrl: 'some-url', correctiveActions: { manualSteps: ['do X'] }, @@ -627,6 +635,7 @@ describe('getHandledDeprecatedConfigs', () => { ], }, "documentationUrl": "some-url", + "level": "warning", "message": "some deprecation message", }, ], diff --git a/packages/kbn-config/src/deprecation/apply_deprecations.test.ts b/packages/kbn-config/src/deprecation/apply_deprecations.test.ts index 3f84eed867655..5acf725ba93a6 100644 --- a/packages/kbn-config/src/deprecation/apply_deprecations.test.ts +++ b/packages/kbn-config/src/deprecation/apply_deprecations.test.ts @@ -109,8 +109,8 @@ describe('applyDeprecations', () => { const initialConfig = { foo: 'bar', deprecated: 'deprecated', renamed: 'renamed' }; const { config: migrated } = applyDeprecations(initialConfig, [ - wrapHandler(deprecations.unused('deprecated')), - wrapHandler(deprecations.rename('renamed', 'newname')), + wrapHandler(deprecations.unused('deprecated', { level: 'critical' })), + wrapHandler(deprecations.rename('renamed', 'newname', { level: 'critical' })), ]); expect(migrated).toEqual({ foo: 'bar', newname: 'renamed' }); @@ -131,8 +131,10 @@ describe('applyDeprecations', () => { }; const { config: migrated } = applyDeprecations(initialConfig, [ - wrapHandler(deprecations.unused('deprecated.nested')), - wrapHandler(deprecations.rename('nested.from.rename', 'nested.to.renamed')), + wrapHandler(deprecations.unused('deprecated.nested', { level: 'critical' })), + wrapHandler( + deprecations.rename('nested.from.rename', 'nested.to.renamed', { level: 'critical' }) + ), ]); expect(migrated).toStrictEqual({ @@ -150,7 +152,7 @@ describe('applyDeprecations', () => { const initialConfig = { foo: 'bar', deprecated: 'deprecated' }; const { config: migrated } = applyDeprecations(initialConfig, [ - wrapHandler(deprecations.unused('deprecated')), + wrapHandler(deprecations.unused('deprecated', { level: 'critical' })), ]); expect(initialConfig).toEqual({ foo: 'bar', deprecated: 'deprecated' }); diff --git a/packages/kbn-config/src/deprecation/deprecation_factory.test.ts b/packages/kbn-config/src/deprecation/deprecation_factory.test.ts index d9fe90ff711ed..f104e42d1d2ce 100644 --- a/packages/kbn-config/src/deprecation/deprecation_factory.test.ts +++ b/packages/kbn-config/src/deprecation/deprecation_factory.test.ts @@ -32,7 +32,7 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = deprecate('deprecated', '8.0.0')( + const commands = deprecate('deprecated', '8.0.0', { level: 'critical' })( rawConfig, 'myplugin', addDeprecation, @@ -49,6 +49,7 @@ describe('DeprecationFactory', () => { "Remove \\"myplugin.deprecated\\" from the Kibana config file, CLI flag, or environment variable (in Docker only) before upgrading to 8.0.0.", ], }, + "level": "critical", "message": "Configuring \\"myplugin.deprecated\\" is deprecated and will be removed in 8.0.0.", "title": "Setting \\"myplugin.deprecated\\" is deprecated", }, @@ -69,7 +70,7 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = deprecate('section.deprecated', '8.0.0')( + const commands = deprecate('section.deprecated', '8.0.0', { level: 'critical' })( rawConfig, 'myplugin', addDeprecation, @@ -86,6 +87,7 @@ describe('DeprecationFactory', () => { "Remove \\"myplugin.section.deprecated\\" from the Kibana config file, CLI flag, or environment variable (in Docker only) before upgrading to 8.0.0.", ], }, + "level": "critical", "message": "Configuring \\"myplugin.section.deprecated\\" is deprecated and will be removed in 8.0.0.", "title": "Setting \\"myplugin.section.deprecated\\" is deprecated", }, @@ -103,7 +105,7 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = deprecate('deprecated', '8.0.0')( + const commands = deprecate('deprecated', '8.0.0', { level: 'critical' })( rawConfig, 'myplugin', addDeprecation, @@ -125,7 +127,7 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = deprecateFromRoot('myplugin.deprecated', '8.0.0')( + const commands = deprecateFromRoot('myplugin.deprecated', '8.0.0', { level: 'critical' })( rawConfig, 'does-not-matter', addDeprecation, @@ -142,6 +144,7 @@ describe('DeprecationFactory', () => { "Remove \\"myplugin.deprecated\\" from the Kibana config file, CLI flag, or environment variable (in Docker only) before upgrading to 8.0.0.", ], }, + "level": "critical", "message": "Configuring \\"myplugin.deprecated\\" is deprecated and will be removed in 8.0.0.", "title": "Setting \\"myplugin.deprecated\\" is deprecated", }, @@ -159,7 +162,7 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = deprecateFromRoot('myplugin.deprecated', '8.0.0')( + const commands = deprecateFromRoot('myplugin.deprecated', '8.0.0', { level: 'critical' })( rawConfig, 'does-not-matter', addDeprecation, @@ -181,7 +184,7 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = rename('deprecated', 'renamed')( + const commands = rename('deprecated', 'renamed', { level: 'critical' })( rawConfig, 'myplugin', addDeprecation, @@ -206,6 +209,7 @@ describe('DeprecationFactory', () => { "Replace \\"myplugin.deprecated\\" with \\"myplugin.renamed\\" in the Kibana config file, CLI flag, or environment variable (in Docker only).", ], }, + "level": "critical", "message": "Setting \\"myplugin.deprecated\\" has been replaced by \\"myplugin.renamed\\"", "title": "Setting \\"myplugin.deprecated\\" is deprecated", }, @@ -223,7 +227,12 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = rename('deprecated', 'new')(rawConfig, 'myplugin', addDeprecation, context); + const commands = rename('deprecated', 'new', { level: 'critical' })( + rawConfig, + 'myplugin', + addDeprecation, + context + ); expect(commands).toBeUndefined(); expect(addDeprecation).toHaveBeenCalledTimes(0); }); @@ -239,7 +248,7 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = rename('oldsection.deprecated', 'newsection.renamed')( + const commands = rename('oldsection.deprecated', 'newsection.renamed', { level: 'critical' })( rawConfig, 'myplugin', addDeprecation, @@ -264,6 +273,7 @@ describe('DeprecationFactory', () => { "Replace \\"myplugin.oldsection.deprecated\\" with \\"myplugin.newsection.renamed\\" in the Kibana config file, CLI flag, or environment variable (in Docker only).", ], }, + "level": "critical", "message": "Setting \\"myplugin.oldsection.deprecated\\" has been replaced by \\"myplugin.newsection.renamed\\"", "title": "Setting \\"myplugin.oldsection.deprecated\\" is deprecated", }, @@ -278,7 +288,7 @@ describe('DeprecationFactory', () => { renamed: 'renamed', }, }; - const commands = rename('deprecated', 'renamed')( + const commands = rename('deprecated', 'renamed', { level: 'critical' })( rawConfig, 'myplugin', addDeprecation, @@ -298,6 +308,7 @@ describe('DeprecationFactory', () => { "Remove \\"myplugin.deprecated\\" from the config.", ], }, + "level": "critical", "message": "Setting \\"myplugin.deprecated\\" has been replaced by \\"myplugin.renamed\\". However, both keys are present. Ignoring \\"myplugin.deprecated\\"", "title": "Setting \\"myplugin.deprecated\\" is deprecated", }, @@ -318,12 +329,9 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = renameFromRoot('myplugin.deprecated', 'myplugin.renamed')( - rawConfig, - 'does-not-matter', - addDeprecation, - context - ); + const commands = renameFromRoot('myplugin.deprecated', 'myplugin.renamed', { + level: 'critical', + })(rawConfig, 'does-not-matter', addDeprecation, context); expect(commands).toEqual({ set: [ { @@ -343,6 +351,7 @@ describe('DeprecationFactory', () => { "Replace \\"myplugin.deprecated\\" with \\"myplugin.renamed\\" in the Kibana config file, CLI flag, or environment variable (in Docker only).", ], }, + "level": "critical", "message": "Setting \\"myplugin.deprecated\\" has been replaced by \\"myplugin.renamed\\"", "title": "Setting \\"myplugin.deprecated\\" is deprecated", }, @@ -361,12 +370,9 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = renameFromRoot('oldplugin.deprecated', 'newplugin.renamed')( - rawConfig, - 'does-not-matter', - addDeprecation, - context - ); + const commands = renameFromRoot('oldplugin.deprecated', 'newplugin.renamed', { + level: 'critical', + })(rawConfig, 'does-not-matter', addDeprecation, context); expect(commands).toEqual({ set: [ { @@ -386,6 +392,7 @@ describe('DeprecationFactory', () => { "Replace \\"oldplugin.deprecated\\" with \\"newplugin.renamed\\" in the Kibana config file, CLI flag, or environment variable (in Docker only).", ], }, + "level": "critical", "message": "Setting \\"oldplugin.deprecated\\" has been replaced by \\"newplugin.renamed\\"", "title": "Setting \\"oldplugin.deprecated\\" is deprecated", }, @@ -404,7 +411,7 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = renameFromRoot('myplugin.deprecated', 'myplugin.new')( + const commands = renameFromRoot('myplugin.deprecated', 'myplugin.new', { level: 'critical' })( rawConfig, 'does-not-matter', addDeprecation, @@ -421,12 +428,9 @@ describe('DeprecationFactory', () => { renamed: 'renamed', }, }; - const commands = renameFromRoot('myplugin.deprecated', 'myplugin.renamed')( - rawConfig, - 'does-not-matter', - addDeprecation, - context - ); + const commands = renameFromRoot('myplugin.deprecated', 'myplugin.renamed', { + level: 'critical', + })(rawConfig, 'does-not-matter', addDeprecation, context); expect(commands).toEqual({ unset: [{ path: 'myplugin.deprecated' }], }); @@ -442,6 +446,7 @@ describe('DeprecationFactory', () => { "Remove \\"myplugin.deprecated\\" from the config.", ], }, + "level": "critical", "message": "Setting \\"myplugin.deprecated\\" has been replaced by \\"myplugin.renamed\\". However, both keys are present. Ignoring \\"myplugin.deprecated\\"", "title": "Setting \\"myplugin.deprecated\\" is deprecated", }, @@ -462,7 +467,12 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = unused('deprecated')(rawConfig, 'myplugin', addDeprecation, context); + const commands = unused('deprecated', { level: 'critical' })( + rawConfig, + 'myplugin', + addDeprecation, + context + ); expect(commands).toEqual({ unset: [{ path: 'myplugin.deprecated' }], }); @@ -476,6 +486,7 @@ describe('DeprecationFactory', () => { "Remove \\"myplugin.deprecated\\" from the Kibana config file, CLI flag, or environment variable (in Docker only).", ], }, + "level": "critical", "message": "You no longer need to configure \\"myplugin.deprecated\\".", "title": "Setting \\"myplugin.deprecated\\" is deprecated", }, @@ -496,7 +507,12 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = unused('section.deprecated')(rawConfig, 'myplugin', addDeprecation, context); + const commands = unused('section.deprecated', { level: 'critical' })( + rawConfig, + 'myplugin', + addDeprecation, + context + ); expect(commands).toEqual({ unset: [{ path: 'myplugin.section.deprecated' }], }); @@ -510,6 +526,7 @@ describe('DeprecationFactory', () => { "Remove \\"myplugin.section.deprecated\\" from the Kibana config file, CLI flag, or environment variable (in Docker only).", ], }, + "level": "critical", "message": "You no longer need to configure \\"myplugin.section.deprecated\\".", "title": "Setting \\"myplugin.section.deprecated\\" is deprecated", }, @@ -527,7 +544,12 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = unused('deprecated')(rawConfig, 'myplugin', addDeprecation, context); + const commands = unused('deprecated', { level: 'critical' })( + rawConfig, + 'myplugin', + addDeprecation, + context + ); expect(commands).toBeUndefined(); expect(addDeprecation).toBeCalledTimes(0); }); @@ -544,7 +566,7 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = unusedFromRoot('myplugin.deprecated')( + const commands = unusedFromRoot('myplugin.deprecated', { level: 'critical' })( rawConfig, 'does-not-matter', addDeprecation, @@ -563,6 +585,7 @@ describe('DeprecationFactory', () => { "Remove \\"myplugin.deprecated\\" from the Kibana config file, CLI flag, or environment variable (in Docker only).", ], }, + "level": "critical", "message": "You no longer need to configure \\"myplugin.deprecated\\".", "title": "Setting \\"myplugin.deprecated\\" is deprecated", }, @@ -580,7 +603,7 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = unusedFromRoot('myplugin.deprecated')( + const commands = unusedFromRoot('myplugin.deprecated', { level: 'critical' })( rawConfig, 'does-not-matter', addDeprecation, diff --git a/packages/kbn-config/src/deprecation/deprecation_factory.ts b/packages/kbn-config/src/deprecation/deprecation_factory.ts index ea4db280e915b..90279148c2bdf 100644 --- a/packages/kbn-config/src/deprecation/deprecation_factory.ts +++ b/packages/kbn-config/src/deprecation/deprecation_factory.ts @@ -13,7 +13,7 @@ import { ConfigDeprecation, AddConfigDeprecation, ConfigDeprecationFactory, - DeprecatedConfigDetails, + FactoryConfigDeprecationDetails, ConfigDeprecationCommand, } from './types'; @@ -30,7 +30,7 @@ const _deprecate = ( addDeprecation: AddConfigDeprecation, deprecatedKey: string, removeBy: string, - details?: Partial + details: FactoryConfigDeprecationDetails ): void => { const fullPath = getPath(rootPath, deprecatedKey); if (get(config, fullPath) === undefined) { @@ -62,7 +62,7 @@ const _rename = ( addDeprecation: AddConfigDeprecation, oldKey: string, newKey: string, - details?: Partial + details: FactoryConfigDeprecationDetails ): void | ConfigDeprecationCommand => { const fullOldPath = getPath(rootPath, oldKey); const oldValue = get(config, fullOldPath); @@ -131,7 +131,7 @@ const _unused = ( rootPath: string, addDeprecation: AddConfigDeprecation, unusedKey: string, - details?: Partial + details: FactoryConfigDeprecationDetails ): void | ConfigDeprecationCommand => { const fullPath = getPath(rootPath, unusedKey); if (get(config, fullPath) === undefined) { @@ -164,7 +164,7 @@ const deprecate = ( unusedKey: string, removeBy: string, - details?: Partial + details: FactoryConfigDeprecationDetails ): ConfigDeprecation => (config, rootPath, addDeprecation) => _deprecate(config, rootPath, addDeprecation, unusedKey, removeBy, details); @@ -173,28 +173,28 @@ const deprecateFromRoot = ( unusedKey: string, removeBy: string, - details?: Partial + details: FactoryConfigDeprecationDetails ): ConfigDeprecation => (config, rootPath, addDeprecation) => _deprecate(config, '', addDeprecation, unusedKey, removeBy, details); const rename = - (oldKey: string, newKey: string, details?: Partial): ConfigDeprecation => + (oldKey: string, newKey: string, details: FactoryConfigDeprecationDetails): ConfigDeprecation => (config, rootPath, addDeprecation) => _rename(config, rootPath, addDeprecation, oldKey, newKey, details); const renameFromRoot = - (oldKey: string, newKey: string, details?: Partial): ConfigDeprecation => + (oldKey: string, newKey: string, details: FactoryConfigDeprecationDetails): ConfigDeprecation => (config, rootPath, addDeprecation) => _rename(config, '', addDeprecation, oldKey, newKey, details); const unused = - (unusedKey: string, details?: Partial): ConfigDeprecation => + (unusedKey: string, details: FactoryConfigDeprecationDetails): ConfigDeprecation => (config, rootPath, addDeprecation) => _unused(config, rootPath, addDeprecation, unusedKey, details); const unusedFromRoot = - (unusedKey: string, details?: Partial): ConfigDeprecation => + (unusedKey: string, details: FactoryConfigDeprecationDetails): ConfigDeprecation => (config, rootPath, addDeprecation) => _unused(config, '', addDeprecation, unusedKey, details); diff --git a/packages/kbn-config/src/deprecation/types.ts b/packages/kbn-config/src/deprecation/types.ts index 6abe4cd94a6fb..052741c0b4be3 100644 --- a/packages/kbn-config/src/deprecation/types.ts +++ b/packages/kbn-config/src/deprecation/types.ts @@ -31,7 +31,7 @@ export interface DeprecatedConfigDetails { * - warning: will not break deployment upon upgrade * - critical: needs to be addressed before upgrade. */ - level?: 'warning' | 'critical'; + level: 'warning' | 'critical'; /** (optional) set to `true` to prevent the config service from logging the deprecation message. */ silent?: boolean; /** (optional) link to the documentation for more details on the deprecation. */ @@ -107,9 +107,9 @@ export interface ConfigDeprecationCommand { * @example * ```typescript * const provider: ConfigDeprecationProvider = ({ deprecate, rename, unused }) => [ - * deprecate('deprecatedKey', '8.0.0'), - * rename('oldKey', 'newKey'), - * unused('deprecatedKey'), + * deprecate('deprecatedKey', '8.0.0', { level: 'warning' }), + * rename('oldKey', 'newKey', { level: 'warning' }), + * unused('deprecatedKey', { level: 'warning' }), * (config, path) => ({ unset: [{ key: 'path.to.key' }] }) * ] * ``` @@ -118,6 +118,10 @@ export interface ConfigDeprecationCommand { */ export type ConfigDeprecationProvider = (factory: ConfigDeprecationFactory) => ConfigDeprecation[]; +/** @public */ +export type FactoryConfigDeprecationDetails = Pick & + Partial>; + /** * Provides helpers to generates the most commonly used {@link ConfigDeprecation} * when invoking a {@link ConfigDeprecationProvider}. @@ -127,8 +131,8 @@ export type ConfigDeprecationProvider = (factory: ConfigDeprecationFactory) => C * @example * ```typescript * const provider: ConfigDeprecationProvider = ({ rename, unused }) => [ - * rename('oldKey', 'newKey'), - * unused('deprecatedKey'), + * rename('oldKey', 'newKey', { level: 'critical' }), + * unused('deprecatedKey', { level: 'warning' }), * ] * ``` * @@ -144,14 +148,14 @@ export interface ConfigDeprecationFactory { * Log a deprecation warning indicating 'myplugin.deprecatedKey' should be removed by `8.0.0` * ```typescript * const provider: ConfigDeprecationProvider = ({ deprecate }) => [ - * deprecate('deprecatedKey', '8.0.0'), + * deprecate('deprecatedKey', '8.0.0', { level: 'critical' }), * ] * ``` */ deprecate( deprecatedKey: string, removeBy: string, - details?: Partial + details: FactoryConfigDeprecationDetails ): ConfigDeprecation; /** @@ -165,14 +169,14 @@ export interface ConfigDeprecationFactory { * Log a deprecation warning indicating 'myplugin.deprecatedKey' should be removed by `8.0.0` * ```typescript * const provider: ConfigDeprecationProvider = ({ deprecateFromRoot }) => [ - * deprecateFromRoot('deprecatedKey', '8.0.0'), + * deprecateFromRoot('deprecatedKey', '8.0.0', { level: 'critical' }), * ] * ``` */ deprecateFromRoot( deprecatedKey: string, removeBy: string, - details?: Partial + details: FactoryConfigDeprecationDetails ): ConfigDeprecation; /** @@ -183,7 +187,7 @@ export interface ConfigDeprecationFactory { * Rename 'myplugin.oldKey' to 'myplugin.newKey' * ```typescript * const provider: ConfigDeprecationProvider = ({ rename }) => [ - * rename('oldKey', 'newKey'), + * rename('oldKey', 'newKey', { level: 'warning' }), * ] * ``` * @@ -209,7 +213,7 @@ export interface ConfigDeprecationFactory { rename( oldKey: string, newKey: string, - details?: Partial + details: FactoryConfigDeprecationDetails ): ConfigDeprecation; /** @@ -223,7 +227,7 @@ export interface ConfigDeprecationFactory { * Rename 'oldplugin.key' to 'newplugin.key' * ```typescript * const provider: ConfigDeprecationProvider = ({ renameFromRoot }) => [ - * renameFromRoot('oldplugin.key', 'newplugin.key'), + * renameFromRoot('oldplugin.key', 'newplugin.key', { level: 'critical' }), * ] * ``` * @@ -249,7 +253,7 @@ export interface ConfigDeprecationFactory { renameFromRoot( oldKey: string, newKey: string, - details?: Partial + details: FactoryConfigDeprecationDetails ): ConfigDeprecation; /** @@ -260,7 +264,7 @@ export interface ConfigDeprecationFactory { * Flags 'myplugin.deprecatedKey' as unused * ```typescript * const provider: ConfigDeprecationProvider = ({ unused }) => [ - * unused('deprecatedKey'), + * unused('deprecatedKey', { level: 'warning' }), * ] * ``` * @@ -283,7 +287,7 @@ export interface ConfigDeprecationFactory { * } * ``` */ - unused(unusedKey: string, details?: Partial): ConfigDeprecation; + unused(unusedKey: string, details: FactoryConfigDeprecationDetails): ConfigDeprecation; /** * Remove a configuration property from the root configuration. @@ -296,7 +300,7 @@ export interface ConfigDeprecationFactory { * Flags 'somepath.deprecatedProperty' as unused * ```typescript * const provider: ConfigDeprecationProvider = ({ unusedFromRoot }) => [ - * unusedFromRoot('somepath.deprecatedProperty'), + * unusedFromRoot('somepath.deprecatedProperty', { level: 'warning' }), * ] * ``` * @@ -319,7 +323,7 @@ export interface ConfigDeprecationFactory { * } * ``` */ - unusedFromRoot(unusedKey: string, details?: Partial): ConfigDeprecation; + unusedFromRoot(unusedKey: string, details: FactoryConfigDeprecationDetails): ConfigDeprecation; } /** @internal */ diff --git a/src/core/server/deprecations/deprecations_service.test.ts b/src/core/server/deprecations/deprecations_service.test.ts index a058ac38d5b01..ffcf70c59f9f2 100644 --- a/src/core/server/deprecations/deprecations_service.test.ts +++ b/src/core/server/deprecations/deprecations_service.test.ts @@ -101,6 +101,7 @@ describe('DeprecationsService', () => { [ { configPath: 'test', + level: 'critical', message: 'testMessage', documentationUrl: 'testDocUrl', correctiveActions: { diff --git a/src/core/server/deprecations/deprecations_service.ts b/src/core/server/deprecations/deprecations_service.ts index 0c3fd75987aa6..df8084e6c783a 100644 --- a/src/core/server/deprecations/deprecations_service.ts +++ b/src/core/server/deprecations/deprecations_service.ts @@ -206,7 +206,7 @@ export class DeprecationsService ({ configPath, title = `${domainId} has a deprecated setting`, - level = 'critical', + level, message, correctiveActions, documentationUrl, diff --git a/src/core/server/elasticsearch/elasticsearch_config.ts b/src/core/server/elasticsearch/elasticsearch_config.ts index 8356023e8c1bf..74341ddd2c1ad 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.ts @@ -246,6 +246,7 @@ const deprecations: ConfigDeprecationProvider = () => [ if (es.logQueries === true) { addDeprecation({ configPath: `${fromPath}.logQueries`, + level: 'warning', message: `Setting [${fromPath}.logQueries] is deprecated and no longer used. You should set the log level to "debug" for the "elasticsearch.query" context in "logging.loggers".`, correctiveActions: { manualSteps: [ diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index 22af901c20a98..5bc9cfa4993a5 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -180,7 +180,7 @@ export type HttpConfigType = TypeOf; export const config: ServiceConfigDescriptor = { path: 'server' as const, schema: configSchema, - deprecations: ({ rename }) => [rename('maxPayloadBytes', 'maxPayload')], + deprecations: ({ rename }) => [rename('maxPayloadBytes', 'maxPayload', { level: 'warning' })], }; export class HttpConfig implements IHttpConfig { diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index dac54824d30a3..65ef756b39e17 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -293,6 +293,7 @@ describe('PluginsService', () => { ); expect(standardMockPluginSystem.addPlugin).not.toHaveBeenCalled(); } + async function expectSuccess() { await expect(pluginsService.discover({ environment: environmentPreboot })).resolves.toEqual( expect.anything() @@ -1099,7 +1100,9 @@ describe('PluginsService', () => { renamed: schema.string(), // Mandatory string to make sure that the field is actually renamed by deprecations }), deprecations: ({ renameFromRoot }) => [ - renameFromRoot('plugin-1-deprecations.toBeRenamed', 'plugin-2-deprecations.renamed'), + renameFromRoot('plugin-1-deprecations.toBeRenamed', 'plugin-2-deprecations.renamed', { + level: 'critical', + }), ], }, }), diff --git a/src/core/server/ui_settings/ui_settings_config.ts b/src/core/server/ui_settings/ui_settings_config.ts index 943828db72aa2..de76fe1cbfc8a 100644 --- a/src/core/server/ui_settings/ui_settings_config.ts +++ b/src/core/server/ui_settings/ui_settings_config.ts @@ -11,8 +11,8 @@ import { ConfigDeprecationProvider } from 'src/core/server'; import { ServiceConfigDescriptor } from '../internal_types'; const deprecations: ConfigDeprecationProvider = ({ unused, renameFromRoot }) => [ - unused('enabled'), - renameFromRoot('server.defaultRoute', 'uiSettings.overrides.defaultRoute'), + unused('enabled', { level: 'warning' }), + renameFromRoot('server.defaultRoute', 'uiSettings.overrides.defaultRoute', { level: 'warning' }), ]; const configSchema = schema.object({ diff --git a/src/plugins/console/server/config.ts b/src/plugins/console/server/config.ts index ee2a642ed5e4b..2183a133e408f 100644 --- a/src/plugins/console/server/config.ts +++ b/src/plugins/console/server/config.ts @@ -82,8 +82,8 @@ const config7x: PluginConfigDescriptor = { ui: true, }, schema: schema7x, - deprecations: ({ deprecate, unused }) => [ - unused('ssl'), + deprecations: ({ unused }) => [ + unused('ssl', { level: 'critical' }), (completeConfig, rootPath, addDeprecation) => { if (get(completeConfig, 'console.enabled') === undefined) { return completeConfig; diff --git a/test/plugin_functional/plugins/core_plugin_deprecations/server/config.ts b/test/plugin_functional/plugins/core_plugin_deprecations/server/config.ts index 5b9145be52661..aca0fca301a13 100644 --- a/test/plugin_functional/plugins/core_plugin_deprecations/server/config.ts +++ b/test/plugin_functional/plugins/core_plugin_deprecations/server/config.ts @@ -23,6 +23,7 @@ const configSecretDeprecation: ConfigDeprecation = (settings, fromPath, addDepre if (get(settings, 'corePluginDeprecations.secret') !== 42) { addDeprecation({ configPath: 'corePluginDeprecations.secret', + level: 'critical', documentationUrl: 'config-secret-doc-url', message: 'Kibana plugin functional tests will no longer allow corePluginDeprecations.secret ' + @@ -40,8 +41,8 @@ const configSecretDeprecation: ConfigDeprecation = (settings, fromPath, addDepre export const config: PluginConfigDescriptor = { schema: configSchema, deprecations: ({ rename, unused }) => [ - rename('oldProperty', 'newProperty'), - unused('noLongerUsed'), + rename('oldProperty', 'newProperty', { level: 'warning' }), + unused('noLongerUsed', { level: 'warning' }), configSecretDeprecation, ], }; diff --git a/test/plugin_functional/test_suites/core/deprecations.ts b/test/plugin_functional/test_suites/core/deprecations.ts index d8dc82a56cb55..bb533c18f7340 100644 --- a/test/plugin_functional/test_suites/core/deprecations.ts +++ b/test/plugin_functional/test_suites/core/deprecations.ts @@ -21,7 +21,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide { configPath: 'corePluginDeprecations.oldProperty', title: 'Setting "corePluginDeprecations.oldProperty" is deprecated', - level: 'critical', + level: 'warning', message: 'Setting "corePluginDeprecations.oldProperty" has been replaced by "corePluginDeprecations.newProperty"', correctiveActions: { @@ -36,7 +36,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide { configPath: 'corePluginDeprecations.noLongerUsed', title: 'Setting "corePluginDeprecations.noLongerUsed" is deprecated', - level: 'critical', + level: 'warning', message: 'You no longer need to configure "corePluginDeprecations.noLongerUsed".', correctiveActions: { manualSteps: [ diff --git a/x-pack/plugins/reporting/server/config/index.ts b/x-pack/plugins/reporting/server/config/index.ts index 534b51f9a0f7f..c068b30f43266 100644 --- a/x-pack/plugins/reporting/server/config/index.ts +++ b/x-pack/plugins/reporting/server/config/index.ts @@ -19,7 +19,7 @@ export const config: PluginConfigDescriptor = { schema: ConfigSchema, deprecations: ({ unused }) => [ unused('capture.browser.chromium.maxScreenshotDimension', { level: 'warning' }), // unused since 7.8 - unused('capture.browser.type'), + unused('capture.browser.type', { level: 'warning' }), unused('poll.jobCompletionNotifier.intervalErrorMultiplier', { level: 'warning' }), // unused since 7.10 unused('poll.jobsRefresh.intervalErrorMultiplier', { level: 'warning' }), // unused since 7.10 unused('capture.viewport', { level: 'warning' }), // deprecated as unused since 7.16 diff --git a/x-pack/plugins/rule_registry/server/config.ts b/x-pack/plugins/rule_registry/server/config.ts index 7f3a3db42556e..c0b9739910982 100644 --- a/x-pack/plugins/rule_registry/server/config.ts +++ b/x-pack/plugins/rule_registry/server/config.ts @@ -9,7 +9,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { PluginConfigDescriptor } from 'src/core/server'; export const config: PluginConfigDescriptor = { - deprecations: ({ deprecate, unused }) => [unused('unsafe.indexUpgrade.enabled')], + deprecations: ({ unused }) => [unused('unsafe.indexUpgrade.enabled', { level: 'warning' })], schema: schema.object({ write: schema.object({ disabledRegistrationContexts: schema.arrayOf(schema.string(), { defaultValue: [] }), diff --git a/x-pack/plugins/screenshotting/server/config/index.ts b/x-pack/plugins/screenshotting/server/config/index.ts index 38f5a6e8f20fa..225c0cb957c62 100644 --- a/x-pack/plugins/screenshotting/server/config/index.ts +++ b/x-pack/plugins/screenshotting/server/config/index.ts @@ -14,30 +14,38 @@ import { ConfigSchema, ConfigType } from './schema'; export const config: PluginConfigDescriptor = { schema: ConfigSchema, deprecations: ({ renameFromRoot }) => [ - renameFromRoot('xpack.reporting.capture.networkPolicy', 'xpack.screenshotting.networkPolicy'), + renameFromRoot('xpack.reporting.capture.networkPolicy', 'xpack.screenshotting.networkPolicy', { + level: 'warning', + }), renameFromRoot( 'xpack.reporting.capture.browser.autoDownload', - 'xpack.screenshotting.browser.autoDownload' + 'xpack.screenshotting.browser.autoDownload', + { level: 'warning' } ), renameFromRoot( 'xpack.reporting.capture.browser.chromium.inspect', - 'xpack.screenshotting.browser.chromium.inspect' + 'xpack.screenshotting.browser.chromium.inspect', + { level: 'warning' } ), renameFromRoot( 'xpack.reporting.capture.browser.chromium.disableSandbox', - 'xpack.screenshotting.browser.chromium.disableSandbox' + 'xpack.screenshotting.browser.chromium.disableSandbox', + { level: 'warning' } ), renameFromRoot( 'xpack.reporting.capture.browser.chromium.proxy.enabled', - 'xpack.screenshotting.browser.chromium.proxy.enabled' + 'xpack.screenshotting.browser.chromium.proxy.enabled', + { level: 'warning' } ), renameFromRoot( 'xpack.reporting.capture.browser.chromium.proxy.server', - 'xpack.screenshotting.browser.chromium.proxy.server' + 'xpack.screenshotting.browser.chromium.proxy.server', + { level: 'warning' } ), renameFromRoot( 'xpack.reporting.capture.browser.chromium.proxy.bypass', - 'xpack.screenshotting.browser.chromium.proxy.bypass' + 'xpack.screenshotting.browser.chromium.proxy.bypass', + { level: 'warning' } ), ], exposeToUsage: { From 9024098d0fc4643c03f6a58ceb41d4ff57a8de8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Wed, 26 Jan 2022 09:38:57 +0100 Subject: [PATCH 37/46] [Security Solution][Endpoint] Don't hide form when loading policies, display a specific loader instead (#123461) * Don't hide form when loading policies, display a specific loader instead * Uses existing prop for loading state and keep the selector available, only displays the loader for the by policy selection * Fix test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../effected_policy_select.test.tsx | 7 ++++ .../effected_policy_select.tsx | 40 +++++++++++-------- .../view/components/form/index.tsx | 12 +++--- .../create_trusted_app_form.test.tsx | 5 +-- 4 files changed, 37 insertions(+), 27 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/components/effected_policy_select/effected_policy_select.test.tsx b/x-pack/plugins/security_solution/public/management/components/effected_policy_select/effected_policy_select.test.tsx index 5eebc2721857f..1a2e179175943 100644 --- a/x-pack/plugins/security_solution/public/management/components/effected_policy_select/effected_policy_select.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/effected_policy_select/effected_policy_select.test.tsx @@ -149,5 +149,12 @@ describe('when using EffectedPolicySelect component', () => { selected: [componentProps.options[0]], }); }); + + it('should show loader only when by polocy selected', () => { + const { queryByTestId } = render({ isLoading: true }); + expect(queryByTestId('loading-spinner')).toBeNull(); + selectPerPolicy(); + expect(queryByTestId('loading-spinner')).not.toBeNull(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/components/effected_policy_select/effected_policy_select.tsx b/x-pack/plugins/security_solution/public/management/components/effected_policy_select/effected_policy_select.tsx index 0fa77511ea9fe..e20df0c122956 100644 --- a/x-pack/plugins/security_solution/public/management/components/effected_policy_select/effected_policy_select.tsx +++ b/x-pack/plugins/security_solution/public/management/components/effected_policy_select/effected_policy_select.tsx @@ -28,6 +28,7 @@ import { LinkToApp } from '../../../common/components/endpoint/link_to_app'; import { getPolicyDetailPath } from '../../common/routing'; import { useTestIdGenerator } from '../hooks/use_test_id_generator'; import { useAppUrl } from '../../../common/lib/kibana/hooks'; +import { Loader } from '../../../common/components/loader'; const NOOP = () => {}; const DEFAULT_LIST_PROPS: EuiSelectableProps['listProps'] = { bordered: true, showIcons: false }; @@ -79,6 +80,7 @@ export const EffectedPolicySelect = memo( isGlobal, isPlatinumPlus, description, + isLoading = false, onChange, listProps, options, @@ -215,28 +217,32 @@ export const EffectedPolicySelect = memo( onChange={handleGlobalButtonChange} color="primary" isFullWidth + data-test-subj={getTestId('byPolicyGlobalButtonGroup')} /> - {!isGlobal && ( - - - - {...otherSelectableProps} - options={selectableOptions} - listProps={listProps || DEFAULT_LIST_PROPS} - onChange={handleOnPolicySelectChange} - searchProps={SEARCH_PROPS} - searchable={true} - data-test-subj={getTestId('policiesSelectable')} - > - {listBuilderCallback} - - - - )} + {!isGlobal && + (isLoading ? ( + + ) : ( + + + + {...otherSelectableProps} + options={selectableOptions} + listProps={listProps || DEFAULT_LIST_PROPS} + onChange={handleOnPolicySelectChange} + searchProps={SEARCH_PROPS} + searchable={true} + data-test-subj={getTestId('policiesSelectable')} + > + {listBuilderCallback} + + + + ))} ); } diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx index 8921488f64c79..fa68215cc768b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx @@ -127,11 +127,10 @@ export const EventFiltersForm: React.FC = memo( const handleOnBuilderChange = useCallback( (arg: OnChangeProps) => { if ( - (hasFormChanged === false && arg.exceptionItems[0] === undefined) || - (arg.exceptionItems[0] !== undefined && - exception !== undefined && - isEqual(exception?.entries, arg.exceptionItems[0].entries)) + (!hasFormChanged && arg.exceptionItems[0] === undefined) || + isEqual(arg.exceptionItems[0]?.entries, exception?.entries) ) { + setHasFormChanged(true); return; } setHasFormChanged(true); @@ -400,12 +399,13 @@ export const EventFiltersForm: React.FC = memo( selected={selection.selected} options={policies} isGlobal={selection.isGlobal} + isLoading={arePoliciesLoading} isPlatinumPlus={isPlatinumPlus} onChange={handleOnChangeEffectScope} data-test-subj={'effectedPolicies-select'} /> ), - [policies, selection, isPlatinumPlus, handleOnChangeEffectScope] + [policies, selection, isPlatinumPlus, handleOnChangeEffectScope, arePoliciesLoading] ); const commentsSection = useMemo( @@ -435,7 +435,7 @@ export const EventFiltersForm: React.FC = memo( [commentsInputMemo] ); - if (isIndexPatternLoading || !exception || arePoliciesLoading) { + if (isIndexPatternLoading || !exception) { return ; } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx index 18aa2484f6067..952b707b5d5c3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx @@ -311,10 +311,7 @@ describe('When using the Trusted App Form', () => { policies: ['123'], }; render(); - expect( - renderResult.getByTestId(`${dataTestSubjForForm}-effectedPolicies-policiesSelectable`) - .textContent - ).toEqual('Loading options'); + expect(renderResult.queryByTestId('loading-spinner')).not.toBeNull(); }); }); From 16642e0028d445c805a6378d750b24688dc191de Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Wed, 26 Jan 2022 11:22:16 +0100 Subject: [PATCH 38/46] [SecuritySolution][Hosts] All hosts table OS column tooltip message (#123628) * tooltip added to OS column * change os info message Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/hosts/components/hosts_table/translations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/translations.ts b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/translations.ts index ead534b1edc5b..15276355f800c 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/translations.ts @@ -43,7 +43,7 @@ export const HOST_RISK_TOOLTIP = i18n.translate( export const OS_LAST_SEEN_TOOLTIP = i18n.translate( 'xpack.securitySolution.hostsTable.osLastSeenToolTip', { - defaultMessage: 'Most recently observed OS', + defaultMessage: 'Last observed operating system', } ); From 03c8d080262a1d6995c4fd985af9065c6b8d7037 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 26 Jan 2022 12:24:00 +0100 Subject: [PATCH 39/46] XY Fix percentile with decimals (#123709) --- .../vis_types/xy/public/utils/accessors.test.ts | 16 ++++++++++++++++ .../vis_types/xy/public/utils/accessors.tsx | 12 ++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/plugins/vis_types/xy/public/utils/accessors.test.ts b/src/plugins/vis_types/xy/public/utils/accessors.test.ts index 06920ceebe980..ebb389054af7e 100644 --- a/src/plugins/vis_types/xy/public/utils/accessors.test.ts +++ b/src/plugins/vis_types/xy/public/utils/accessors.test.ts @@ -129,6 +129,22 @@ describe('isPercentileIdEqualToSeriesId', () => { expect(isEqual).toBeFalsy(); }); + it('should be equal for column with percentile with decimal points', () => { + const seriesColumnId = '1'; + const columnId = `${seriesColumnId}['95.5']`; + + const isEqual = isPercentileIdEqualToSeriesId(columnId, seriesColumnId); + expect(isEqual).toBeTruthy(); + }); + + it('should not be equal for column with percentile with decimal points equal to seriesColumnId', () => { + const seriesColumnId = '1'; + const columnId = `2['1.3']`; + + const isEqual = isPercentileIdEqualToSeriesId(columnId, seriesColumnId); + expect(isEqual).toBeFalsy(); + }); + it('should not be equal for column with percentile, where columnId contains seriesColumnId', () => { const seriesColumnId = '1'; const columnId = `${seriesColumnId}2.1`; diff --git a/src/plugins/vis_types/xy/public/utils/accessors.tsx b/src/plugins/vis_types/xy/public/utils/accessors.tsx index 6eccb36d6fa73..0acde395bbdb7 100644 --- a/src/plugins/vis_types/xy/public/utils/accessors.tsx +++ b/src/plugins/vis_types/xy/public/utils/accessors.tsx @@ -79,8 +79,16 @@ export const getSplitSeriesAccessorFnMap = ( }; // For percentile, the aggregation id is coming in the form %s.%d, where %s is agg_id and %d - percents -export const getSafeId = (columnId?: number | string | null) => - (columnId || '').toString().split('.')[0]; +export const getSafeId = (columnId?: number | string | null) => { + const id = String(columnId); + // only multi-value aggs like percentiles are allowed to contain dots and [ + const isMultiValueId = id.includes('[') || id.includes('.'); + if (!isMultiValueId) { + return id; + } + const baseId = id.substring(0, id.indexOf('[') !== -1 ? id.indexOf('[') : id.indexOf('.')); + return baseId; +}; export const isPercentileIdEqualToSeriesId = ( columnId: number | string | null | undefined, From c504475926471a527df11c64678a78dd607c8d96 Mon Sep 17 00:00:00 2001 From: Faisal Kanout Date: Wed, 26 Jan 2022 14:41:50 +0300 Subject: [PATCH 40/46] [RAC][8.1]Add duration format function with tests (#123794) * Add duration format function with tests * Fix typo --- x-pack/plugins/observability/common/index.ts | 5 ++- .../common/utils/formatters/duration.test.ts | 41 ++++++++++++++++++- .../common/utils/formatters/duration.ts | 18 ++++++++ x-pack/plugins/observability/kibana.json | 10 ++++- 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/observability/common/index.ts b/x-pack/plugins/observability/common/index.ts index 4f303390e1e1b..3eb59effedc3a 100644 --- a/x-pack/plugins/observability/common/index.ts +++ b/x-pack/plugins/observability/common/index.ts @@ -5,7 +5,10 @@ * 2.0. */ -export type { AsDuration, AsPercent } from './utils/formatters'; +export type { AsDuration, AsPercent, TimeUnitChar } from './utils/formatters'; + +export { formatDurationFromTimeUnitChar } from './utils/formatters'; + export { enableInspectEsQueries, maxSuggestions, diff --git a/x-pack/plugins/observability/common/utils/formatters/duration.test.ts b/x-pack/plugins/observability/common/utils/formatters/duration.test.ts index 69f4792325a35..422da81320926 100644 --- a/x-pack/plugins/observability/common/utils/formatters/duration.test.ts +++ b/x-pack/plugins/observability/common/utils/formatters/duration.test.ts @@ -5,7 +5,13 @@ * 2.0. */ -import { asDuration, asTransactionRate, toMicroseconds, asMillisecondDuration } from './duration'; +import { + asDuration, + asTransactionRate, + toMicroseconds, + asMillisecondDuration, + formatDurationFromTimeUnitChar, +} from './duration'; describe('duration formatters', () => { describe('asDuration', () => { @@ -126,4 +132,37 @@ describe('duration formatters', () => { expect(asMillisecondDuration(undefined)).toEqual('N/A'); }); }); + + describe('formatDurationFromTimeUnitChar', () => { + it('Convert "s" to "secs".', () => { + expect(formatDurationFromTimeUnitChar(30, 's')).toEqual('30 secs'); + }); + it('Convert "s" to "sec."', () => { + expect(formatDurationFromTimeUnitChar(1, 's')).toEqual('1 sec'); + }); + + it('Convert "m" to "mins".', () => { + expect(formatDurationFromTimeUnitChar(10, 'm')).toEqual('10 mins'); + }); + + it('Convert "m" to "min."', () => { + expect(formatDurationFromTimeUnitChar(1, 'm')).toEqual('1 min'); + }); + + it('Convert "h" to "hrs."', () => { + expect(formatDurationFromTimeUnitChar(5, 'h')).toEqual('5 hrs'); + }); + + it('Convert "h" to "hr"', () => { + expect(formatDurationFromTimeUnitChar(1, 'h')).toEqual('1 hr'); + }); + + it('Convert "d" to "days"', () => { + expect(formatDurationFromTimeUnitChar(2, 'd')).toEqual('2 days'); + }); + + it('Convert "d" to "day"', () => { + expect(formatDurationFromTimeUnitChar(1, 'd')).toEqual('1 day'); + }); + }); }); diff --git a/x-pack/plugins/observability/common/utils/formatters/duration.ts b/x-pack/plugins/observability/common/utils/formatters/duration.ts index 481005332cc30..96486079f3c4f 100644 --- a/x-pack/plugins/observability/common/utils/formatters/duration.ts +++ b/x-pack/plugins/observability/common/utils/formatters/duration.ts @@ -215,3 +215,21 @@ export function asMillisecondDuration(value: Maybe) { microseconds: value, }).formatted; } + +export type TimeUnitChar = 's' | 'm' | 'h' | 'd'; + +export const formatDurationFromTimeUnitChar = (time: number, unit: TimeUnitChar): string => { + const sForPlural = time !== 0 && time > 1 ? 's' : ''; // Negative values are not taken into account + switch (unit) { + case 's': + return `${time} sec${sForPlural}`; + case 'm': + return `${time} min${sForPlural}`; + case 'h': + return `${time} hr${sForPlural}`; + case 'd': + return `${time} day${sForPlural}`; + default: + return `${time} ${unit}`; + } +}; diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index 8074462fd90aa..dff163c57d5f6 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -31,5 +31,13 @@ ], "ui": true, "server": true, - "requiredBundles": ["data", "dataViews", "kibanaReact", "kibanaUtils"] + "requiredBundles": [ + "data", + "dataViews", + "kibanaReact", + "kibanaUtils" + ], + "extraPublicDirs": [ + "common" + ] } From 6e4c3111228bf8958efd5eaebdbb76ae60eb6fb6 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 26 Jan 2022 08:54:24 -0500 Subject: [PATCH 41/46] Add correlation section to audit logging docs (#123757) --- docs/user/security/audit-logging.asciidoc | 57 +++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index 1e7eb1971af08..15d5db7395ec6 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -24,8 +24,9 @@ an <> to write the audit log to a location of Refer to the table of events that can be logged for auditing purposes. -Each event is broken down into <>, <>, <> and <> fields -to make it easy to filter, query and aggregate the resulting logs. +Each event is broken down into <>, <>, <> and +<> fields to make it easy to filter, query and aggregate the resulting logs. The <> +field can be used to correlate multiple events that originate from the same request. Refer to <> for a table of fields that get logged with audit event. @@ -423,7 +424,57 @@ Example: `https` | *Field* | *Description* -| `trace.id` +| [[field-trace-id]] `trace.id` | Unique identifier allowing events of the same transaction from {kib} and {es} to be be correlated. |====== + +[[xpack-security-ecs-audit-correlation]] +==== Correlating audit events + +Audit events can be correlated in two ways: + +1. Multiple {kib} audit events that resulted from the same request can be correlated together. +2. If {ref}/enable-audit-logging.html[{es} audit logging] is enabled, {kib} audit events from one request can be correlated with backend + calls that create {es} audit events. + +NOTE: The examples below are simplified, many fields have been omitted and values have been shortened for clarity. + +===== Example 1: correlating multiple {kib} audit events + +When "thom" creates a new alerting rule, five audit events are written: + +[source,json] +------------- +{"event":{"action":"http_request","category":["web"],"outcome":"unknown"},"http":{"request":{"method":"post"}},"url":{"domain":"localhost","path":"/api/alerting/rule","port":5601,"scheme":"https"},"user":{"name":"thom","roles":["superuser"]},"kibana":{"space_id":"default","session_id":"3dHCZRB..."},"@timestamp":"2022-01-25T13:05:34.449-05:00","message":"User is requesting [/api/alerting/rule] endpoint","trace":{"id":"e300e06..."}} +{"event":{"action":"space_get","category":["database"],"type":["access"],"outcome":"success"},"kibana":{"space_id":"default","session_id":"3dHCZRB...","saved_object":{"type":"space","id":"default"}},"user":{"name":"thom","roles":["superuser"]},"@timestamp":"2022-01-25T13:05:34.454-05:00","message":"User has accessed space [id=default]","trace":{"id":"e300e06..."}} +{"event":{"action":"connector_get","category":["database"],"type":["access"],"outcome":"success"},"kibana":{"space_id":"default","session_id":"3dHCZRB...","saved_object":{"type":"action","id":"5e3b1ae..."}},"user":{"name":"thom","roles":["superuser"]},"@timestamp":"2022-01-25T13:05:34.948-05:00","message":"User has accessed connector [id=5e3b1ae...]","trace":{"id":"e300e06..."}} +{"event":{"action":"connector_get","category":["database"],"type":["access"],"outcome":"success"},"kibana":{"space_id":"default","session_id":"3dHCZRB...","saved_object":{"type":"action","id":"5e3b1ae..."}},"user":{"name":"thom","roles":["superuser"]},"@timestamp":"2022-01-25T13:05:34.956-05:00","message":"User has accessed connector [id=5e3b1ae...]","trace":{"id":"e300e06..."}} +{"event":{"action":"rule_create","category":["database"],"type":["creation"],"outcome":"unknown"},"kibana":{"space_id":"default","session_id":"3dHCZRB...","saved_object":{"type":"alert","id":"64517c3..."}},"user":{"name":"thom","roles":["superuser"]},"@timestamp":"2022-01-25T13:05:34.956-05:00","message":"User is creating rule [id=64517c3...]","trace":{"id":"e300e06..."}} +------------- + +All of these audit events can be correlated together by the same `trace.id` value `"e300e06..."`. The first event is the HTTP API call, the +next audit events are checks to validate the space and the connectors, and the last audit event is the actual rule creation. + +===== Example 2: correlating a {kib} audit event with {es} audit events + +When "thom" logs in, a "user_login" {kib} audit event is written: + +[source,json] +------------- +{"event":{"action":"user_login","category":["authentication"],"outcome":"success"},"user":{"name":"thom","roles":["superuser"]},"@timestamp":"2022-01-25T09:40:39.267-05:00","message":"User [thom] has logged in using basic provider [name=basic]","trace":{"id":"818cbf3..."}} +------------- + +The `trace.id` value `"818cbf3..."` in the {kib} audit event can be correlated with the `opaque_id` value in these six {es} audit events: + +[source,json] +------------- +{"type":"audit", "timestamp":"2022-01-25T09:40:38,604-0500", "event.action":"access_granted", "user.name":"thom", "user.roles":["superuser"], "request.id":"YCx8wxs...", "action":"cluster:admin/xpack/security/user/authenticate", "request.name":"AuthenticateRequest", "opaque_id":"818cbf3..."} +{"type":"audit", "timestamp":"2022-01-25T09:40:38,613-0500", "event.action":"access_granted", "user.name":"kibana_system", "user.roles":["kibana_system"], "request.id":"Ksx73Ad...", "action":"indices:data/write/index", "request.name":"IndexRequest", "indices":[".kibana_security_session_1"], "opaque_id":"818cbf3..."} +{"type":"audit", "timestamp":"2022-01-25T09:40:38,613-0500", "event.action":"access_granted", "user.name":"kibana_system", "user.roles":["kibana_system"], "request.id":"Ksx73Ad...", "action":"indices:data/write/bulk", "request.name":"BulkRequest", "opaque_id":"818cbf3..."} +{"type":"audit", "timestamp":"2022-01-25T09:40:38,613-0500", "event.action":"access_granted", "user.name":"kibana_system", "user.roles":["kibana_system"], "request.id":"Ksx73Ad...", "action":"indices:data/write/bulk[s]", "request.name":"BulkShardRequest", "indices":[".kibana_security_session_1"], "opaque_id":"818cbf3..."} +{"type":"audit", "timestamp":"2022-01-25T09:40:38,613-0500", "event.action":"access_granted", "user.name":"kibana_system", "user.roles":["kibana_system"], "request.id":"Ksx73Ad...", "action":"indices:data/write/index:op_type/create", "request.name":"BulkItemRequest", "indices":[".kibana_security_session_1"], "opaque_id":"818cbf3..."} +{"type":"audit", "timestamp":"2022-01-25T09:40:38,613-0500", "event.action":"access_granted", "user.name":"kibana_system", "user.roles":["kibana_system"], "request.id":"Ksx73Ad...", "action":"indices:data/write/bulk[s][p]", "request.name":"BulkShardRequest", "indices":[".kibana_security_session_1"], "opaque_id":"818cbf3..."} +------------- + +The {es} audit events show that "thom" authenticated, then subsequently "kibana_system" created a session for that user. From e45c3529f06292b42fdc740ff48c18e69bb3cbf5 Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Wed, 26 Jan 2022 06:11:05 -0800 Subject: [PATCH 42/46] [APM] Re-enable useLatestPackageVersion option for integration edit custom UI (#123782) (#123783) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/apm/public/plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 2d00080201709..eccfe6f90f764 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -370,7 +370,7 @@ export class ApmPlugin implements Plugin { fleet.registerExtension({ package: 'apm', view: 'package-policy-edit', - useLatestPackageVersion: false, + useLatestPackageVersion: true, Component: getLazyAPMPolicyEditExtension(), }); From 629c602a67e090c1c5a7e7cbb61206d511f0845a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 26 Jan 2022 16:30:14 +0100 Subject: [PATCH 43/46] [Infra UI] Avoid eager async imports in metric alert registrations (#123285) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../infra/common/alerting/metrics/types.ts | 40 +++++++++-- .../threshold_annotations.test.tsx | 10 +-- .../threshold_annotations.tsx | 10 +-- .../inventory/components/expression.test.tsx | 13 ++-- .../inventory/components/expression.tsx | 71 +++++++++---------- .../inventory/components/expression_chart.tsx | 26 ++++--- .../alerting/inventory/components/metric.tsx | 26 +++---- .../inventory/components/validation.tsx | 13 ++-- .../infra/public/alerting/inventory/index.ts | 9 +-- .../expression_editor/threshold.tsx | 19 +++-- .../metric_anomaly/components/expression.tsx | 33 ++++----- .../metric_anomaly/components/validation.tsx | 3 +- .../components/expression.test.tsx | 7 +- .../components/expression.tsx | 34 +++++---- .../components/expression_chart.test.tsx | 11 ++- .../components/expression_row.test.tsx | 7 +- .../components/expression_row.tsx | 31 ++++---- .../components/validation.tsx | 13 ++-- .../public/alerting/metric_threshold/index.ts | 9 ++- .../public/alerting/metric_threshold/types.ts | 9 +-- x-pack/plugins/infra/public/plugin.ts | 9 ++- x-pack/plugins/infra/server/features.ts | 8 ++- .../server/lib/alerting/common/messages.ts | 2 +- .../infra/server/lib/alerting/common/types.ts | 35 --------- .../evaluate_condition.ts | 8 +-- .../inventory_metric_threshold_executor.ts | 17 +++-- .../lib/calculate_from_based_on_metric.ts | 4 +- ...er_inventory_metric_threshold_rule_type.ts | 41 ++++++----- .../inventory_metric_threshold/types.ts | 27 ------- .../metric_anomaly/metric_anomaly_executor.ts | 15 ++-- .../lib/create_percentile_aggregation.ts | 3 +- .../lib/create_timerange.test.ts | 4 +- .../metric_threshold/lib/create_timerange.ts | 2 +- .../metric_threshold/lib/evaluate_rule.ts | 14 ++-- .../metric_threshold/lib/metric_query.test.ts | 4 +- .../metric_threshold/lib/metric_query.ts | 4 +- .../metric_threshold_executor.test.ts | 24 +++---- .../metric_threshold_executor.ts | 18 ++--- .../register_metric_threshold_rule_type.ts | 22 +++--- .../lib/alerting/metric_threshold/types.ts | 46 ------------ .../metrics_ui/inventory_threshold_alert.ts | 10 +-- .../apis/metrics_ui/metric_threshold_alert.ts | 16 ++--- .../apis/metrics_ui/metrics_alerting.ts | 4 +- 43 files changed, 309 insertions(+), 422 deletions(-) delete mode 100644 x-pack/plugins/infra/server/lib/alerting/common/types.ts delete mode 100644 x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts delete mode 100644 x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts diff --git a/x-pack/plugins/infra/common/alerting/metrics/types.ts b/x-pack/plugins/infra/common/alerting/metrics/types.ts index 19812a7d37517..0216f63b8f85d 100644 --- a/x-pack/plugins/infra/common/alerting/metrics/types.ts +++ b/x-pack/plugins/infra/common/alerting/metrics/types.ts @@ -4,14 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import * as rt from 'io-ts'; import { Unit } from '@elastic/datemath'; +import * as rt from 'io-ts'; +import { SnapshotCustomMetricInput } from '../../http_api'; import { ANOMALY_THRESHOLD } from '../../infra_ml'; import { InventoryItemType, SnapshotMetricType } from '../../inventory_models/types'; -import { SnapshotCustomMetricInput } from '../../http_api'; -// TODO: Have threshold and inventory alerts import these types from this file instead of from their -// local directories export const METRIC_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.threshold'; export const METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.inventory.threshold'; export const METRIC_ANOMALY_ALERT_TYPE_ID = 'metrics.alert.anomaly'; @@ -37,6 +35,14 @@ export enum Aggregators { P99 = 'p99', } +export enum AlertStates { + OK, + ALERT, + WARNING, + NO_DATA, + ERROR, +} + const metricAnomalyNodeTypeRT = rt.union([rt.literal('hosts'), rt.literal('k8s')]); const metricAnomalyMetricRT = rt.union([ rt.literal('memory_usage'), @@ -80,3 +86,29 @@ export interface InventoryMetricThresholdParams { sourceId?: string; alertOnNoData?: boolean; } + +interface BaseMetricExpressionParams { + timeSize: number; + timeUnit: Unit; + sourceId?: string; + threshold: number[]; + comparator: Comparator; + warningComparator?: Comparator; + warningThreshold?: number[]; +} + +export interface NonCountMetricExpressionParams extends BaseMetricExpressionParams { + aggType: Exclude; + metric: string; +} + +export interface CountMetricExpressionParams extends BaseMetricExpressionParams { + aggType: Aggregators.COUNT; + metric: never; +} + +export type MetricExpressionParams = NonCountMetricExpressionParams | CountMetricExpressionParams; + +export const QUERY_INVALID: unique symbol = Symbol('QUERY_INVALID'); + +export type FilterQuery = string | typeof QUERY_INVALID; diff --git a/x-pack/plugins/infra/public/alerting/common/criterion_preview_chart/threshold_annotations.test.tsx b/x-pack/plugins/infra/public/alerting/common/criterion_preview_chart/threshold_annotations.test.tsx index 306a623eed984..be8e474b60114 100644 --- a/x-pack/plugins/infra/public/alerting/common/criterion_preview_chart/threshold_annotations.test.tsx +++ b/x-pack/plugins/infra/public/alerting/common/criterion_preview_chart/threshold_annotations.test.tsx @@ -4,15 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { shallow } from 'enzyme'; import React from 'react'; -import { ThresholdAnnotations } from './threshold_annotations'; -import { - Comparator, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../server/lib/alerting/metric_threshold/types'; -// import { Color } from 'x-pack/plugins/infra/common/color_palette'; +import { Comparator } from '../../../../common/alerting/metrics'; import { Color } from '../../../../common/color_palette'; -import { shallow } from 'enzyme'; +import { ThresholdAnnotations } from './threshold_annotations'; jest.mock('@elastic/charts', () => { const original = jest.requireActual('@elastic/charts'); diff --git a/x-pack/plugins/infra/public/alerting/common/criterion_preview_chart/threshold_annotations.tsx b/x-pack/plugins/infra/public/alerting/common/criterion_preview_chart/threshold_annotations.tsx index 397d355eaeb5a..9400537bb9d7c 100644 --- a/x-pack/plugins/infra/public/alerting/common/criterion_preview_chart/threshold_annotations.tsx +++ b/x-pack/plugins/infra/public/alerting/common/criterion_preview_chart/threshold_annotations.tsx @@ -4,14 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React from 'react'; +import { AnnotationDomainType, LineAnnotation, RectAnnotation } from '@elastic/charts'; import { first, last } from 'lodash'; -import { RectAnnotation, AnnotationDomainType, LineAnnotation } from '@elastic/charts'; - -import { - Comparator, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../server/lib/alerting/metric_threshold/types'; +import React from 'react'; +import { Comparator } from '../../../../common/alerting/metrics'; import { Color, colorTransformer } from '../../../../common/color_palette'; interface ThresholdAnnotationsProps { diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx index ccb7a0e34d736..c8cd2da45c5c3 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx @@ -5,17 +5,14 @@ * 2.0. */ -import { mountWithIntl, shallowWithIntl, nextTick } from '@kbn/test/jest'; -// We are using this inside a `jest.mock` call. Jest requires dynamic dependencies to be prefixed with `mock` -import { coreMock as mockCoreMock } from 'src/core/public/mocks'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { InventoryMetricConditions } from '../../../../server/lib/alerting/inventory_metric_threshold/types'; +import { mountWithIntl, nextTick, shallowWithIntl } from '@kbn/test/jest'; import React from 'react'; -import { Expressions, AlertContextMeta, ExpressionRow, defaultExpression } from './expression'; import { act } from 'react-dom/test-utils'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Comparator } from '../../../../server/lib/alerting/metric_threshold/types'; +// We are using this inside a `jest.mock` call. Jest requires dynamic dependencies to be prefixed with `mock` +import { coreMock as mockCoreMock } from 'src/core/public/mocks'; +import { Comparator, InventoryMetricConditions } from '../../../../common/alerting/metrics'; import { SnapshotCustomMetricInput } from '../../../../common/http_api/snapshot_api'; +import { AlertContextMeta, defaultExpression, ExpressionRow, Expressions } from './expression'; jest.mock('../../../containers/metrics_source/use_source_via_http', () => ({ useSourceViaHttp: () => ({ diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx index 5734347b8909d..f7d52a1aa95f0 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx @@ -6,71 +6,66 @@ */ import { Unit } from '@elastic/datemath'; -import React, { useCallback, useMemo, useEffect, useState, ChangeEvent } from 'react'; import { + EuiButtonEmpty, + EuiButtonIcon, + EuiCheckbox, + EuiFieldSearch, EuiFlexGroup, EuiFlexItem, - EuiButtonIcon, + EuiFormRow, + EuiHealth, + EuiIcon, EuiSpacer, EuiText, - EuiFormRow, - EuiButtonEmpty, - EuiFieldSearch, - EuiCheckbox, EuiToolTip, - EuiIcon, - EuiHealth, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { debounce, omit } from 'lodash'; -import { toMetricOpt } from '../../../../common/snapshot_metric_i18n'; -import { - Comparator, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../server/lib/alerting/metric_threshold/types'; +import React, { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { - ThresholdExpression, ForLastExpression, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../triggers_actions_ui/public/common'; -import { IErrorObject, RuleTypeParamsExpressionProps, + ThresholdExpression, } from '../../../../../triggers_actions_ui/public'; -import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar'; -import { useSourceViaHttp } from '../../../containers/metrics_source/use_source_via_http'; -import { sqsMetricTypes } from '../../../../common/inventory_models/aws_sqs/toolbar_items'; +import { + Comparator, + FilterQuery, + InventoryMetricConditions, + QUERY_INVALID, +} from '../../../../common/alerting/metrics'; +import { + SnapshotCustomMetricInput, + SnapshotCustomMetricInputRT, +} from '../../../../common/http_api/snapshot_api'; +import { findInventoryModel } from '../../../../common/inventory_models'; import { ec2MetricTypes } from '../../../../common/inventory_models/aws_ec2/toolbar_items'; -import { s3MetricTypes } from '../../../../common/inventory_models/aws_s3/toolbar_items'; import { rdsMetricTypes } from '../../../../common/inventory_models/aws_rds/toolbar_items'; -import { hostMetricTypes } from '../../../../common/inventory_models/host/toolbar_items'; +import { s3MetricTypes } from '../../../../common/inventory_models/aws_s3/toolbar_items'; +import { sqsMetricTypes } from '../../../../common/inventory_models/aws_sqs/toolbar_items'; import { containerMetricTypes } from '../../../../common/inventory_models/container/toolbar_items'; +import { hostMetricTypes } from '../../../../common/inventory_models/host/toolbar_items'; import { podMetricTypes } from '../../../../common/inventory_models/pod/toolbar_items'; -import { findInventoryModel } from '../../../../common/inventory_models'; import { InventoryItemType, SnapshotMetricType, SnapshotMetricTypeRT, } from '../../../../common/inventory_models/types'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { InventoryMetricConditions } from '../../../../server/lib/alerting/inventory_metric_threshold/types'; -import { MetricExpression } from './metric'; -import { NodeTypeExpression } from './node_type'; +import { toMetricOpt } from '../../../../common/snapshot_metric_i18n'; +import { DerivedIndexPattern } from '../../../containers/metrics_source'; +import { useSourceViaHttp } from '../../../containers/metrics_source/use_source_via_http'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; import { InfraWaffleMapOptions } from '../../../lib/lib'; +import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar'; import { convertKueryToElasticSearchQuery } from '../../../utils/kuery'; -import { - SnapshotCustomMetricInput, - SnapshotCustomMetricInputRT, -} from '../../../../common/http_api/snapshot_api'; - -import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; -import { DerivedIndexPattern } from '../../../containers/metrics_source'; - import { ExpressionChart } from './expression_chart'; +import { MetricExpression } from './metric'; +import { NodeTypeExpression } from './node_type'; + const FILTER_TYPING_DEBOUNCE_MS = 500; -export const QUERY_INVALID = Symbol('QUERY_INVALID'); export interface AlertContextMeta { options?: Partial; @@ -85,7 +80,7 @@ type Props = Omit< { criteria: Criteria; nodeType: InventoryItemType; - filterQuery?: string | symbol; + filterQuery?: FilterQuery; filterQueryText?: string; sourceId: string; alertOnNoData?: boolean; diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression_chart.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression_chart.tsx index a83aa2ec12676..4902a2d309b20 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression_chart.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression_chart.tsx @@ -4,35 +4,33 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useMemo, useCallback } from 'react'; import { Axis, Chart, niceTimeFormatter, Position, Settings } from '@elastic/charts'; -import { first, last } from 'lodash'; -import moment from 'moment'; import { EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { first, last } from 'lodash'; +import moment from 'moment'; +import React, { useCallback, useMemo } from 'react'; +import { InventoryMetricConditions } from '../../../../common/alerting/metrics'; import { Color } from '../../../../common/color_palette'; -import { MetricsExplorerRow, MetricsExplorerAggregation } from '../../../../common/http_api'; -import { MetricExplorerSeriesChart } from '../../../pages/metrics/metrics_explorer/components/series_chart'; -import { MetricsExplorerChartType } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; -import { calculateDomain } from '../../../pages/metrics/metrics_explorer/components/helpers/calculate_domain'; -import { getMetricId } from '../../../pages/metrics/metrics_explorer/components/helpers/get_metric_id'; +import { MetricsExplorerAggregation, MetricsExplorerRow } from '../../../../common/http_api'; +import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { InventoryMetricConditions } from '../../../../server/lib/alerting/inventory_metric_threshold/types'; import { useSnapshot } from '../../../pages/metrics/inventory_view/hooks/use_snaphot'; -import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; +import { useWaffleOptionsContext } from '../../../pages/metrics/inventory_view/hooks/use_waffle_options'; import { createInventoryMetricFormatter } from '../../../pages/metrics/inventory_view/lib/create_inventory_metric_formatter'; - +import { calculateDomain } from '../../../pages/metrics/metrics_explorer/components/helpers/calculate_domain'; +import { getMetricId } from '../../../pages/metrics/metrics_explorer/components/helpers/get_metric_id'; +import { MetricExplorerSeriesChart } from '../../../pages/metrics/metrics_explorer/components/series_chart'; +import { MetricsExplorerChartType } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; import { ChartContainer, + getChartTheme, LoadingState, NoDataState, TIME_LABELS, tooltipProps, - getChartTheme, } from '../../common/criterion_preview_chart/criterion_preview_chart'; import { ThresholdAnnotations } from '../../common/criterion_preview_chart/threshold_annotations'; -import { useWaffleOptionsContext } from '../../../pages/metrics/inventory_view/hooks/use_waffle_options'; interface Props { expression: InventoryMetricConditions; diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/metric.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/metric.tsx index 9319272833c14..ba5316cbcfebd 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/metric.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/metric.tsx @@ -4,33 +4,33 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useState, useCallback, useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; import { + EuiButtonGroup, + EuiButtonIcon, + EuiComboBox, EuiExpression, - EuiPopover, + EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow, - EuiComboBox, - EuiButtonGroup, - EuiSpacer, + EuiPopover, + EuiPopoverTitle, EuiSelect, + EuiSpacer, EuiText, - EuiFieldText, } from '@elastic/eui'; -import { EuiPopoverTitle, EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { debounce } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; +import { IErrorObject } from '../../../../../triggers_actions_ui/public'; import { getCustomMetricLabel } from '../../../../common/formatters/get_custom_metric_label'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; import { + SnapshotCustomAggregation, + SnapshotCustomAggregationRT, SnapshotCustomMetricInput, SnapshotCustomMetricInputRT, - SnapshotCustomAggregation, SNAPSHOT_CUSTOM_AGGREGATIONS, - SnapshotCustomAggregationRT, } from '../../../../common/http_api/snapshot_api'; import { DerivedIndexPattern } from '../../../containers/metrics_source'; diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx index 561bb39c6dce7..e093ea789cf79 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx @@ -6,21 +6,20 @@ */ import { i18n } from '@kbn/i18n'; +import type { ValidationResult } from '../../../../../triggers_actions_ui/public'; import { - InventoryMetricConditions, Comparator, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../server/lib/alerting/inventory_metric_threshold/types'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ValidationResult } from '../../../../../triggers_actions_ui/public/types'; -import { QUERY_INVALID } from './expression'; + FilterQuery, + InventoryMetricConditions, + QUERY_INVALID, +} from '../../../../common/alerting/metrics'; export function validateMetricThreshold({ criteria, filterQuery, }: { criteria: InventoryMetricConditions[]; - filterQuery?: string | symbol; + filterQuery?: FilterQuery; }): ValidationResult { const validationResult = { errors: {} }; const errors: { diff --git a/x-pack/plugins/infra/public/alerting/inventory/index.ts b/x-pack/plugins/infra/public/alerting/inventory/index.ts index aa44369f8eb2c..ca9f383aa28b0 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/index.ts +++ b/x-pack/plugins/infra/public/alerting/inventory/index.ts @@ -7,15 +7,12 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; +import { AlertTypeParams as RuleTypeParams } from '../../../../alerting/common'; +import { ObservabilityRuleTypeModel } from '../../../../observability/public'; import { InventoryMetricConditions, METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../server/lib/alerting/inventory_metric_threshold/types'; - -import { ObservabilityRuleTypeModel } from '../../../../observability/public'; - -import { AlertTypeParams as RuleTypeParams } from '../../../../alerting/common'; +} from '../../../common/alerting/metrics'; import { validateMetricThreshold } from './components/validation'; import { formatReason } from './rule_data_formatters'; diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/threshold.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/threshold.tsx index fdc60daceb715..6b3023123cd8c 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/threshold.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/threshold.tsx @@ -5,21 +5,20 @@ * 2.0. */ -import React, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { isNumber, isFinite } from 'lodash'; import { - EuiPopoverTitle, - EuiFlexItem, + EuiExpression, + EuiFieldNumber, EuiFlexGroup, + EuiFlexItem, + EuiFormRow, EuiPopover, + EuiPopoverTitle, EuiSelect, - EuiFieldNumber, - EuiExpression, - EuiFormRow, } from '@elastic/eui'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { IErrorObject } from '../../../../../../triggers_actions_ui/public/types'; +import { i18n } from '@kbn/i18n'; +import { isFinite, isNumber } from 'lodash'; +import React, { useState } from 'react'; +import { IErrorObject } from '../../../../../../triggers_actions_ui/public'; import { Comparator, ComparatorToi18nMap, diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx index 5a0060f229795..51629b656a7e7 100644 --- a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx @@ -5,34 +5,29 @@ * 2.0. */ -import React, { useCallback, useState, useMemo, useEffect } from 'react'; -import { EuiFlexGroup, EuiSpacer, EuiText, EuiLoadingContent } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiFlexGroup, EuiLoadingContent, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useInfraMLCapabilities } from '../../../containers/ml/infra_ml_capabilities'; -import { SubscriptionSplashPrompt } from '../../../components/subscription_splash_content'; -import { MetricAnomalyParams } from '../../../../common/alerting/metrics'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { euiStyled, EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; -import { - WhenExpression, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../triggers_actions_ui/public/common'; import { RuleTypeParams, RuleTypeParamsExpressionProps, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../triggers_actions_ui/public/types'; -import { useSourceViaHttp } from '../../../containers/metrics_source/use_source_via_http'; + WhenExpression, +} from '../../../../../triggers_actions_ui/public'; +import { MetricAnomalyParams } from '../../../../common/alerting/metrics'; +import { ANOMALY_THRESHOLD } from '../../../../common/infra_ml'; import { findInventoryModel } from '../../../../common/inventory_models'; import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; -import { NodeTypeExpression } from './node_type'; -import { SeverityThresholdExpression } from './severity_threshold'; -import { InfraWaffleMapOptions } from '../../../lib/lib'; -import { ANOMALY_THRESHOLD } from '../../../../common/infra_ml'; - -import { InfluencerFilter } from './influencer_filter'; +import { SubscriptionSplashPrompt } from '../../../components/subscription_splash_content'; +import { useSourceViaHttp } from '../../../containers/metrics_source/use_source_via_http'; +import { useInfraMLCapabilities } from '../../../containers/ml/infra_ml_capabilities'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; import { useActiveKibanaSpace } from '../../../hooks/use_kibana_space'; +import { InfraWaffleMapOptions } from '../../../lib/lib'; +import { InfluencerFilter } from './influencer_filter'; +import { NodeTypeExpression } from './node_type'; +import { SeverityThresholdExpression } from './severity_threshold'; export interface AlertContextMeta { metric?: InfraWaffleMapOptions['metric']; diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/validation.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/validation.tsx index 8e254fb2b67a8..a4eda632b2fdd 100644 --- a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/validation.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/validation.tsx @@ -6,8 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ValidationResult } from '../../../../../triggers_actions_ui/public/types'; +import type { ValidationResult } from '../../../../../triggers_actions_ui/public'; export function validateMetricAnomaly({ hasInfraMLCapabilities, diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx index f5df605316e24..94192f9f911c5 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx @@ -6,14 +6,13 @@ */ import { mountWithIntl, nextTick } from '@kbn/test/jest'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; // We are using this inside a `jest.mock` call. Jest requires dynamic dependencies to be prefixed with `mock` import { coreMock as mockCoreMock } from 'src/core/public/mocks'; +import { Comparator } from '../../../../common/alerting/metrics'; import { MetricsExplorerMetric } from '../../../../common/http_api/metrics_explorer'; -import React from 'react'; import { Expressions } from './expression'; -import { act } from 'react-dom/test-utils'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Comparator } from '../../../../server/lib/alerting/metric_threshold/types'; jest.mock('../../../containers/metrics_source/use_source_via_http', () => ({ useSourceViaHttp: () => ({ diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx index f553d4b9dadf7..e0039c4590069 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -6,43 +6,41 @@ */ import { Unit } from '@elastic/datemath'; -import React, { ChangeEvent, useCallback, useMemo, useEffect, useState } from 'react'; import { - EuiSpacer, - EuiText, - EuiFormRow, + EuiAccordion, EuiButtonEmpty, EuiCheckbox, - EuiToolTip, - EuiIcon, EuiFieldSearch, - EuiAccordion, - EuiPanel, + EuiFormRow, + EuiIcon, EuiLink, + EuiPanel, + EuiSpacer, + EuiText, + EuiToolTip, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { debounce } from 'lodash'; -import { Comparator, Aggregators } from '../../../../common/alerting/metrics'; -import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; +import React, { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { + ForLastExpression, IErrorObject, RuleTypeParams, RuleTypeParamsExpressionProps, } from '../../../../../triggers_actions_ui/public'; +import { Aggregators, Comparator, QUERY_INVALID } from '../../../../common/alerting/metrics'; +import { useSourceViaHttp } from '../../../containers/metrics_source/use_source_via_http'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; +import { MetricsExplorerGroupBy } from '../../../pages/metrics/metrics_explorer/components/group_by'; import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar'; import { MetricsExplorerOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; -import { MetricsExplorerGroupBy } from '../../../pages/metrics/metrics_explorer/components/group_by'; -import { useSourceViaHttp } from '../../../containers/metrics_source/use_source_via_http'; import { convertKueryToElasticSearchQuery } from '../../../utils/kuery'; - -import { ExpressionRow } from './expression_row'; -import { MetricExpression, AlertParams, AlertContextMeta } from '../types'; +import { AlertContextMeta, AlertParams, MetricExpression } from '../types'; import { ExpressionChart } from './expression_chart'; -import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; +import { ExpressionRow } from './expression_row'; const FILTER_TYPING_DEBOUNCE_MS = 500; -export const QUERY_INVALID = Symbol('QUERY_INVALID'); type Props = Omit< RuleTypeParamsExpressionProps, diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx index c2c1fa719bb95..f7e3201bbf2c9 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx @@ -5,17 +5,16 @@ * 2.0. */ +import { DataViewBase } from '@kbn/es-query'; import { mountWithIntl, nextTick } from '@kbn/test/jest'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; // We are using this inside a `jest.mock` call. Jest requires dynamic dependencies to be prefixed with `mock` import { coreMock as mockCoreMock } from 'src/core/public/mocks'; -import { MetricExpression } from '../types'; -import { DataViewBase } from '@kbn/es-query'; +import { Aggregators, Comparator } from '../../../../common/alerting/metrics'; import { MetricsSourceConfiguration } from '../../../../common/metrics_sources'; -import React from 'react'; +import { MetricExpression } from '../types'; import { ExpressionChart } from './expression_chart'; -import { act } from 'react-dom/test-utils'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Aggregators, Comparator } from '../../../../server/lib/alerting/metric_threshold/types'; const mockStartServices = mockCoreMock.createStart(); jest.mock('../../../hooks/use_kibana', () => ({ diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.test.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.test.tsx index b4321dbfda320..1d8c6f6339878 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.test.tsx @@ -6,12 +6,11 @@ */ import { mountWithIntl, nextTick } from '@kbn/test/jest'; -import { MetricExpression } from '../types'; import React from 'react'; -import { ExpressionRow } from './expression_row'; import { act } from 'react-dom/test-utils'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Comparator } from '../../../../server/lib/alerting/metric_threshold/types'; +import { Comparator } from '../../../../common/alerting/metrics'; +import { MetricExpression } from '../types'; +import { ExpressionRow } from './expression_row'; jest.mock('../../../containers/metrics_source/use_source_via_http', () => ({ useSourceViaHttp: () => ({ diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.tsx index cc5a58aee1303..6f1d1ed6e12c0 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.tsx @@ -4,35 +4,32 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useCallback, useState, useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; import { + EuiButtonEmpty, + EuiButtonIcon, EuiFlexGroup, EuiFlexItem, - EuiButtonIcon, + EuiHealth, + EuiLink, EuiSpacer, EuiText, - EuiLink, - EuiHealth, - EuiButtonEmpty, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { omit } from 'lodash'; -import { pctToDecimal, decimalToPct } from '../../../../common/utils/corrected_percent_convert'; +import React, { useCallback, useMemo, useState } from 'react'; +import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { - WhenExpression, + builtInComparators, + IErrorObject, OfExpression, ThresholdExpression, + WhenExpression, } from '../../../../../triggers_actions_ui/public'; -import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; -import { IErrorObject } from '../../../../../triggers_actions_ui/public'; -import { MetricExpression, AGGREGATION_TYPES } from '../types'; -import { - Comparator, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../server/lib/alerting/metric_threshold/types'; -import { builtInComparators } from '../../../../../triggers_actions_ui/public'; +import { Comparator } from '../../../../common/alerting/metrics'; +import { decimalToPct, pctToDecimal } from '../../../../common/utils/corrected_percent_convert'; import { DerivedIndexPattern } from '../../../containers/metrics_source'; +import { AGGREGATION_TYPES, MetricExpression } from '../types'; const customComparators = { ...builtInComparators, diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx index 8df313aa1627a..8634c3686087f 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx @@ -6,21 +6,20 @@ */ import { i18n } from '@kbn/i18n'; +import { ValidationResult } from '../../../../../triggers_actions_ui/public'; import { - MetricExpressionParams, Comparator, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../server/lib/alerting/metric_threshold/types'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ValidationResult } from '../../../../../triggers_actions_ui/public/types'; -import { QUERY_INVALID } from './expression'; + FilterQuery, + MetricExpressionParams, + QUERY_INVALID, +} from '../../../../common/alerting/metrics'; export function validateMetricThreshold({ criteria, filterQuery, }: { criteria: MetricExpressionParams[]; - filterQuery?: string | symbol; + filterQuery?: FilterQuery; }): ValidationResult { const validationResult = { errors: {} }; const errors: { diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts index cb938597ab432..e67ece7836595 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts @@ -7,15 +7,14 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; -import { ObservabilityRuleTypeModel } from '../../../../observability/public'; -import { validateMetricThreshold } from './components/validation'; -import { formatReason } from './rule_data_formatters'; import { AlertTypeParams as RuleTypeParams } from '../../../../alerting/common'; +import { ObservabilityRuleTypeModel } from '../../../../observability/public'; import { MetricExpressionParams, METRIC_THRESHOLD_ALERT_TYPE_ID, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../server/lib/alerting/metric_threshold/types'; +} from '../../../common/alerting/metrics'; +import { validateMetricThreshold } from './components/validation'; +import { formatReason } from './rule_data_formatters'; interface MetricThresholdRuleTypeParams extends RuleTypeParams { criteria: MetricExpressionParams[]; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts index 0d1c85087f33d..a88dd1d4548b8 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts @@ -5,12 +5,9 @@ * 2.0. */ -import { - MetricExpressionParams, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../server/lib/alerting/metric_threshold/types'; -import { MetricsExplorerOptions } from '../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; +import { FilterQuery, MetricExpressionParams } from '../../../common/alerting/metrics'; import { MetricsExplorerSeries } from '../../../common/http_api/metrics_explorer'; +import { MetricsExplorerOptions } from '../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; export interface AlertContextMeta { currentOptions?: Partial; @@ -57,7 +54,7 @@ export interface ExpressionChartData { export interface AlertParams { criteria: MetricExpression[]; groupBy?: string | string[]; - filterQuery?: string | symbol; + filterQuery?: FilterQuery; sourceId: string; filterQueryText?: string; alertOnNoData?: boolean; diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index bc3aff9f01637..1eb016f582939 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -10,6 +10,9 @@ import { AppMountParameters, PluginInitializerContext } from 'kibana/public'; import { from } from 'rxjs'; import { map } from 'rxjs/operators'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; +import { createInventoryMetricRuleType } from './alerting/inventory'; +import { createLogThresholdRuleType } from './alerting/log_threshold'; +import { createMetricThresholdRuleType } from './alerting/metric_threshold'; import { LOG_STREAM_EMBEDDABLE } from './components/log_stream/log_stream_embeddable'; import { LogStreamEmbeddableFactoryDefinition } from './components/log_stream/log_stream_embeddable_factory'; import { createMetricsFetchData, createMetricsHasData } from './metrics_overview_fetchers'; @@ -26,15 +29,11 @@ import { getLogsHasDataFetcher, getLogsOverviewDataFetcher } from './utils/logs_ export class Plugin implements InfraClientPluginClass { constructor(_context: PluginInitializerContext) {} - async setup(core: InfraClientCoreSetup, pluginsSetup: InfraClientSetupDeps) { + setup(core: InfraClientCoreSetup, pluginsSetup: InfraClientSetupDeps) { if (pluginsSetup.home) { registerFeatures(pluginsSetup.home); } - const { createInventoryMetricRuleType } = await import('./alerting/inventory'); - const { createLogThresholdRuleType } = await import('./alerting/log_threshold'); - const { createMetricThresholdRuleType } = await import('./alerting/metric_threshold'); - pluginsSetup.observability.observabilityRuleTypeRegistry.register( createInventoryMetricRuleType() ); diff --git a/x-pack/plugins/infra/server/features.ts b/x-pack/plugins/infra/server/features.ts index 361565c3672c5..3e7ede11f7e9d 100644 --- a/x-pack/plugins/infra/server/features.ts +++ b/x-pack/plugins/infra/server/features.ts @@ -6,10 +6,12 @@ */ import { i18n } from '@kbn/i18n'; -import { LOG_DOCUMENT_COUNT_RULE_TYPE_ID } from '../common/alerting/logs/log_threshold/types'; -import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from './lib/alerting/inventory_metric_threshold/types'; -import { METRIC_THRESHOLD_ALERT_TYPE_ID } from './lib/alerting/metric_threshold/types'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; +import { LOG_DOCUMENT_COUNT_RULE_TYPE_ID } from '../common/alerting/logs/log_threshold/types'; +import { + METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, + METRIC_THRESHOLD_ALERT_TYPE_ID, +} from '../common/alerting/metrics'; import { LOGS_FEATURE_ID, METRICS_FEATURE_ID } from '../common/constants'; export const METRICS_FEATURE = { diff --git a/x-pack/plugins/infra/server/lib/alerting/common/messages.ts b/x-pack/plugins/infra/server/lib/alerting/common/messages.ts index 0a3b41190a088..d92670a4eb5f6 100644 --- a/x-pack/plugins/infra/server/lib/alerting/common/messages.ts +++ b/x-pack/plugins/infra/server/lib/alerting/common/messages.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { Comparator, AlertStates } from './types'; +import { AlertStates, Comparator } from '../../../../common/alerting/metrics'; export const DOCUMENT_COUNT_I18N = i18n.translate( 'xpack.infra.metrics.alerting.threshold.documentCount', diff --git a/x-pack/plugins/infra/server/lib/alerting/common/types.ts b/x-pack/plugins/infra/server/lib/alerting/common/types.ts deleted file mode 100644 index 1d038cace14fe..0000000000000 --- a/x-pack/plugins/infra/server/lib/alerting/common/types.ts +++ /dev/null @@ -1,35 +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 enum Comparator { - GT = '>', - LT = '<', - GT_OR_EQ = '>=', - LT_OR_EQ = '<=', - BETWEEN = 'between', - OUTSIDE_RANGE = 'outside', -} - -export enum Aggregators { - COUNT = 'count', - AVERAGE = 'avg', - SUM = 'sum', - MIN = 'min', - MAX = 'max', - RATE = 'rate', - CARDINALITY = 'cardinality', - P95 = 'p95', - P99 = 'p99', -} - -export enum AlertStates { - OK, - ALERT, - WARNING, - NO_DATA, - ERROR, -} diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts index 8224c95087339..6425aa1018825 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts @@ -5,14 +5,14 @@ * 2.0. */ +import { ElasticsearchClient } from 'kibana/server'; import { mapValues } from 'lodash'; import moment from 'moment'; -import { ElasticsearchClient } from 'kibana/server'; -import { Comparator, InventoryMetricConditions } from './types'; -import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; +import { Comparator, InventoryMetricConditions } from '../../../../common/alerting/metrics'; import { InfraTimerangeInput } from '../../../../common/http_api'; -import { InfraSource } from '../../sources'; +import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; import { LogQueryFields } from '../../../services/log_queries/get_log_query_fields'; +import { InfraSource } from '../../sources'; import { calcualteFromBasedOnMetric } from './lib/calculate_from_based_on_metric'; import { getData } from './lib/get_data'; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index 6e318e1e82d6c..497bb0cc960a7 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -7,14 +7,11 @@ import { i18n } from '@kbn/i18n'; import { ALERT_REASON, ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils'; -import moment from 'moment'; import { first, get, last } from 'lodash'; -import { getCustomMetricLabel } from '../../../../common/formatters/get_custom_metric_label'; -import { toMetricOpt } from '../../../../common/snapshot_metric_i18n'; -import { AlertStates } from './types'; +import moment from 'moment'; import { - ActionGroupIdsOf, ActionGroup, + ActionGroupIdsOf, AlertInstanceContext as AlertContext, AlertInstanceState as AlertState, RecoveredActionGroup, @@ -23,18 +20,20 @@ import { AlertInstance as Alert, AlertTypeState as RuleTypeState, } from '../../../../../alerting/server'; +import { AlertStates, InventoryMetricThresholdParams } from '../../../../common/alerting/metrics'; +import { createFormatter } from '../../../../common/formatters'; +import { getCustomMetricLabel } from '../../../../common/formatters/get_custom_metric_label'; +import { METRIC_FORMATTERS } from '../../../../common/formatters/snapshot_metric_formats'; import { SnapshotMetricType } from '../../../../common/inventory_models/types'; +import { toMetricOpt } from '../../../../common/snapshot_metric_i18n'; import { InfraBackendLibs } from '../../infra_types'; -import { METRIC_FORMATTERS } from '../../../../common/formatters/snapshot_metric_formats'; -import { createFormatter } from '../../../../common/formatters'; -import { InventoryMetricThresholdParams } from '../../../../common/alerting/metrics'; import { buildErrorAlertReason, buildFiredAlertReason, + buildInvalidQueryAlertReason, buildNoDataAlertReason, // buildRecoveredAlertReason, stateToAlertMessage, - buildInvalidQueryAlertReason, } from '../common/messages'; import { evaluateCondition } from './evaluate_condition'; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/calculate_from_based_on_metric.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/calculate_from_based_on_metric.ts index 7c6031dffd57d..5adaa44130929 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/calculate_from_based_on_metric.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/calculate_from_based_on_metric.ts @@ -6,14 +6,14 @@ */ import { Moment } from 'moment'; +import { InventoryMetricConditions } from '../../../../../common/alerting/metrics'; import { SnapshotCustomMetricInput } from '../../../../../common/http_api'; +import { findInventoryModel } from '../../../../../common/inventory_models'; import { InventoryItemType, SnapshotMetricType, } from '../../../../../common/inventory_models/types'; -import { InventoryMetricConditions } from '../types'; import { isRate } from './is_rate'; -import { findInventoryModel } from '../../../../../common/inventory_models'; export const calcualteFromBasedOnMetric = ( to: Moment, diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_rule_type.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_rule_type.ts index 9776d1ab66915..ac68a0df706fb 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_rule_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_rule_type.ts @@ -5,37 +5,40 @@ * 2.0. */ -import { schema, Type } from '@kbn/config-schema'; import { Unit } from '@elastic/datemath'; +import { schema, Type } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; import { PluginSetupContract } from '../../../../../alerting/server'; import { - createInventoryMetricThresholdExecutor, - FIRED_ACTIONS, - FIRED_ACTIONS_ID, - WARNING_ACTIONS, -} from './inventory_metric_threshold_executor'; -import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, Comparator } from './types'; + Comparator, + METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, +} from '../../../../common/alerting/metrics'; +import { + SnapshotCustomAggregation, + SNAPSHOT_CUSTOM_AGGREGATIONS, +} from '../../../../common/http_api/snapshot_api'; +import { + InventoryItemType, + SnapshotMetricType, + SnapshotMetricTypeKeys, +} from '../../../../common/inventory_models/types'; import { InfraBackendLibs } from '../../infra_types'; -import { oneOfLiterals, validateIsStringElasticsearchJSONFilter } from '../common/utils'; import { - groupActionVariableDescription, alertStateActionVariableDescription, + groupActionVariableDescription, + metricActionVariableDescription, reasonActionVariableDescription, + thresholdActionVariableDescription, timestampActionVariableDescription, valueActionVariableDescription, - metricActionVariableDescription, - thresholdActionVariableDescription, } from '../common/messages'; +import { oneOfLiterals, validateIsStringElasticsearchJSONFilter } from '../common/utils'; import { - SnapshotMetricTypeKeys, - SnapshotMetricType, - InventoryItemType, -} from '../../../../common/inventory_models/types'; -import { - SNAPSHOT_CUSTOM_AGGREGATIONS, - SnapshotCustomAggregation, -} from '../../../../common/http_api/snapshot_api'; + createInventoryMetricThresholdExecutor, + FIRED_ACTIONS, + FIRED_ACTIONS_ID, + WARNING_ACTIONS, +} from './inventory_metric_threshold_executor'; const condition = schema.object({ threshold: schema.arrayOf(schema.number()), diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts deleted file mode 100644 index 829f34d42ee03..0000000000000 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.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 { Unit } from '@elastic/datemath'; -import { SnapshotCustomMetricInput } from '../../../../common/http_api/snapshot_api'; -import { SnapshotMetricType } from '../../../../common/inventory_models/types'; -import { Comparator, AlertStates, Aggregators } from '../common/types'; - -export { Comparator, AlertStates, Aggregators }; - -export const METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.inventory.threshold'; - -export interface InventoryMetricConditions { - metric: SnapshotMetricType; - timeSize: number; - timeUnit: Unit; - sourceId?: string; - threshold: number[]; - comparator: Comparator; - customMetric?: SnapshotCustomMetricInput; - warningThreshold?: number[]; - warningComparator?: Comparator; -} diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts index a0eac87ed161e..f762d694a59e7 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts @@ -6,27 +6,26 @@ */ import { i18n } from '@kbn/i18n'; +import { KibanaRequest } from 'kibana/server'; import { first } from 'lodash'; import moment from 'moment'; -import { KibanaRequest } from 'kibana/server'; -import { stateToAlertMessage } from '../common/messages'; -import { MetricAnomalyParams } from '../../../../common/alerting/metrics'; -import { MappedAnomalyHit } from '../../infra_ml'; -import { AlertStates } from '../common/types'; import { ActionGroup, AlertInstanceContext as AlertContext, AlertInstanceState as AlertState, } from '../../../../../alerting/common'; import { AlertExecutorOptions as RuleExecutorOptions } from '../../../../../alerting/server'; -import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; -import { MetricAnomalyAllowedActionGroups } from './register_metric_anomaly_rule_type'; import { MlPluginSetup } from '../../../../../ml/server'; +import { AlertStates, MetricAnomalyParams } from '../../../../common/alerting/metrics'; +import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; +import { MappedAnomalyHit } from '../../infra_ml'; import { InfraBackendLibs } from '../../infra_types'; +import { stateToAlertMessage } from '../common/messages'; import { evaluateCondition } from './evaluate_condition'; +import { MetricAnomalyAllowedActionGroups } from './register_metric_anomaly_rule_type'; export const createMetricAnomalyExecutor = - (libs: InfraBackendLibs, ml?: MlPluginSetup) => + (_libs: InfraBackendLibs, ml?: MlPluginSetup) => async ({ services, params, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_percentile_aggregation.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_percentile_aggregation.ts index 92219e733da5d..35111c1a69b2f 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_percentile_aggregation.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_percentile_aggregation.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { Aggregators } from '../types'; +import { Aggregators } from '../../../../../common/alerting/metrics'; + export const createPercentileAggregation = ( type: Aggregators.P95 | Aggregators.P99, field: string diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_timerange.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_timerange.test.ts index 5640a1d928436..bf365d7e89bca 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_timerange.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_timerange.test.ts @@ -5,9 +5,9 @@ * 2.0. */ -import { createTimerange } from './create_timerange'; -import { Aggregators } from '../types'; import moment from 'moment'; +import { Aggregators } from '../../../../../common/alerting/metrics'; +import { createTimerange } from './create_timerange'; describe('createTimerange(interval, aggType, timeframe)', () => { describe('without timeframe', () => { diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_timerange.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_timerange.ts index cca63aca14d09..03c407e8afe37 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_timerange.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_timerange.ts @@ -6,7 +6,7 @@ */ import moment from 'moment'; -import { Aggregators } from '../types'; +import { Aggregators } from '../../../../../common/alerting/metrics'; export const createTimerange = ( interval: number, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_rule.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_rule.ts index a3de79368cb80..4fbac02cff19b 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_rule.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_rule.ts @@ -5,23 +5,25 @@ * 2.0. */ -import moment from 'moment'; import { ElasticsearchClient } from 'kibana/server'; -import { difference, mapValues, first, last, isNaN, isNumber, isObject, has } from 'lodash'; +import { difference, first, has, isNaN, isNumber, isObject, last, mapValues } from 'lodash'; +import moment from 'moment'; import { + Aggregators, + Comparator, isTooManyBucketsPreviewException, + MetricExpressionParams, TOO_MANY_BUCKETS_PREVIEW_EXCEPTION, } from '../../../../../common/alerting/metrics'; -import { getIntervalInSeconds } from '../../../../utils/get_interval_in_seconds'; import { InfraSource } from '../../../../../common/source_configuration/source_configuration'; -import { InfraDatabaseSearchResponse } from '../../../adapters/framework/adapter_types'; import { createAfterKeyHandler } from '../../../../utils/create_afterkey_handler'; import { getAllCompositeData } from '../../../../utils/get_all_composite_data'; +import { getIntervalInSeconds } from '../../../../utils/get_interval_in_seconds'; +import { InfraDatabaseSearchResponse } from '../../../adapters/framework/adapter_types'; import { DOCUMENT_COUNT_I18N } from '../../common/messages'; import { UNGROUPED_FACTORY_KEY } from '../../common/utils'; -import { MetricExpressionParams, Comparator, Aggregators } from '../types'; -import { getElasticsearchMetricQuery } from './metric_query'; import { createTimerange } from './create_timerange'; +import { getElasticsearchMetricQuery } from './metric_query'; interface AggregationWithoutIntervals { aggregatedValue: { value: number; values?: Array<{ key: number; value: number }> }; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.test.ts index 3c6bca67de413..7e26bc2ba6be6 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.test.ts @@ -5,9 +5,9 @@ * 2.0. */ -import { MetricExpressionParams } from '../types'; -import { getElasticsearchMetricQuery } from './metric_query'; import moment from 'moment'; +import { MetricExpressionParams } from '../../../../../common/alerting/metrics'; +import { getElasticsearchMetricQuery } from './metric_query'; describe("The Metric Threshold Alert's getElasticsearchMetricQuery", () => { const expressionParams = { diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts index e0abd8465e306..5f7d643ec22eb 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts @@ -5,11 +5,11 @@ * 2.0. */ +import { Aggregators, MetricExpressionParams } from '../../../../../common/alerting/metrics'; import { TIMESTAMP_FIELD } from '../../../../../common/constants'; import { networkTraffic } from '../../../../../common/inventory_models/shared/metrics/snapshot/network_traffic'; -import { MetricExpressionParams, Aggregators } from '../types'; -import { createPercentileAggregation } from './create_percentile_aggregation'; import { calculateDateHistogramOffset } from '../../../metrics/lib/calculate_date_histogram_offset'; +import { createPercentileAggregation } from './create_percentile_aggregation'; const getParsedFilterQuery: (filterQuery: string | undefined) => Record | null = ( filterQuery diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index ac3285db98faf..c007154320db4 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -5,30 +5,30 @@ * 2.0. */ -import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; -import * as mocks from './test_mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { + AlertInstanceContext as AlertContext, + AlertInstanceState as AlertState, +} from '../../../../../alerting/server'; // import { RecoveredActionGroup } from '../../../../../alerting/common'; import { - alertsMock, - AlertServicesMock, AlertInstanceMock, + AlertServicesMock, + alertsMock, } from '../../../../../alerting/server/mocks'; import { LifecycleAlertServices } from '../../../../../rule_registry/server'; import { ruleRegistryMocks } from '../../../../../rule_registry/server/mocks'; import { createLifecycleRuleExecutorMock } from '../../../../../rule_registry/server/utils/create_lifecycle_rule_executor_mock'; -import { InfraSources } from '../../sources'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; -import { - AlertInstanceContext as AlertContext, - AlertInstanceState as AlertState, -} from '../../../../../alerting/server'; import { Aggregators, Comparator, CountMetricExpressionParams, NonCountMetricExpressionParams, -} from './types'; +} from '../../../../common/alerting/metrics'; +import { InfraSources } from '../../sources'; +import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; +import * as mocks from './test_mocks'; interface AlertTestInstance { instance: AlertInstanceMock; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index 1b85df04c428b..8f6c359b150c1 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -5,33 +5,33 @@ * 2.0. */ -import { first, last, isEqual } from 'lodash'; import { i18n } from '@kbn/i18n'; -import moment from 'moment'; import { ALERT_REASON } from '@kbn/rule-data-utils'; +import { first, isEqual, last } from 'lodash'; +import moment from 'moment'; import { ActionGroupIdsOf, - RecoveredActionGroup, - AlertInstanceState as AlertState, AlertInstanceContext as AlertContext, + AlertInstanceState as AlertState, + RecoveredActionGroup, } from '../../../../../alerting/common'; import { - AlertTypeState as RuleTypeState, AlertInstance as Alert, + AlertTypeState as RuleTypeState, } from '../../../../../alerting/server'; +import { AlertStates, Comparator } from '../../../../common/alerting/metrics'; +import { createFormatter } from '../../../../common/formatters'; import { InfraBackendLibs } from '../../infra_types'; import { buildErrorAlertReason, buildFiredAlertReason, + buildInvalidQueryAlertReason, buildNoDataAlertReason, // buildRecoveredAlertReason, stateToAlertMessage, - buildInvalidQueryAlertReason, } from '../common/messages'; import { UNGROUPED_FACTORY_KEY } from '../common/utils'; -import { createFormatter } from '../../../../common/formatters'; -import { AlertStates, Comparator } from './types'; -import { evaluateRule, EvaluatedRuleParams } from './lib/evaluate_rule'; +import { EvaluatedRuleParams, evaluateRule } from './lib/evaluate_rule'; export type MetricThresholdRuleParams = Record; export type MetricThresholdRuleTypeState = RuleTypeState & { diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_rule_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_rule_type.ts index 6142e7083e0d2..d5d819ba7902e 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_rule_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_rule_type.ts @@ -8,25 +8,25 @@ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; import { ActionGroupIdsOf } from '../../../../../alerting/common'; -import { RuleType, PluginSetupContract } from '../../../../../alerting/server'; +import { PluginSetupContract, RuleType } from '../../../../../alerting/server'; +import { Comparator, METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../common/alerting/metrics'; import { METRIC_EXPLORER_AGGREGATIONS } from '../../../../common/http_api'; -import { - createMetricThresholdExecutor, - FIRED_ACTIONS, - WARNING_ACTIONS, -} from './metric_threshold_executor'; -import { METRIC_THRESHOLD_ALERT_TYPE_ID, Comparator } from './types'; import { InfraBackendLibs } from '../../infra_types'; -import { oneOfLiterals, validateIsStringElasticsearchJSONFilter } from '../common/utils'; import { - groupActionVariableDescription, alertStateActionVariableDescription, + groupActionVariableDescription, + metricActionVariableDescription, reasonActionVariableDescription, + thresholdActionVariableDescription, timestampActionVariableDescription, valueActionVariableDescription, - metricActionVariableDescription, - thresholdActionVariableDescription, } from '../common/messages'; +import { oneOfLiterals, validateIsStringElasticsearchJSONFilter } from '../common/utils'; +import { + createMetricThresholdExecutor, + FIRED_ACTIONS, + WARNING_ACTIONS, +} from './metric_threshold_executor'; type MetricThresholdAllowedActionGroups = ActionGroupIdsOf< typeof FIRED_ACTIONS | typeof WARNING_ACTIONS diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts deleted file mode 100644 index 101be1f77b9d0..0000000000000 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Unit } from '@elastic/datemath'; -import { Comparator, AlertStates } from '../common/types'; - -export { Comparator, AlertStates }; -export const METRIC_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.threshold'; - -export enum Aggregators { - COUNT = 'count', - AVERAGE = 'avg', - SUM = 'sum', - MIN = 'min', - MAX = 'max', - RATE = 'rate', - CARDINALITY = 'cardinality', - P95 = 'p95', - P99 = 'p99', -} - -interface BaseMetricExpressionParams { - timeSize: number; - timeUnit: Unit; - sourceId?: string; - threshold: number[]; - comparator: Comparator; - warningComparator?: Comparator; - warningThreshold?: number[]; -} - -export interface NonCountMetricExpressionParams extends BaseMetricExpressionParams { - aggType: Exclude; - metric: string; -} - -export interface CountMetricExpressionParams extends BaseMetricExpressionParams { - aggType: Aggregators.COUNT; - metric: never; -} - -export type MetricExpressionParams = NonCountMetricExpressionParams | CountMetricExpressionParams; diff --git a/x-pack/test/api_integration/apis/metrics_ui/inventory_threshold_alert.ts b/x-pack/test/api_integration/apis/metrics_ui/inventory_threshold_alert.ts index a5f72f1c43b81..98502430016b2 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/inventory_threshold_alert.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/inventory_threshold_alert.ts @@ -10,15 +10,15 @@ import { convertToKibanaClient } from '@kbn/test'; import { Comparator, InventoryMetricConditions, -} from '../../../../plugins/infra/server/lib/alerting/inventory_metric_threshold/types'; -import { InfraSource } from '../../../../plugins/infra/server/lib/sources'; -import { FtrProviderContext } from '../../ftr_provider_context'; -import { DATES } from './constants'; -import { evaluateCondition } from '../../../../plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition'; +} from '../../../../plugins/infra/common/alerting/metrics'; import { InventoryItemType, SnapshotMetricType, } from '../../../../plugins/infra/common/inventory_models/types'; +import { evaluateCondition } from '../../../../plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition'; +import { InfraSource } from '../../../../plugins/infra/server/lib/sources'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { DATES } from './constants'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); diff --git a/x-pack/test/api_integration/apis/metrics_ui/metric_threshold_alert.ts b/x-pack/test/api_integration/apis/metrics_ui/metric_threshold_alert.ts index d0fac5a7bd170..7e00044ef1365 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/metric_threshold_alert.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/metric_threshold_alert.ts @@ -7,18 +7,18 @@ import expect from '@kbn/expect'; import { convertToKibanaClient } from '@kbn/test'; -import { InfraSource } from '../../../../plugins/infra/common/source_configuration/source_configuration'; -import { FtrProviderContext } from '../../ftr_provider_context'; -import { - evaluateRule, - EvaluatedRuleParams, -} from '../../../../plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_rule'; import { Aggregators, + Comparator, CountMetricExpressionParams, NonCountMetricExpressionParams, -} from '../../../../plugins/infra/server/lib/alerting/metric_threshold/types'; -import { Comparator } from '../../../../plugins/infra/server/lib/alerting/common/types'; +} from '../../../../plugins/infra/common/alerting/metrics'; +import { InfraSource } from '../../../../plugins/infra/common/source_configuration/source_configuration'; +import { + EvaluatedRuleParams, + evaluateRule, +} from '../../../../plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_rule'; +import { FtrProviderContext } from '../../ftr_provider_context'; import { DATES } from './constants'; const { gauge, rate } = DATES['alert-test-data']; diff --git a/x-pack/test/api_integration/apis/metrics_ui/metrics_alerting.ts b/x-pack/test/api_integration/apis/metrics_ui/metrics_alerting.ts index 02a9f3070fe58..8b77bf7b1c089 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/metrics_alerting.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/metrics_alerting.ts @@ -7,10 +7,10 @@ import expect from '@kbn/expect'; import moment from 'moment'; +import { MetricExpressionParams } from '../../../../plugins/infra/common/alerting/metrics'; import { getElasticsearchMetricQuery } from '../../../../plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query'; -import { MetricExpressionParams } from '../../../../plugins/infra/server/lib/alerting/metric_threshold/types'; - import { FtrProviderContext } from '../../ftr_provider_context'; + export default function ({ getService }: FtrProviderContext) { const client = getService('es'); const index = 'test-index'; From cd9e4cca617cb2663d09410d92866a7bebe9fc14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ester=20Mart=C3=AD=20Vilaseca?= Date: Wed, 26 Jan 2022 16:34:59 +0100 Subject: [PATCH 44/46] Add alpha param to render the new overview page (#123465) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/pages/overview/index.test.tsx | 40 ++++++++++++++++++- .../public/pages/overview/index.tsx | 3 +- .../observability/public/routes/index.tsx | 1 + 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/observability/public/pages/overview/index.test.tsx b/x-pack/plugins/observability/public/pages/overview/index.test.tsx index b37ed1d873ba7..682f979f44ec5 100644 --- a/x-pack/plugins/observability/public/pages/overview/index.test.tsx +++ b/x-pack/plugins/observability/public/pages/overview/index.test.tsx @@ -13,7 +13,7 @@ import { OverviewPage as OldOverviewPage } from './old_overview_page'; import { OverviewPage as NewOverviewPage } from './overview_page'; describe('Overview page', () => { - it('should render the old overview page when feature flag is disabled', () => { + it('should render the old overview page when feature flag is disabled and queryParams are empty', () => { const pluginContext = { config: { unsafe: { @@ -31,7 +31,7 @@ describe('Overview page', () => { expect(component.find(NewOverviewPage)).toHaveLength(0); }); - it('should render the new overview page when feature flag is enabled', () => { + it('should render the new overview page when feature flag is enabled and queryParams are empty', () => { const pluginContext = { config: { unsafe: { @@ -48,4 +48,40 @@ describe('Overview page', () => { expect(component.find(OldOverviewPage)).toHaveLength(0); expect(component.find(NewOverviewPage)).toHaveLength(1); }); + + it('should render the new overview page when feature flag is enabled and alpha param is in the url', () => { + const pluginContext = { + config: { + unsafe: { + overviewNext: { enabled: true }, + }, + }, + }; + + jest + .spyOn(PluginContext, 'usePluginContext') + .mockReturnValue(pluginContext as PluginContextValue); + + const component = shallow(); + expect(component.find(OldOverviewPage)).toHaveLength(0); + expect(component.find(NewOverviewPage)).toHaveLength(1); + }); + + it('should render the new overview page when feature flag is disabled and alpha param is in the url', () => { + const pluginContext = { + config: { + unsafe: { + overviewNext: { enabled: false }, + }, + }, + }; + + jest + .spyOn(PluginContext, 'usePluginContext') + .mockReturnValue(pluginContext as PluginContextValue); + + const component = shallow(); + expect(component.find(OldOverviewPage)).toHaveLength(0); + expect(component.find(NewOverviewPage)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/observability/public/pages/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx index cc38445e3a0f2..6b773b303031e 100644 --- a/x-pack/plugins/observability/public/pages/overview/index.tsx +++ b/x-pack/plugins/observability/public/pages/overview/index.tsx @@ -18,8 +18,9 @@ interface Props { export function OverviewPage(props: Props) { const { config } = usePluginContext(); + const alpha = props.routeParams.query.alpha; - if (config.unsafe.overviewNext.enabled) { + if (config.unsafe.overviewNext.enabled || alpha) { return ; } else { return ; diff --git a/x-pack/plugins/observability/public/routes/index.tsx b/x-pack/plugins/observability/public/routes/index.tsx index 6f38a66cdb643..5f85ccd3af7b7 100644 --- a/x-pack/plugins/observability/public/routes/index.tsx +++ b/x-pack/plugins/observability/public/routes/index.tsx @@ -52,6 +52,7 @@ export const routes = { rangeTo: t.string, refreshPaused: jsonRt.pipe(t.boolean), refreshInterval: jsonRt.pipe(t.number), + alpha: jsonRt.pipe(t.boolean), }), }, exact: true, From 740ce6c7aa4c7052e8f609953b6edf04d85152d8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 26 Jan 2022 09:37:47 -0600 Subject: [PATCH 45/46] Update dependency chromedriver to ^97.0.2 (#123788) Co-authored-by: Renovate Bot --- package.json | 2 +- yarn.lock | 32 ++++++++++---------------------- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index 3c1284644a1ad..69bf4f62918aa 100644 --- a/package.json +++ b/package.json @@ -722,7 +722,7 @@ "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "chromedriver": "^97.0.0", + "chromedriver": "^97.0.2", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", "compression-webpack-plugin": "^4.0.0", diff --git a/yarn.lock b/yarn.lock index b1def198f8042..50a4bdea73c76 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4888,10 +4888,10 @@ dependencies: defer-to-connect "^2.0.0" -"@testim/chrome-version@^1.0.7": - version "1.0.7" - resolved "https://registry.yarnpkg.com/@testim/chrome-version/-/chrome-version-1.0.7.tgz#0cd915785ec4190f08a3a6acc9b61fc38fb5f1a9" - integrity sha512-8UT/J+xqCYfn3fKtOznAibsHpiuDshCb0fwgWxRazTT19Igp9ovoXMPhXyLD6m3CKQGTMHgqoxaFfMWaL40Rnw== +"@testim/chrome-version@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@testim/chrome-version/-/chrome-version-1.1.2.tgz#092005c5b77bd3bb6576a4677110a11485e11864" + integrity sha512-1c4ZOETSRpI0iBfIFUqU4KqwBAB2lHUAlBjZz/YqOHqwM9dTTzjV6Km0ZkiEiSCx/tLr1BtESIKyWWMww+RUqw== "@testing-library/dom@^7.28.1", "@testing-library/dom@^7.30.3": version "7.30.3" @@ -8252,13 +8252,6 @@ axios@^0.21.1: dependencies: follow-redirects "^1.10.0" -axios@^0.21.2: - version "0.21.4" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" - integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg== - dependencies: - follow-redirects "^1.14.0" - axios@^0.24.0: version "0.24.0" resolved "https://registry.yarnpkg.com/axios/-/axios-0.24.0.tgz#804e6fa1e4b9c5288501dd9dff56a7a0940d20d6" @@ -9724,13 +9717,13 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -chromedriver@^97.0.0: - version "97.0.0" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-97.0.0.tgz#7005b1a15a6456558d0fc4d5b72c98c12d1b033d" - integrity sha512-SZ9MW+/6/Ypz20CNdRKocsmRM2AJ/YwHaWpA1Np2QVPFUbhjhus6vBtqFD+l8M5qrktLWPQSjTwIsDckNfXIRg== +chromedriver@^97.0.2: + version "97.0.2" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-97.0.2.tgz#b6c26f6667ad40dc8cf08818878cc064787116fc" + integrity sha512-sOAfKCR3WsHvmKedZoWa+3tBVGdPtxq4zKxgKZCoJ2c924olBTW4Bnha6SHl93Yo7+QqsNn6ZpAC0ojhutacAg== dependencies: - "@testim/chrome-version" "^1.0.7" - axios "^0.21.2" + "@testim/chrome-version" "^1.1.2" + axios "^0.24.0" del "^6.0.0" extract-zip "^2.0.1" https-proxy-agent "^5.0.0" @@ -14320,11 +14313,6 @@ follow-redirects@^1.0.0, follow-redirects@^1.10.0: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA== -follow-redirects@^1.14.0: - version "1.14.3" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.3.tgz#6ada78118d8d24caee595595accdc0ac6abd022e" - integrity sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw== - follow-redirects@^1.14.4: version "1.14.6" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.6.tgz#8cfb281bbc035b3c067d6cd975b0f6ade6e855cd" From 83fee75692ab2ed0d2e6c7ef418d48ce6a6faeaf Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Wed, 26 Jan 2022 10:51:32 -0500 Subject: [PATCH 46/46] [Alerting] Telemetry for long-running/cancelled rules (#123291) * Renaming alerting telemetry files * Adding daily counts for execution timeouts * Threading in usageCounter * Adding usage counter for alerts after cancellation * Updating telemetry mappings * Adding tests * Adding tests * Cleanup * Cleanup * Adding rule type id to counter name * Adding new siem rule types * Replacing all dots with underscores Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/alerting/server/plugin.ts | 12 +- .../server/task_runner/task_runner.test.ts | 37 +++ .../server/task_runner/task_runner.ts | 22 ++ .../task_runner/task_runner_cancel.test.ts | 20 ++ .../task_runner/task_runner_factory.test.ts | 5 +- .../server/task_runner/task_runner_factory.ts | 2 + ...try.test.ts => alerting_telemetry.test.ts} | 73 +++-- ...rts_telemetry.ts => alerting_telemetry.ts} | 181 ++++++++---- ...st.ts => alerting_usage_collector.test.ts} | 8 +- ...llector.ts => alerting_usage_collector.ts} | 30 +- x-pack/plugins/alerting/server/usage/index.ts | 2 +- x-pack/plugins/alerting/server/usage/task.ts | 58 ++-- x-pack/plugins/alerting/server/usage/types.ts | 4 +- .../schema/xpack_plugins.json | 268 ++++++++++++++++++ 14 files changed, 601 insertions(+), 121 deletions(-) rename x-pack/plugins/alerting/server/usage/{alerts_telemetry.test.ts => alerting_telemetry.test.ts} (71%) rename x-pack/plugins/alerting/server/usage/{alerts_telemetry.ts => alerting_telemetry.ts} (77%) rename x-pack/plugins/alerting/server/usage/{alerts_usage_collector.test.ts => alerting_usage_collector.test.ts} (86%) rename x-pack/plugins/alerting/server/usage/{alerts_usage_collector.ts => alerting_usage_collector.ts} (82%) diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 0273bda209b7c..70aad0d6921e1 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -8,7 +8,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { first } from 'rxjs/operators'; import { BehaviorSubject } from 'rxjs'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { UsageCollectionSetup, UsageCounter } from 'src/plugins/usage_collection/server'; import { SecurityPluginSetup, SecurityPluginStart } from '../../security/server'; import { EncryptedSavedObjectsPluginSetup, @@ -51,7 +51,7 @@ import { AlertTypeState, Services, } from './types'; -import { registerAlertsUsageCollector } from './usage'; +import { registerAlertingUsageCollector } from './usage'; import { initializeAlertingTelemetry, scheduleAlertingTelemetry } from './usage/task'; import { IEventLogger, IEventLogService, IEventLogClientService } from '../../event_log/server'; import { PluginStartContract as FeaturesPluginStart } from '../../features/server'; @@ -153,6 +153,7 @@ export class AlertingPlugin { private eventLogService?: IEventLogService; private eventLogger?: IEventLogger; private kibanaBaseUrl: string | undefined; + private usageCounter: UsageCounter | undefined; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.create().pipe(first()).toPromise(); @@ -208,7 +209,7 @@ export class AlertingPlugin { const usageCollection = plugins.usageCollection; if (usageCollection) { - registerAlertsUsageCollector( + registerAlertingUsageCollector( usageCollection, core.getStartServices().then(([_, { taskManager }]) => taskManager) ); @@ -223,7 +224,7 @@ export class AlertingPlugin { } // Usage counter for telemetry - const usageCounter = plugins.usageCollection?.createUsageCounter(ALERTS_FEATURE_ID); + this.usageCounter = plugins.usageCollection?.createUsageCounter(ALERTS_FEATURE_ID); setupSavedObjects( core.savedObjects, @@ -259,7 +260,7 @@ export class AlertingPlugin { defineRoutes({ router, licenseState: this.licenseState, - usageCounter, + usageCounter: this.usageCounter, encryptedSavedObjects: plugins.encryptedSavedObjects, }); @@ -393,6 +394,7 @@ export class AlertingPlugin { supportsEphemeralTasks: plugins.taskManager.supportsEphemeralTasks(), maxEphemeralActionsPerRule: config.maxEphemeralActionsPerAlert, cancelAlertsOnRuleTimeout: config.cancelAlertsOnRuleTimeout, + usageCounter: this.usageCounter, }); }); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index a466583cd3bd3..18598ab385552 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -7,6 +7,7 @@ import sinon from 'sinon'; import { schema } from '@kbn/config-schema'; +import { usageCountersServiceMock } from 'src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock'; import { AlertExecutorOptions, AlertTypeParams, @@ -59,6 +60,9 @@ const ruleType: jest.Mocked = { let fakeTimer: sinon.SinonFakeTimers; +const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); +const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + describe('Task Runner', () => { let mockedTaskInstance: ConcreteTaskInstance; @@ -113,6 +117,7 @@ describe('Task Runner', () => { supportsEphemeralTasks: false, maxEphemeralActionsPerRule: 10, cancelAlertsOnRuleTimeout: true, + usageCounter: mockUsageCounter, }; function testAgainstEphemeralSupport( @@ -397,6 +402,7 @@ describe('Task Runner', () => { }, expect.any(Function) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); testAgainstEphemeralSupport( @@ -683,6 +689,7 @@ describe('Task Runner', () => { ruleset: 'alerts', }, }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } ); @@ -899,6 +906,7 @@ describe('Task Runner', () => { ruleset: 'alerts', }, }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); testAgainstEphemeralSupport( @@ -965,6 +973,7 @@ describe('Task Runner', () => { 4, 'ruleExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } ); @@ -1157,6 +1166,7 @@ describe('Task Runner', () => { ], ] `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); testAgainstEphemeralSupport( @@ -1218,6 +1228,7 @@ describe('Task Runner', () => { }); await taskRunner.run(); expect(enqueueFunction).toHaveBeenCalledTimes(1); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } ); @@ -1287,6 +1298,7 @@ describe('Task Runner', () => { }); await taskRunner.run(); expect(enqueueFunction).toHaveBeenCalledTimes(1); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } ); @@ -1607,6 +1619,7 @@ describe('Task Runner', () => { ], ] `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } ); @@ -2013,6 +2026,7 @@ describe('Task Runner', () => { }, ] `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } ); @@ -2112,6 +2126,7 @@ describe('Task Runner', () => { expect(enqueueFunction).toHaveBeenCalledTimes(2); expect((enqueueFunction as jest.Mock).mock.calls[1][0].id).toEqual('1'); expect((enqueueFunction as jest.Mock).mock.calls[0][0].id).toEqual('2'); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } ); @@ -2246,6 +2261,7 @@ describe('Task Runner', () => { }, ] `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } ); @@ -2501,6 +2517,7 @@ describe('Task Runner', () => { ], ] `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('validates params before executing the alert type', async () => { @@ -2557,6 +2574,7 @@ describe('Task Runner', () => { expect(taskRunnerFactoryInitializerParams.logger.error).toHaveBeenCalledWith( `Executing Rule foo:test:1 has resulted in Error: params invalid: [param1]: expected value of type [string] but got [undefined]` ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('uses API key when provided', async () => { @@ -2591,6 +2609,7 @@ describe('Task Runner', () => { request, '/' ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test(`doesn't use API key when not provided`, async () => { @@ -2623,6 +2642,7 @@ describe('Task Runner', () => { request, '/' ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('rescheduled the Alert if the schedule has update during a task run', async () => { @@ -2673,6 +2693,7 @@ describe('Task Runner', () => { }, } `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('recovers gracefully when the RuleType executor throws an exception', async () => { @@ -2826,6 +2847,7 @@ describe('Task Runner', () => { ], ] `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('recovers gracefully when the Alert Task Runner throws an exception when fetching the encrypted attributes', async () => { @@ -2960,6 +2982,7 @@ describe('Task Runner', () => { ], ] `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('recovers gracefully when the Alert Task Runner throws an exception when license is higher than supported', async () => { @@ -3103,6 +3126,7 @@ describe('Task Runner', () => { ], ] `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('recovers gracefully when the Alert Task Runner throws an exception when getting internal Services', async () => { @@ -3246,6 +3270,7 @@ describe('Task Runner', () => { ], ] `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('recovers gracefully when the Alert Task Runner throws an exception when fetching attributes', async () => { @@ -3388,6 +3413,7 @@ describe('Task Runner', () => { ], ] `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('recovers gracefully when the Runner of a legacy Alert task which has no schedule throws an exception when fetching attributes', async () => { @@ -3438,6 +3464,7 @@ describe('Task Runner', () => { "state": Object {}, } `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test(`doesn't change previousStartedAt when it fails to run`, async () => { @@ -3484,6 +3511,7 @@ describe('Task Runner', () => { expect(runnerResult.state.previousStartedAt).toEqual( new Date(originalAlertSate.previousStartedAt) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('avoids rescheduling a failed Alert Task Runner when it throws due to failing to fetch the alert', async () => { @@ -3525,6 +3553,7 @@ describe('Task Runner', () => { `Unable to execute rule "1" in the "foo" space because Saved object [alert/1] not found - this rule will not be rescheduled. To restart rule execution, try disabling and re-enabling this rule.` ); expect(isUnrecoverableError(ex)).toBeTruthy(); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); }); @@ -3566,6 +3595,7 @@ describe('Task Runner', () => { 1, `Unable to execute rule "1" in the "test space" space because Saved object [alert/1] not found - this rule will not be rescheduled. To restart rule execution, try disabling and re-enabling this rule.` ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); }); @@ -3877,6 +3907,7 @@ describe('Task Runner', () => { ], ] `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('duration is updated for active alerts when alert state contains start time', async () => { @@ -4118,6 +4149,7 @@ describe('Task Runner', () => { ], ] `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('duration is not calculated for active alerts when alert state does not contain start time', async () => { @@ -4347,6 +4379,7 @@ describe('Task Runner', () => { ], ] `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('end is logged for active alerts when alert state contains start time and alert recovers', async () => { @@ -4575,6 +4608,7 @@ describe('Task Runner', () => { ], ] `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('end calculation is skipped for active alerts when alert state does not contain start time and alert recovers', async () => { @@ -4799,6 +4833,7 @@ describe('Task Runner', () => { ], ] `); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('successfully executes the task with ephemeral tasks enabled', async () => { @@ -4989,6 +5024,7 @@ describe('Task Runner', () => { }, { refresh: false, namespace: undefined } ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('successfully bails on execution if the rule is disabled', async () => { @@ -5083,6 +5119,7 @@ describe('Task Runner', () => { }, message: 'test:1: execution failed', }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('successfully stores successful runs', async () => { diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index 9640dd9038ce7..ba1b574ab749f 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -8,6 +8,7 @@ import apm from 'elastic-apm-node'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { Dictionary, pickBy, mapValues, without, cloneDeep } from 'lodash'; import type { Request } from '@hapi/hapi'; +import { UsageCounter } from 'src/plugins/usage_collection/server'; import uuid from 'uuid'; import { addSpaceIdToPath } from '../../../spaces/server'; import { Logger, KibanaRequest } from '../../../../../src/core/server'; @@ -109,6 +110,7 @@ export class TaskRunner< >; private readonly executionId: string; private readonly ruleTypeRegistry: RuleTypeRegistry; + private usageCounter?: UsageCounter; private searchAbortController: AbortController; private cancelled: boolean; @@ -127,6 +129,7 @@ export class TaskRunner< ) { this.context = context; this.logger = context.logger; + this.usageCounter = context.usageCounter; this.ruleType = ruleType; this.ruleName = null; this.taskInstance = taskInstanceToAlertTaskInstance(taskInstance); @@ -256,6 +259,18 @@ export class TaskRunner< return !this.context.cancelAlertsOnRuleTimeout || !this.ruleType.cancelAlertsOnRuleTimeout; } + private countUsageOfActionExecutionAfterRuleCancellation() { + if (this.cancelled && this.usageCounter) { + if (this.context.cancelAlertsOnRuleTimeout && this.ruleType.cancelAlertsOnRuleTimeout) { + // Increment usage counter for skipped actions + this.usageCounter.incrementCounter({ + counterName: `alertsSkippedDueToRuleExecutionTimeout_${this.ruleType.id}`, + incrementBy: 1, + }); + } + } + } + async executeAlert( alertId: string, alert: AlertInstance, @@ -378,6 +393,7 @@ export class TaskRunner< event.error.message = err.message; event.event = event.event || {}; event.event.outcome = 'failure'; + throw new ErrorWithReason(AlertExecutionStatusErrorReasons.Execute, err); } @@ -483,6 +499,12 @@ export class TaskRunner< this.logger.debug( `no scheduling of actions for rule ${ruleLabel}: rule execution has been cancelled.` ); + // Usage counter for telemetry + // This keeps track of how many times action executions were skipped after rule + // execution completed successfully after the execution timeout + // This can occur when rule executors do not short circuit execution in response + // to timeout + this.countUsageOfActionExecutionAfterRuleCancellation(); } } diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts index e24be639c7fcc..f071af53bc10e 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts @@ -6,6 +6,7 @@ */ import sinon from 'sinon'; +import { usageCountersServiceMock } from 'src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock'; import { AlertExecutorOptions, AlertTypeParams, @@ -52,6 +53,9 @@ const ruleType: jest.Mocked = { let fakeTimer: sinon.SinonFakeTimers; +const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); +const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + describe('Task Runner Cancel', () => { let mockedTaskInstance: ConcreteTaskInstance; @@ -106,6 +110,7 @@ describe('Task Runner Cancel', () => { supportsEphemeralTasks: false, maxEphemeralActionsPerRule: 10, cancelAlertsOnRuleTimeout: true, + usageCounter: mockUsageCounter, }; const mockDate = new Date('2019-02-12T21:01:22.479Z'); @@ -333,6 +338,11 @@ describe('Task Runner Cancel', () => { }, { refresh: false, namespace: undefined } ); + expect(mockUsageCounter.incrementCounter).toHaveBeenCalledTimes(1); + expect(mockUsageCounter.incrementCounter).toHaveBeenCalledWith({ + counterName: 'alertsSkippedDueToRuleExecutionTimeout_test', + incrementBy: 1, + }); }); test('actionsPlugin.execute is called if rule execution is cancelled but cancelAlertsOnRuleTimeout from config is false', async () => { @@ -361,6 +371,8 @@ describe('Task Runner Cancel', () => { await promise; testActionsExecute(); + + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('actionsPlugin.execute is called if rule execution is cancelled but cancelAlertsOnRuleTimeout for ruleType is false', async () => { @@ -397,6 +409,8 @@ describe('Task Runner Cancel', () => { await promise; testActionsExecute(); + + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('actionsPlugin.execute is skipped if rule execution is cancelled and cancelAlertsOnRuleTimeout for both config and ruleType are true', async () => { @@ -563,6 +577,12 @@ describe('Task Runner Cancel', () => { ruleset: 'alerts', }, }); + + expect(mockUsageCounter.incrementCounter).toHaveBeenCalledTimes(1); + expect(mockUsageCounter.incrementCounter).toHaveBeenCalledWith({ + counterName: 'alertsSkippedDueToRuleExecutionTimeout_test', + incrementBy: 1, + }); }); function testActionsExecute() { diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts index 038eecda349a1..2e321f7354458 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -6,6 +6,7 @@ */ import sinon from 'sinon'; +import { usageCountersServiceMock } from 'src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock'; import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager/server'; import { TaskRunnerContext, TaskRunnerFactory } from './task_runner_factory'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; @@ -22,7 +23,8 @@ import { ruleTypeRegistryMock } from '../rule_type_registry.mock'; import { executionContextServiceMock } from '../../../../../src/core/server/mocks'; const executionContext = executionContextServiceMock.createSetupContract(); - +const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); +const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); const ruleType: UntypedNormalizedRuleType = { id: 'test', name: 'My test alert', @@ -86,6 +88,7 @@ describe('Task Runner Factory', () => { maxEphemeralActionsPerRule: 10, cancelAlertsOnRuleTimeout: true, executionContext, + usageCounter: mockUsageCounter, }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts index 69c8ff471c8bb..f410e55eaab1c 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -6,6 +6,7 @@ */ import type { PublicMethodsOf } from '@kbn/utility-types'; +import { UsageCounter } from 'src/plugins/usage_collection/server'; import type { Logger, KibanaRequest, @@ -46,6 +47,7 @@ export interface TaskRunnerContext { supportsEphemeralTasks: boolean; maxEphemeralActionsPerRule: number; cancelAlertsOnRuleTimeout: boolean; + usageCounter?: UsageCounter; } export class TaskRunnerFactory { diff --git a/x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts b/x-pack/plugins/alerting/server/usage/alerting_telemetry.test.ts similarity index 71% rename from x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts rename to x-pack/plugins/alerting/server/usage/alerting_telemetry.test.ts index 848c5e9b72168..2ed7ca2d02c5d 100644 --- a/x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts +++ b/x-pack/plugins/alerting/server/usage/alerting_telemetry.test.ts @@ -5,22 +5,25 @@ * 2.0. */ +/* eslint-disable @typescript-eslint/naming-convention */ + // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from '../../../../../src/core/server/elasticsearch/client/mocks'; import { getTotalCountAggregations, getTotalCountInUse, getExecutionsPerDayCount, -} from './alerts_telemetry'; + getExecutionTimeoutsPerDayCount, +} from './alerting_telemetry'; -describe('alerts telemetry', () => { - test('getTotalCountInUse should replace first "." symbol to "__" in alert types names', async () => { +describe('alerting telemetry', () => { + test('getTotalCountInUse should replace "." symbols with "__" in rule types names', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; mockEsClient.search.mockReturnValue( // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { - byAlertTypeId: { + byRuleTypeId: { value: { ruleTypes: { '.index-threshold': 2, @@ -47,8 +50,8 @@ describe('alerts telemetry', () => { Object { "countByType": Object { "__index-threshold": 2, - "document.test__": 1, - "logs.alert.document.count": 1, + "document__test__": 1, + "logs__alert__document__count": 1, }, "countNamespaces": 1, "countTotal": 4, @@ -62,7 +65,7 @@ Object { // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { - byAlertTypeId: { + byRuleTypeId: { value: { ruleTypes: { '.index-threshold': 2, @@ -100,8 +103,8 @@ Object { }, "count_by_type": Object { "__index-threshold": 2, - "document.test__": 1, - "logs.alert.document.count": 1, + "document__test__": 1, + "logs__alert__document__count": 1, }, "count_rules_namespaces": 0, "count_total": 4, @@ -129,7 +132,7 @@ Object { `); }); - test('getTotalExecutionsCount should return execution aggregations for total count, count by rule type and number of failed executions', async () => { + test('getExecutionsPerDayCount should return execution aggregations for total count, count by rule type and number of failed executions', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; mockEsClient.search.mockReturnValue( // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values @@ -176,26 +179,62 @@ Object { avgExecutionTime: 0, avgExecutionTimeByType: { '__index-threshold': 1043934, - 'document.test__': 17687687, - 'logs.alert.document.count': 1675765, + document__test__: 17687687, + logs__alert__document__count: 1675765, }, countByType: { '__index-threshold': 2, - 'document.test__': 1, - 'logs.alert.document.count': 1, + document__test__: 1, + logs__alert__document__count: 1, }, countFailuresByReason: { unknown: 4, }, countFailuresByReasonByType: { unknown: { - '.index-threshold': 2, - 'document.test.': 1, - 'logs.alert.document.count': 1, + '__index-threshold': 2, + document__test__: 1, + logs__alert__document__count: 1, }, }, countTotal: 4, countTotalFailures: 4, }); }); + + test('getExecutionTimeoutsPerDayCount should return execution aggregations for total timeout count and count by rule type', async () => { + const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; + mockEsClient.search.mockReturnValue( + // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values + elasticsearchClientMock.createSuccessTransportRequestPromise({ + aggregations: { + byRuleTypeId: { + value: { + ruleTypes: { + '.index-threshold': 2, + 'logs.alert.document.count': 1, + 'document.test.': 1, + }, + }, + }, + }, + hits: { + hits: [], + }, + }) + ); + + const telemetry = await getExecutionTimeoutsPerDayCount(mockEsClient, 'test'); + + expect(mockEsClient.search).toHaveBeenCalledTimes(1); + + expect(telemetry).toStrictEqual({ + countTotal: 4, + countByType: { + '__index-threshold': 2, + document__test__: 1, + logs__alert__document__count: 1, + }, + }); + }); }); diff --git a/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts b/x-pack/plugins/alerting/server/usage/alerting_telemetry.ts similarity index 77% rename from x-pack/plugins/alerting/server/usage/alerts_telemetry.ts rename to x-pack/plugins/alerting/server/usage/alerting_telemetry.ts index 075404e82e1a9..b21e1d4b00ef1 100644 --- a/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts +++ b/x-pack/plugins/alerting/server/usage/alerting_telemetry.ts @@ -6,15 +6,15 @@ */ import { ElasticsearchClient } from 'kibana/server'; -import { AlertsUsage } from './types'; +import { AlertingUsage } from './types'; -const alertTypeMetric = { +const ruleTypeMetric = { scripted_metric: { init_script: 'state.ruleTypes = [:]; state.namespaces = [:]', map_script: ` - String alertType = doc['alert.alertTypeId'].value; + String ruleType = doc['alert.alertTypeId'].value; String namespace = doc['namespaces'] !== null && doc['namespaces'].size() > 0 ? doc['namespaces'].value : 'default'; - state.ruleTypes.put(alertType, state.ruleTypes.containsKey(alertType) ? state.ruleTypes.get(alertType) + 1 : 1); + state.ruleTypes.put(ruleType, state.ruleTypes.containsKey(ruleType) ? state.ruleTypes.get(ruleType) + 1 : 1); if (state.namespaces.containsKey(namespace) === false) { state.namespaces.put(namespace, 1); } @@ -38,7 +38,7 @@ const alertTypeMetric = { }, }; -const ruleTypeExecutionsMetric = { +const ruleTypeExecutionsWithDurationMetric = { scripted_metric: { init_script: 'state.ruleTypes = [:]; state.ruleTypesDuration = [:];', map_script: ` @@ -66,6 +66,32 @@ const ruleTypeExecutionsMetric = { }, }; +const ruleTypeExecutionsMetric = { + scripted_metric: { + init_script: 'state.ruleTypes = [:]', + map_script: ` + String ruleType = doc['rule.category'].value; + state.ruleTypes.put(ruleType, state.ruleTypes.containsKey(ruleType) ? state.ruleTypes.get(ruleType) + 1 : 1); + `, + // Combine script is executed per cluster, but we already have a key-value pair per cluster. + // Despite docs that say this is optional, this script can't be blank. + combine_script: 'return state', + // Reduce script is executed across all clusters, so we need to add up all the total from each cluster + // This also needs to account for having no data + reduce_script: ` + Map result = [:]; + for (Map m : states.toArray()) { + if (m !== null) { + for (String k : m.keySet()) { + result.put(k, result.containsKey(k) ? result.get(k) + m.get(k) : m.get(k)); + } + } + } + return result; + `, + }, +}; + const ruleTypeFailureExecutionsMetric = { scripted_metric: { init_script: 'state.reasons = [:]', @@ -99,10 +125,10 @@ const ruleTypeFailureExecutionsMetric = { export async function getTotalCountAggregations( esClient: ElasticsearchClient, - kibanaInex: string + kibanaIndex: string ): Promise< Pick< - AlertsUsage, + AlertingUsage, | 'count_total' | 'count_by_type' | 'throttle_time' @@ -114,7 +140,7 @@ export async function getTotalCountAggregations( > > { const { body: results } = await esClient.search({ - index: kibanaInex, + index: kibanaIndex, body: { size: 0, query: { @@ -210,7 +236,7 @@ export async function getTotalCountAggregations( }, }, aggs: { - byAlertTypeId: alertTypeMetric, + byRuleTypeId: ruleTypeMetric, max_throttle_time: { max: { field: 'alert_throttle' } }, min_throttle_time: { min: { field: 'alert_throttle' } }, avg_throttle_time: { avg: { field: 'alert_throttle' } }, @@ -225,7 +251,7 @@ export async function getTotalCountAggregations( }); const aggregations = results.aggregations as { - byAlertTypeId: { value: { ruleTypes: Record } }; + byRuleTypeId: { value: { ruleTypes: Record } }; max_throttle_time: { value: number }; min_throttle_time: { value: number }; avg_throttle_time: { value: number }; @@ -237,23 +263,15 @@ export async function getTotalCountAggregations( avg_actions_count: { value: number }; }; - const totalAlertsCount = Object.keys(aggregations.byAlertTypeId.value.ruleTypes).reduce( + const totalRulesCount = Object.keys(aggregations.byRuleTypeId.value.ruleTypes).reduce( (total: number, key: string) => - parseInt(aggregations.byAlertTypeId.value.ruleTypes[key], 10) + total, + parseInt(aggregations.byRuleTypeId.value.ruleTypes[key], 10) + total, 0 ); return { - count_total: totalAlertsCount, - count_by_type: Object.keys(aggregations.byAlertTypeId.value.ruleTypes).reduce( - // ES DSL aggregations are returned as `any` by esClient.search - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (obj: any, key: string) => ({ - ...obj, - [replaceFirstAndLastDotSymbols(key)]: aggregations.byAlertTypeId.value.ruleTypes[key], - }), - {} - ), + count_total: totalRulesCount, + count_by_type: replaceDotSymbolsInRuleTypeIds(aggregations.byRuleTypeId.value.ruleTypes), throttle_time: { min: `${aggregations.min_throttle_time.value}s`, avg: `${aggregations.avg_throttle_time.value}s`, @@ -283,9 +301,9 @@ export async function getTotalCountAggregations( }; } -export async function getTotalCountInUse(esClient: ElasticsearchClient, kibanaInex: string) { +export async function getTotalCountInUse(esClient: ElasticsearchClient, kibanaIndex: string) { const { body: searchResult } = await esClient.search({ - index: kibanaInex, + index: kibanaIndex, size: 0, body: { query: { @@ -294,43 +312,28 @@ export async function getTotalCountInUse(esClient: ElasticsearchClient, kibanaIn }, }, aggs: { - byAlertTypeId: alertTypeMetric, + byRuleTypeId: ruleTypeMetric, }, }, }); const aggregations = searchResult.aggregations as { - byAlertTypeId: { + byRuleTypeId: { value: { ruleTypes: Record; namespaces: Record }; }; }; return { - countTotal: Object.keys(aggregations.byAlertTypeId.value.ruleTypes).reduce( + countTotal: Object.keys(aggregations.byRuleTypeId.value.ruleTypes).reduce( (total: number, key: string) => - parseInt(aggregations.byAlertTypeId.value.ruleTypes[key], 10) + total, + parseInt(aggregations.byRuleTypeId.value.ruleTypes[key], 10) + total, 0 ), - countByType: Object.keys(aggregations.byAlertTypeId.value.ruleTypes).reduce( - // ES DSL aggregations are returned as `any` by esClient.search - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (obj: any, key: string) => ({ - ...obj, - [replaceFirstAndLastDotSymbols(key)]: aggregations.byAlertTypeId.value.ruleTypes[key], - }), - {} - ), - countNamespaces: Object.keys(aggregations.byAlertTypeId.value.namespaces).length, + countByType: replaceDotSymbolsInRuleTypeIds(aggregations.byRuleTypeId.value.ruleTypes), + countNamespaces: Object.keys(aggregations.byRuleTypeId.value.namespaces).length, }; } -function replaceFirstAndLastDotSymbols(strToReplace: string) { - const hasFirstSymbolDot = strToReplace.startsWith('.'); - const appliedString = hasFirstSymbolDot ? strToReplace.replace('.', '__') : strToReplace; - const hasLastSymbolDot = strToReplace.endsWith('.'); - return hasLastSymbolDot ? `${appliedString.slice(0, -1)}__` : appliedString; -} - export async function getExecutionsPerDayCount( esClient: ElasticsearchClient, eventLogIndex: string @@ -363,7 +366,7 @@ export async function getExecutionsPerDayCount( }, }, aggs: { - byRuleTypeId: ruleTypeExecutionsMetric, + byRuleTypeId: ruleTypeExecutionsWithDurationMetric, failuresByReason: ruleTypeFailureExecutionsMetric, avgDuration: { avg: { field: 'event.duration' } }, }, @@ -392,15 +395,8 @@ export async function getExecutionsPerDayCount( parseInt(executionsAggregations.byRuleTypeId.value.ruleTypes[key], 10) + total, 0 ), - countByType: Object.keys(executionsAggregations.byRuleTypeId.value.ruleTypes).reduce( - // ES DSL aggregations are returned as `any` by esClient.search - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (obj: any, key: string) => ({ - ...obj, - [replaceFirstAndLastDotSymbols(key)]: - executionsAggregations.byRuleTypeId.value.ruleTypes[key], - }), - {} + countByType: replaceDotSymbolsInRuleTypeIds( + executionsAggregations.byRuleTypeId.value.ruleTypes ), countTotalFailures: Object.keys( executionFailuresAggregations.failuresByReason.value.reasons @@ -426,7 +422,7 @@ export async function getExecutionsPerDayCount( ); return { ...obj, - [replaceFirstAndLastDotSymbols(reason)]: countByRuleTypes, + [replaceDotSymbols(reason)]: countByRuleTypes, }; }, {} @@ -438,8 +434,9 @@ export async function getExecutionsPerDayCount( // eslint-disable-next-line @typescript-eslint/no-explicit-any (obj: any, key: string) => ({ ...obj, - [replaceFirstAndLastDotSymbols(key)]: - executionFailuresAggregations.failuresByReason.value.reasons[key], + [key]: replaceDotSymbolsInRuleTypeIds( + executionFailuresAggregations.failuresByReason.value.reasons[key] + ), }), {} ), @@ -449,7 +446,7 @@ export async function getExecutionsPerDayCount( // eslint-disable-next-line @typescript-eslint/no-explicit-any (obj: any, key: string) => ({ ...obj, - [replaceFirstAndLastDotSymbols(key)]: Math.round( + [replaceDotSymbols(key)]: Math.round( executionsAggregations.byRuleTypeId.value.ruleTypesDuration[key] / parseInt(executionsAggregations.byRuleTypeId.value.ruleTypes[key], 10) ), @@ -458,3 +455,69 @@ export async function getExecutionsPerDayCount( ), }; } + +export async function getExecutionTimeoutsPerDayCount( + esClient: ElasticsearchClient, + eventLogIndex: string +) { + const { body: searchResult } = await esClient.search({ + index: eventLogIndex, + size: 0, + body: { + query: { + bool: { + filter: { + bool: { + must: [ + { + term: { 'event.action': 'execute-timeout' }, + }, + { + term: { 'event.provider': 'alerting' }, + }, + { + range: { + '@timestamp': { + gte: 'now-1d', + }, + }, + }, + ], + }, + }, + }, + }, + aggs: { + byRuleTypeId: ruleTypeExecutionsMetric, + }, + }, + }); + + const executionsAggregations = searchResult.aggregations as { + byRuleTypeId: { + value: { ruleTypes: Record; ruleTypesDuration: Record }; + }; + }; + + return { + countTotal: Object.keys(executionsAggregations.byRuleTypeId.value.ruleTypes).reduce( + (total: number, key: string) => + parseInt(executionsAggregations.byRuleTypeId.value.ruleTypes[key], 10) + total, + 0 + ), + countByType: replaceDotSymbolsInRuleTypeIds( + executionsAggregations.byRuleTypeId.value.ruleTypes + ), + }; +} + +function replaceDotSymbols(strToReplace: string) { + return strToReplace.replaceAll('.', '__'); +} + +function replaceDotSymbolsInRuleTypeIds(ruleTypeIdObj: Record) { + return Object.keys(ruleTypeIdObj).reduce( + (obj, key) => ({ ...obj, [replaceDotSymbols(key)]: ruleTypeIdObj[key] }), + {} + ); +} diff --git a/x-pack/plugins/alerting/server/usage/alerts_usage_collector.test.ts b/x-pack/plugins/alerting/server/usage/alerting_usage_collector.test.ts similarity index 86% rename from x-pack/plugins/alerting/server/usage/alerts_usage_collector.test.ts rename to x-pack/plugins/alerting/server/usage/alerting_usage_collector.test.ts index 9539f189c4fd6..e05086949b349 100644 --- a/x-pack/plugins/alerting/server/usage/alerts_usage_collector.test.ts +++ b/x-pack/plugins/alerting/server/usage/alerting_usage_collector.test.ts @@ -6,13 +6,13 @@ */ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { registerAlertsUsageCollector } from './alerts_usage_collector'; +import { registerAlertingUsageCollector } from './alerting_usage_collector'; import { taskManagerMock } from '../../../task_manager/server/mocks'; const taskManagerStart = taskManagerMock.createStart(); beforeEach(() => jest.resetAllMocks()); -describe('registerAlertsUsageCollector', () => { +describe('registerAlertingUsageCollector', () => { let usageCollectionMock: jest.Mocked; beforeEach(() => { @@ -23,7 +23,7 @@ describe('registerAlertsUsageCollector', () => { }); it('should call registerCollector', () => { - registerAlertsUsageCollector( + registerAlertingUsageCollector( usageCollectionMock as UsageCollectionSetup, new Promise(() => taskManagerStart) ); @@ -31,7 +31,7 @@ describe('registerAlertsUsageCollector', () => { }); it('should call makeUsageCollector with type = alerts', () => { - registerAlertsUsageCollector( + registerAlertingUsageCollector( usageCollectionMock as UsageCollectionSetup, new Promise(() => taskManagerStart) ); diff --git a/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts b/x-pack/plugins/alerting/server/usage/alerting_usage_collector.ts similarity index 82% rename from x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts rename to x-pack/plugins/alerting/server/usage/alerting_usage_collector.ts index 327073f26bacf..2e5b012cf3b47 100644 --- a/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts +++ b/x-pack/plugins/alerting/server/usage/alerting_usage_collector.ts @@ -8,12 +8,12 @@ import { MakeSchemaFrom, UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { get } from 'lodash'; import { TaskManagerStartContract } from '../../../task_manager/server'; -import { AlertsUsage } from './types'; +import { AlertingUsage } from './types'; -const byTypeSchema: MakeSchemaFrom['count_by_type'] = { +const byTypeSchema: MakeSchemaFrom['count_by_type'] = { // TODO: Find out an automated way to populate the keys or reformat these into an array (and change the Remote Telemetry indexer accordingly) DYNAMIC_KEY: { type: 'long' }, - // Known alerts (searching the use of the alerts API `registerType`: + // Known rule types (searching the use of the rules API `registerType`: // Built-in '__index-threshold': { type: 'long' }, '__es-query': { type: 'long' }, @@ -39,6 +39,12 @@ const byTypeSchema: MakeSchemaFrom['count_by_type'] = { // Security Solution siem__signals: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention siem__notifications: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention + siem__eqlRule: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention + siem__indicatorRule: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention + siem__mlRule: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention + siem__queryRule: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention + siem__savedQueryRule: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention + siem__thresholdRule: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention // Uptime xpack__uptime__alerts__monitorStatus: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention xpack__uptime__alerts__tls: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention @@ -50,7 +56,7 @@ const byTypeSchema: MakeSchemaFrom['count_by_type'] = { xpack__ml__anomaly_detection_jobs_health: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention }; -const byReasonSchema: MakeSchemaFrom['count_rules_executions_failured_by_reason_per_day'] = +const byReasonSchema: MakeSchemaFrom['count_rules_executions_failured_by_reason_per_day'] = { // TODO: Find out an automated way to populate the keys or reformat these into an array (and change the Remote Telemetry indexer accordingly) DYNAMIC_KEY: { type: 'long' }, @@ -60,7 +66,7 @@ const byReasonSchema: MakeSchemaFrom['count_rules_executions_failur unknown: { type: 'long' }, }; -const byReasonSchemaByType: MakeSchemaFrom['count_rules_executions_failured_by_reason_by_type_per_day'] = +const byReasonSchemaByType: MakeSchemaFrom['count_rules_executions_failured_by_reason_by_type_per_day'] = { // TODO: Find out an automated way to populate the keys or reformat these into an array (and change the Remote Telemetry indexer accordingly) DYNAMIC_KEY: byTypeSchema, @@ -70,11 +76,11 @@ const byReasonSchemaByType: MakeSchemaFrom['count_rules_executions_ unknown: byTypeSchema, }; -export function createAlertsUsageCollector( +export function createAlertingUsageCollector( usageCollection: UsageCollectionSetup, taskManager: Promise ) { - return usageCollection.makeUsageCollector({ + return usageCollection.makeUsageCollector({ type: 'alerts', isReady: async () => { await taskManager; @@ -84,7 +90,7 @@ export function createAlertsUsageCollector( try { const doc = await getLatestTaskState(await taskManager); // get the accumulated state from the recurring task - const { runs, ...state } = get(doc, 'state') as AlertsUsage & { runs: number }; + const { runs, ...state } = get(doc, 'state') as AlertingUsage & { runs: number }; return { ...state, @@ -127,6 +133,8 @@ export function createAlertsUsageCollector( count_rules_executions_failured_per_day: 0, count_rules_executions_failured_by_reason_per_day: {}, count_rules_executions_failured_by_reason_by_type_per_day: {}, + count_rules_executions_timeouts_per_day: 0, + count_rules_executions_timeouts_by_type_per_day: {}, avg_execution_time_per_day: 0, avg_execution_time_by_type_per_day: {}, }; @@ -169,6 +177,8 @@ export function createAlertsUsageCollector( count_rules_executions_failured_per_day: { type: 'long' }, count_rules_executions_failured_by_reason_per_day: byReasonSchema, count_rules_executions_failured_by_reason_by_type_per_day: byReasonSchemaByType, + count_rules_executions_timeouts_per_day: { type: 'long' }, + count_rules_executions_timeouts_by_type_per_day: byTypeSchema, avg_execution_time_per_day: { type: 'long' }, avg_execution_time_by_type_per_day: byTypeSchema, }, @@ -194,10 +204,10 @@ async function getLatestTaskState(taskManager: TaskManagerStartContract) { return null; } -export function registerAlertsUsageCollector( +export function registerAlertingUsageCollector( usageCollection: UsageCollectionSetup, taskManager: Promise ) { - const collector = createAlertsUsageCollector(usageCollection, taskManager); + const collector = createAlertingUsageCollector(usageCollection, taskManager); usageCollection.registerCollector(collector); } diff --git a/x-pack/plugins/alerting/server/usage/index.ts b/x-pack/plugins/alerting/server/usage/index.ts index c1d5cc4455a48..82e3afcba7cdb 100644 --- a/x-pack/plugins/alerting/server/usage/index.ts +++ b/x-pack/plugins/alerting/server/usage/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { registerAlertsUsageCollector } from './alerts_usage_collector'; +export { registerAlertingUsageCollector } from './alerting_usage_collector'; diff --git a/x-pack/plugins/alerting/server/usage/task.ts b/x-pack/plugins/alerting/server/usage/task.ts index 20cb9a45e1f30..04ed88edfc722 100644 --- a/x-pack/plugins/alerting/server/usage/task.ts +++ b/x-pack/plugins/alerting/server/usage/task.ts @@ -17,7 +17,8 @@ import { getTotalCountAggregations, getTotalCountInUse, getExecutionsPerDayCount, -} from './alerts_telemetry'; + getExecutionTimeoutsPerDayCount, +} from './alerting_telemetry'; export const TELEMETRY_TASK_TYPE = 'alerting_telemetry'; @@ -92,29 +93,40 @@ export function telemetryTaskRunner( getTotalCountAggregations(esClient, kibanaIndex), getTotalCountInUse(esClient, kibanaIndex), getExecutionsPerDayCount(esClient, eventLogIndex), + getExecutionTimeoutsPerDayCount(esClient, eventLogIndex), ]) - .then(([totalCountAggregations, totalInUse, totalExecutions]) => { - return { - state: { - runs: (state.runs || 0) + 1, - ...totalCountAggregations, - count_active_by_type: totalInUse.countByType, - count_active_total: totalInUse.countTotal, - count_disabled_total: totalCountAggregations.count_total - totalInUse.countTotal, - count_rules_namespaces: totalInUse.countNamespaces, - count_rules_executions_per_day: totalExecutions.countTotal, - count_rules_executions_by_type_per_day: totalExecutions.countByType, - count_rules_executions_failured_per_day: totalExecutions.countTotalFailures, - count_rules_executions_failured_by_reason_per_day: - totalExecutions.countFailuresByReason, - count_rules_executions_failured_by_reason_by_type_per_day: - totalExecutions.countFailuresByReasonByType, - avg_execution_time_per_day: totalExecutions.avgExecutionTime, - avg_execution_time_by_type_per_day: totalExecutions.avgExecutionTimeByType, - }, - runAt: getNextMidnight(), - }; - }) + .then( + ([ + totalCountAggregations, + totalInUse, + dailyExecutionCounts, + dailyExecutionTimeoutCounts, + ]) => { + return { + state: { + runs: (state.runs || 0) + 1, + ...totalCountAggregations, + count_active_by_type: totalInUse.countByType, + count_active_total: totalInUse.countTotal, + count_disabled_total: totalCountAggregations.count_total - totalInUse.countTotal, + count_rules_namespaces: totalInUse.countNamespaces, + count_rules_executions_per_day: dailyExecutionCounts.countTotal, + count_rules_executions_by_type_per_day: dailyExecutionCounts.countByType, + count_rules_executions_failured_per_day: dailyExecutionCounts.countTotalFailures, + count_rules_executions_failured_by_reason_per_day: + dailyExecutionCounts.countFailuresByReason, + count_rules_executions_failured_by_reason_by_type_per_day: + dailyExecutionCounts.countFailuresByReasonByType, + count_rules_executions_timeouts_per_day: dailyExecutionTimeoutCounts.countTotal, + count_rules_executions_timeouts_by_type_per_day: + dailyExecutionTimeoutCounts.countByType, + avg_execution_time_per_day: dailyExecutionCounts.avgExecutionTime, + avg_execution_time_by_type_per_day: dailyExecutionCounts.avgExecutionTimeByType, + }, + runAt: getNextMidnight(), + }; + } + ) .catch((errMsg) => { logger.warn(`Error executing alerting telemetry task: ${errMsg}`); return { diff --git a/x-pack/plugins/alerting/server/usage/types.ts b/x-pack/plugins/alerting/server/usage/types.ts index 546663e3ea403..b86176e23548e 100644 --- a/x-pack/plugins/alerting/server/usage/types.ts +++ b/x-pack/plugins/alerting/server/usage/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -export interface AlertsUsage { +export interface AlertingUsage { count_total: number; count_active_total: number; count_disabled_total: number; @@ -17,6 +17,8 @@ export interface AlertsUsage { count_rules_executions_failured_per_day: number; count_rules_executions_failured_by_reason_per_day: Record; count_rules_executions_failured_by_reason_by_type_per_day: Record>; + count_rules_executions_timeouts_per_day: number; + count_rules_executions_timeouts_by_type_per_day: Record; avg_execution_time_per_day: number; avg_execution_time_by_type_per_day: Record; throttle_time: { 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 799b183f7bbd6..2f893b72cb3e4 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -400,6 +400,24 @@ "siem__notifications": { "type": "long" }, + "siem__eqlRule": { + "type": "long" + }, + "siem__indicatorRule": { + "type": "long" + }, + "siem__mlRule": { + "type": "long" + }, + "siem__queryRule": { + "type": "long" + }, + "siem__savedQueryRule": { + "type": "long" + }, + "siem__thresholdRule": { + "type": "long" + }, "xpack__uptime__alerts__monitorStatus": { "type": "long" }, @@ -485,6 +503,24 @@ "siem__notifications": { "type": "long" }, + "siem__eqlRule": { + "type": "long" + }, + "siem__indicatorRule": { + "type": "long" + }, + "siem__mlRule": { + "type": "long" + }, + "siem__queryRule": { + "type": "long" + }, + "siem__savedQueryRule": { + "type": "long" + }, + "siem__thresholdRule": { + "type": "long" + }, "xpack__uptime__alerts__monitorStatus": { "type": "long" }, @@ -576,6 +612,24 @@ "siem__notifications": { "type": "long" }, + "siem__eqlRule": { + "type": "long" + }, + "siem__indicatorRule": { + "type": "long" + }, + "siem__mlRule": { + "type": "long" + }, + "siem__queryRule": { + "type": "long" + }, + "siem__savedQueryRule": { + "type": "long" + }, + "siem__thresholdRule": { + "type": "long" + }, "xpack__uptime__alerts__monitorStatus": { "type": "long" }, @@ -685,6 +739,24 @@ "siem__notifications": { "type": "long" }, + "siem__eqlRule": { + "type": "long" + }, + "siem__indicatorRule": { + "type": "long" + }, + "siem__mlRule": { + "type": "long" + }, + "siem__queryRule": { + "type": "long" + }, + "siem__savedQueryRule": { + "type": "long" + }, + "siem__thresholdRule": { + "type": "long" + }, "xpack__uptime__alerts__monitorStatus": { "type": "long" }, @@ -770,6 +842,24 @@ "siem__notifications": { "type": "long" }, + "siem__eqlRule": { + "type": "long" + }, + "siem__indicatorRule": { + "type": "long" + }, + "siem__mlRule": { + "type": "long" + }, + "siem__queryRule": { + "type": "long" + }, + "siem__savedQueryRule": { + "type": "long" + }, + "siem__thresholdRule": { + "type": "long" + }, "xpack__uptime__alerts__monitorStatus": { "type": "long" }, @@ -855,6 +945,24 @@ "siem__notifications": { "type": "long" }, + "siem__eqlRule": { + "type": "long" + }, + "siem__indicatorRule": { + "type": "long" + }, + "siem__mlRule": { + "type": "long" + }, + "siem__queryRule": { + "type": "long" + }, + "siem__savedQueryRule": { + "type": "long" + }, + "siem__thresholdRule": { + "type": "long" + }, "xpack__uptime__alerts__monitorStatus": { "type": "long" }, @@ -940,6 +1048,24 @@ "siem__notifications": { "type": "long" }, + "siem__eqlRule": { + "type": "long" + }, + "siem__indicatorRule": { + "type": "long" + }, + "siem__mlRule": { + "type": "long" + }, + "siem__queryRule": { + "type": "long" + }, + "siem__savedQueryRule": { + "type": "long" + }, + "siem__thresholdRule": { + "type": "long" + }, "xpack__uptime__alerts__monitorStatus": { "type": "long" }, @@ -1025,6 +1151,24 @@ "siem__notifications": { "type": "long" }, + "siem__eqlRule": { + "type": "long" + }, + "siem__indicatorRule": { + "type": "long" + }, + "siem__mlRule": { + "type": "long" + }, + "siem__queryRule": { + "type": "long" + }, + "siem__savedQueryRule": { + "type": "long" + }, + "siem__thresholdRule": { + "type": "long" + }, "xpack__uptime__alerts__monitorStatus": { "type": "long" }, @@ -1047,6 +1191,112 @@ } } }, + "count_rules_executions_timeouts_per_day": { + "type": "long" + }, + "count_rules_executions_timeouts_by_type_per_day": { + "properties": { + "DYNAMIC_KEY": { + "type": "long" + }, + "__index-threshold": { + "type": "long" + }, + "__es-query": { + "type": "long" + }, + "transform_health": { + "type": "long" + }, + "apm__error_rate": { + "type": "long" + }, + "apm__transaction_error_rate": { + "type": "long" + }, + "apm__transaction_duration": { + "type": "long" + }, + "apm__transaction_duration_anomaly": { + "type": "long" + }, + "metrics__alert__threshold": { + "type": "long" + }, + "metrics__alert__inventory__threshold": { + "type": "long" + }, + "logs__alert__document__count": { + "type": "long" + }, + "monitoring_alert_cluster_health": { + "type": "long" + }, + "monitoring_alert_cpu_usage": { + "type": "long" + }, + "monitoring_alert_disk_usage": { + "type": "long" + }, + "monitoring_alert_elasticsearch_version_mismatch": { + "type": "long" + }, + "monitoring_alert_kibana_version_mismatch": { + "type": "long" + }, + "monitoring_alert_license_expiration": { + "type": "long" + }, + "monitoring_alert_logstash_version_mismatch": { + "type": "long" + }, + "monitoring_alert_nodes_changed": { + "type": "long" + }, + "siem__signals": { + "type": "long" + }, + "siem__notifications": { + "type": "long" + }, + "siem__eqlRule": { + "type": "long" + }, + "siem__indicatorRule": { + "type": "long" + }, + "siem__mlRule": { + "type": "long" + }, + "siem__queryRule": { + "type": "long" + }, + "siem__savedQueryRule": { + "type": "long" + }, + "siem__thresholdRule": { + "type": "long" + }, + "xpack__uptime__alerts__monitorStatus": { + "type": "long" + }, + "xpack__uptime__alerts__tls": { + "type": "long" + }, + "xpack__uptime__alerts__durationAnomaly": { + "type": "long" + }, + "__geo-containment": { + "type": "long" + }, + "xpack__ml__anomaly_detection_alert": { + "type": "long" + }, + "xpack__ml__anomaly_detection_jobs_health": { + "type": "long" + } + } + }, "avg_execution_time_per_day": { "type": "long" }, @@ -1115,6 +1365,24 @@ "siem__notifications": { "type": "long" }, + "siem__eqlRule": { + "type": "long" + }, + "siem__indicatorRule": { + "type": "long" + }, + "siem__mlRule": { + "type": "long" + }, + "siem__queryRule": { + "type": "long" + }, + "siem__savedQueryRule": { + "type": "long" + }, + "siem__thresholdRule": { + "type": "long" + }, "xpack__uptime__alerts__monitorStatus": { "type": "long" },