From 9b361b0a3683b7828ee13e094eeca882f15a0047 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Wed, 18 May 2022 14:41:27 -0700 Subject: [PATCH 01/37] [Controls] Improved validation for Range Slider (#131421) * Ignores validation in range slider UI * Check if range filter results in no data before applying filter * Ignores validation in range slider UI * Check if range filter results in no data before applying filter * Trigger range slider control update when ignoreValidation setting changes * No longer disable range slider num fields when no data available * Fix functional test * Only add ticks and levels if popover is open * Simplify query for validation check Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../range_slider/range_slider.component.tsx | 14 +- .../range_slider/range_slider_embeddable.tsx | 186 ++++++++++++------ .../range_slider/range_slider_popover.tsx | 66 +++---- .../range_slider/range_slider_strings.ts | 6 +- .../controls/public/services/kibana/data.ts | 4 +- .../controls/range_slider.ts | 8 +- 6 files changed, 174 insertions(+), 110 deletions(-) diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx index 259b6bd7f66a1..54b53f25da89f 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx @@ -20,6 +20,7 @@ import './range_slider.scss'; interface Props { componentStateSubject: BehaviorSubject; + ignoreValidation: boolean; } // Availableoptions and loading state is controled by the embeddable, but is not considered embeddable input. export interface RangeSliderComponentState { @@ -28,9 +29,10 @@ export interface RangeSliderComponentState { min: string; max: string; loading: boolean; + isInvalid?: boolean; } -export const RangeSliderComponent: FC = ({ componentStateSubject }) => { +export const RangeSliderComponent: FC = ({ componentStateSubject, ignoreValidation }) => { // Redux embeddable Context to get state from Embeddable input const { useEmbeddableDispatch, @@ -40,10 +42,11 @@ export const RangeSliderComponent: FC = ({ componentStateSubject }) => { const dispatch = useEmbeddableDispatch(); // useStateObservable to get component state from Embeddable - const { loading, min, max, fieldFormatter } = useStateObservable( - componentStateSubject, - componentStateSubject.getValue() - ); + const { loading, min, max, fieldFormatter, isInvalid } = + useStateObservable( + componentStateSubject, + componentStateSubject.getValue() + ); const { value, id, title } = useEmbeddableSelector((state) => state); @@ -64,6 +67,7 @@ export const RangeSliderComponent: FC = ({ componentStateSubject }) => { value={value ?? ['', '']} onChange={onChangeComplete} fieldFormatter={fieldFormatter} + isInvalid={!ignoreValidation && isInvalid} /> ); }; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx index 1ad34fd361ac6..d7e1984b7c54c 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx @@ -12,12 +12,14 @@ import { buildRangeFilter, COMPARE_ALL_OPTIONS, RangeFilterParams, + Filter, + Query, } from '@kbn/es-query'; import React from 'react'; import ReactDOM from 'react-dom'; import { get, isEqual } from 'lodash'; import deepEqual from 'fast-deep-equal'; -import { Subscription, BehaviorSubject } from 'rxjs'; +import { Subscription, BehaviorSubject, lastValueFrom } from 'rxjs'; import { debounceTime, distinctUntilChanged, skip, map } from 'rxjs/operators'; import { @@ -59,6 +61,7 @@ interface RangeSliderDataFetchProps { dataViewId: string; query?: ControlInput['query']; filters?: ControlInput['filters']; + validate?: boolean; } const fieldMissingError = (fieldName: string) => @@ -99,6 +102,7 @@ export class RangeSliderEmbeddable extends Embeddable value, + isInvalid: false, }; this.updateComponentState(this.componentState); @@ -111,7 +115,7 @@ export class RangeSliderEmbeddable extends Embeddable { + this.runRangeSliderQuery().then(async () => { if (initialValue) { this.setInitializationFinished(); } @@ -122,6 +126,7 @@ export class RangeSliderEmbeddable extends Embeddable { const dataFetchPipe = this.getInput$().pipe( map((newInput) => ({ + validate: !Boolean(newInput.ignoreParentSettings?.ignoreValidations), lastReloadRequestTime: newInput.lastReloadRequestTime, dataViewId: newInput.dataViewId, fieldName: newInput.fieldName, @@ -134,7 +139,7 @@ export class RangeSliderEmbeddable extends Embeddable { - const aggBody: any = {}; - if (field) { - if (field.scripted) { - aggBody.script = { - source: field.script, - lang: field.lang, - }; - } else { - aggBody.field = field.name; - } - } - - return { - maxAgg: { - max: aggBody, - }, - minAgg: { - min: aggBody, - }, - }; - }; - - private fetchMinMax = async () => { + private runRangeSliderQuery = async () => { this.updateComponentState({ loading: true }); this.updateOutput({ loading: true }); const { dataView, field } = await this.getCurrentDataViewAndField(); @@ -220,7 +202,7 @@ export class RangeSliderEmbeddable extends Embeddable { const searchSource = await this.dataService.searchSource.create(); searchSource.setField('size', 0); searchSource.setField('index', dataView); - const aggs = this.minMaxAgg(field); - searchSource.setField('aggs', aggs); - searchSource.setField('filter', filters); - if (!ignoreParentSettings?.ignoreQuery) { + if (query) { searchSource.setField('query', query); } - const resp = await searchSource.fetch$().toPromise(); + const aggBody: any = {}; + + if (field) { + if (field.scripted) { + aggBody.script = { + source: field.script, + lang: field.lang, + }; + } else { + aggBody.field = field.name; + } + } + + const aggs = { + maxAgg: { + max: aggBody, + }, + minAgg: { + min: aggBody, + }, + }; + + searchSource.setField('aggs', aggs); + + const resp = await lastValueFrom(searchSource.fetch$()); const min = get(resp, 'rawResponse.aggregations.minAgg.value', ''); const max = get(resp, 'rawResponse.aggregations.maxAgg.value', ''); - this.updateComponentState({ - min: `${min ?? ''}`, - max: `${max ?? ''}`, - }); - - // build filter with new min/max - await this.buildFilter(); + return { min, max }; }; private buildFilter = async () => { - const { value: [selectedMin, selectedMax] = ['', ''], ignoreParentSettings } = this.getInput(); + const { + value: [selectedMin, selectedMax] = ['', ''], + query, + timeRange, + filters = [], + ignoreParentSettings, + } = this.getInput(); + const availableMin = this.componentState.min; const availableMax = this.componentState.max; @@ -271,22 +302,14 @@ export class RangeSliderEmbeddable extends Embeddable parseFloat(selectedMax); - const isLowerSelectionOutOfRange = - hasLowerSelection && parseFloat(selectedMin) > parseFloat(availableMax); - const isUpperSelectionOutOfRange = - hasUpperSelection && parseFloat(selectedMax) < parseFloat(availableMin); - const isSelectionOutOfRange = - (!ignoreParentSettings?.ignoreValidations && hasData && isLowerSelectionOutOfRange) || - isUpperSelectionOutOfRange; + const { dataView, field } = await this.getCurrentDataViewAndField(); - if (!hasData || !hasEitherSelection || hasInvalidSelection || isSelectionOutOfRange) { - this.updateComponentState({ loading: false }); + if (!hasData || !hasEitherSelection) { + this.updateComponentState({ + loading: false, + isInvalid: !ignoreParentSettings?.ignoreValidations && hasEitherSelection, + }); this.updateOutput({ filters: [], dataViews: [dataView], loading: false }); return; } @@ -307,12 +330,52 @@ export class RangeSliderEmbeddable extends Embeddable { - this.fetchMinMax(); + this.runRangeSliderQuery(); }; public destroy = () => { @@ -327,7 +390,14 @@ export class RangeSliderEmbeddable extends Embeddable - + , node ); diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx index 1bb7501f7104f..fce3dbdfe7009 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx @@ -23,8 +23,11 @@ import { import { RangeSliderStrings } from './range_slider_strings'; import { RangeValue } from './types'; +const INVALID_CLASS = 'rangeSliderAnchor__fieldNumber--invalid'; + export interface Props { id: string; + isInvalid?: boolean; isLoading?: boolean; min: string; max: string; @@ -36,6 +39,7 @@ export interface Props { export const RangeSliderPopover: FC = ({ id, + isInvalid, isLoading, min, max, @@ -52,6 +56,13 @@ export const RangeSliderPopover: FC = ({ let helpText = ''; const hasAvailableRange = min !== '' && max !== ''; + + if (!hasAvailableRange) { + helpText = RangeSliderStrings.popover.getNoAvailableDataHelpText(); + } else if (isInvalid) { + helpText = RangeSliderStrings.popover.getNoDataHelpText(); + } + const hasLowerBoundSelection = value[0] !== ''; const hasUpperBoundSelection = value[1] !== ''; @@ -60,23 +71,10 @@ export const RangeSliderPopover: FC = ({ const minValue = parseFloat(min); const maxValue = parseFloat(max); - if (!hasAvailableRange) { - helpText = 'There is no data to display. Adjust the time range and filters.'; - } - // EuiDualRange can only handle integers as min/max const roundedMin = hasAvailableRange ? Math.floor(minValue) : minValue; const roundedMax = hasAvailableRange ? Math.ceil(maxValue) : maxValue; - const isLowerSelectionInvalid = hasLowerBoundSelection && lowerBoundValue > roundedMax; - const isUpperSelectionInvalid = hasUpperBoundSelection && upperBoundValue < roundedMin; - const isSelectionInvalid = - hasAvailableRange && (isLowerSelectionInvalid || isUpperSelectionInvalid); - - if (isSelectionInvalid) { - helpText = RangeSliderStrings.popover.getNoDataHelpText(); - } - if (lowerBoundValue > upperBoundValue) { errorMessage = RangeSliderStrings.errors.getUpperLessThanLowerErrorMessage(); } @@ -89,7 +87,7 @@ export const RangeSliderPopover: FC = ({ const ticks = []; const levels = []; - if (hasAvailableRange) { + if (hasAvailableRange && isPopoverOpen) { ticks.push({ value: rangeSliderMin, label: fieldFormatter(String(rangeSliderMin)) }); ticks.push({ value: rangeSliderMax, label: fieldFormatter(String(rangeSliderMax)) }); levels.push({ min: roundedMin, max: roundedMax, color: 'success' }); @@ -127,17 +125,15 @@ export const RangeSliderPopover: FC = ({ controlOnly fullWidth className={`rangeSliderAnchor__fieldNumber ${ - hasLowerBoundSelection && isSelectionInvalid - ? 'rangeSliderAnchor__fieldNumber--invalid' - : '' + hasLowerBoundSelection && isInvalid ? INVALID_CLASS : '' }`} value={hasLowerBoundSelection ? lowerBoundValue : ''} onChange={(event) => { onChange([event.target.value, isNaN(upperBoundValue) ? '' : String(upperBoundValue)]); }} - disabled={!hasAvailableRange || isLoading} + disabled={isLoading} placeholder={`${hasAvailableRange ? roundedMin : ''}`} - isInvalid={isLowerSelectionInvalid} + isInvalid={isInvalid} data-test-subj="rangeSlider__lowerBoundFieldNumber" /> @@ -151,17 +147,15 @@ export const RangeSliderPopover: FC = ({ controlOnly fullWidth className={`rangeSliderAnchor__fieldNumber ${ - hasUpperBoundSelection && isSelectionInvalid - ? 'rangeSliderAnchor__fieldNumber--invalid' - : '' + hasUpperBoundSelection && isInvalid ? INVALID_CLASS : '' }`} value={hasUpperBoundSelection ? upperBoundValue : ''} onChange={(event) => { onChange([isNaN(lowerBoundValue) ? '' : String(lowerBoundValue), event.target.value]); }} - disabled={!hasAvailableRange || isLoading} + disabled={isLoading} placeholder={`${hasAvailableRange ? roundedMax : ''}`} - isInvalid={isUpperSelectionInvalid} + isInvalid={isInvalid} data-test-subj="rangeSlider__upperBoundFieldNumber" /> @@ -234,19 +228,17 @@ export const RangeSliderPopover: FC = ({ {errorMessage || helpText} - {hasAvailableRange ? ( - - - onChange(['', ''])} - aria-label={RangeSliderStrings.popover.getClearRangeButtonTitle()} - data-test-subj="rangeSlider__clearRangeButton" - /> - - - ) : null} + + + onChange(['', ''])} + aria-label={RangeSliderStrings.popover.getClearRangeButtonTitle()} + data-test-subj="rangeSlider__clearRangeButton" + /> + + ); diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_strings.ts b/src/plugins/controls/public/control_types/range_slider/range_slider_strings.ts index a901f79ba20f5..53d614fd54a2e 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_strings.ts +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_strings.ts @@ -42,7 +42,11 @@ export const RangeSliderStrings = { }), getNoDataHelpText: () => i18n.translate('controls.rangeSlider.popover.noDataHelpText', { - defaultMessage: 'Selected range is outside of available data. No filter was applied.', + defaultMessage: 'Selected range resulted in no data. No filter was applied.', + }), + getNoAvailableDataHelpText: () => + i18n.translate('controls.rangeSlider.popover.noAvailableDataHelpText', { + defaultMessage: 'There is no data to display. Adjust the time range and filters.', }), }, errors: { diff --git a/src/plugins/controls/public/services/kibana/data.ts b/src/plugins/controls/public/services/kibana/data.ts index 29a96a98c7e76..0dc702542633b 100644 --- a/src/plugins/controls/public/services/kibana/data.ts +++ b/src/plugins/controls/public/services/kibana/data.ts @@ -8,7 +8,7 @@ import { DataViewField } from '@kbn/data-views-plugin/common'; import { get } from 'lodash'; -import { from } from 'rxjs'; +import { from, lastValueFrom } from 'rxjs'; import { KibanaPluginServiceFactory } from '@kbn/presentation-util-plugin/public'; import { ControlsDataService } from '../data'; import { ControlsPluginStartDeps } from '../../types'; @@ -78,7 +78,7 @@ export const dataServiceFactory: DataServiceFactory = ({ startPlugins }) => { searchSource.setField('filter', ignoreParentSettings?.ignoreFilters ? [] : filters); searchSource.setField('query', ignoreParentSettings?.ignoreQuery ? undefined : query); - const resp = await searchSource.fetch$().toPromise(); + const resp = await lastValueFrom(searchSource.fetch$()); const min = get(resp, 'rawResponse.aggregations.minAgg.value', undefined); const max = get(resp, 'rawResponse.aggregations.maxAgg.value', undefined); diff --git a/test/functional/apps/dashboard_elements/controls/range_slider.ts b/test/functional/apps/dashboard_elements/controls/range_slider.ts index b2d07e7a49489..a4b84206bde84 100644 --- a/test/functional/apps/dashboard_elements/controls/range_slider.ts +++ b/test/functional/apps/dashboard_elements/controls/range_slider.ts @@ -206,7 +206,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.rangeSliderSetUpperBound(firstId, '400'); }); - it('disables inputs when no data available', async () => { + it('disables range slider when no data available', async () => { await dashboardControls.createControl({ controlType: RANGE_SLIDER_CONTROL, dataViewTitle: 'logstash-*', @@ -214,12 +214,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { width: 'small', }); const secondId = (await dashboardControls.getAllControlIds())[1]; - expect( - await dashboardControls.rangeSliderGetLowerBoundAttribute(secondId, 'disabled') - ).to.be('true'); - expect( - await dashboardControls.rangeSliderGetUpperBoundAttribute(secondId, 'disabled') - ).to.be('true'); await dashboardControls.rangeSliderOpenPopover(secondId); await dashboardControls.rangeSliderPopoverAssertOpen(); expect( From 8930324da2159547bac1e13ad852552f1825343d Mon Sep 17 00:00:00 2001 From: Rashmi Kulkarni Date: Wed, 18 May 2022 15:19:47 -0700 Subject: [PATCH 02/37] try to unskip maps auto_fit_to_bounds test (#132373) * try to unskip maps auto_fit_to_bounds test * final check --- .../test/functional/apps/maps/group1/auto_fit_to_bounds.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/x-pack/test/functional/apps/maps/group1/auto_fit_to_bounds.js b/x-pack/test/functional/apps/maps/group1/auto_fit_to_bounds.js index 2d5813e81c214..1fe78ec17f1ce 100644 --- a/x-pack/test/functional/apps/maps/group1/auto_fit_to_bounds.js +++ b/x-pack/test/functional/apps/maps/group1/auto_fit_to_bounds.js @@ -11,8 +11,7 @@ export default function ({ getPageObjects, getService }) { const PageObjects = getPageObjects(['maps']); const security = getService('security'); - // FLAKY: https://github.com/elastic/kibana/issues/129467 - describe.skip('auto fit map to bounds', () => { + describe('auto fit map to bounds', () => { describe('initial location', () => { before(async () => { await security.testUser.setRoles(['global_maps_all', 'test_logstash_reader']); @@ -30,7 +29,7 @@ export default function ({ getPageObjects, getService }) { expect(hits).to.equal('6'); const { lat, lon } = await PageObjects.maps.getView(); - expect(Math.round(lat)).to.equal(41); + expect(Math.round(lat)).to.be.within(41, 43); expect(Math.round(lon)).to.equal(-99); }); }); From 27d96702ffe6965fd92795673876ea192450ff62 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 18 May 2022 23:24:19 +0100 Subject: [PATCH 03/37] docs(NA): adding @kbn/ambient-ui-types into ops docs (#132482) * docs(NA): adding @kbn/ambient-ui-types into ops docs * docs(NA): wording update --- dev_docs/operations/operations_landing.mdx | 1 + nav-kibana-dev.docnav.json | 3 ++- packages/kbn-ambient-ui-types/README.mdx | 13 +++++++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/dev_docs/operations/operations_landing.mdx b/dev_docs/operations/operations_landing.mdx index 31e996086dd0b..85676d179074b 100644 --- a/dev_docs/operations/operations_landing.mdx +++ b/dev_docs/operations/operations_landing.mdx @@ -43,5 +43,6 @@ layout: landing { pageId: "kibDevDocsToolingLog" }, { pageId: "kibDevDocsOpsJestSerializers"}, { pageId: "kibDevDocsOpsExpect" }, + { pageId: "kibDevDocsOpsAmbientUiTypes"}, ]} /> \ No newline at end of file diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index 6075889f47889..f565026115a84 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -198,7 +198,8 @@ "items": [ { "id": "kibDevDocsToolingLog" }, { "id": "kibDevDocsOpsJestSerializers"}, - { "id": "kibDevDocsOpsExpect" } + { "id": "kibDevDocsOpsExpect" }, + { "id": "kibDevDocsOpsAmbientUiTypes" } ] } ] diff --git a/packages/kbn-ambient-ui-types/README.mdx b/packages/kbn-ambient-ui-types/README.mdx index d63d8567afe07..dbff6fb8e18a2 100644 --- a/packages/kbn-ambient-ui-types/README.mdx +++ b/packages/kbn-ambient-ui-types/README.mdx @@ -1,7 +1,15 @@ -# @kbn/ambient-ui-types +--- +id: kibDevDocsOpsAmbientUiTypes +slug: /kibana-dev-docs/ops/ambient-ui-types +title: "@kbn/ambient-ui-types" +description: A package holding ambient type definitions for files +date: 2022-05-18 +tags: ['kibana', 'dev', 'contributor', 'operations', 'ambient', 'ui', 'types'] +--- -This is a package of Typescript types for files that might get imported by Webpack and therefore need definitions. +This package holds ambient typescript definitions for files with extensions like `.html, .png, .svg, .mdx` that might get imported by Webpack and therefore needed. +## Plugins These types will automatically be included for plugins. ## Packages @@ -9,4 +17,5 @@ These types will automatically be included for plugins. To include these types in a package: - add `"//packages/kbn-ambient-ui-types"` to the `RUNTIME_DEPS` portion of the `BUILD.bazel` file. +- add `"//packages/kbn-ambient-ui-types:npm_module_types"` to the `TYPES_DEPS` portion of the `BUILD.bazel` file. - add `"@kbn/ambient-ui-types"` to the `types` portion of the `tsconfig.json` file. \ No newline at end of file From 9ebb269f13630ace0db03cedca31c733d3dc5ea7 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 19 May 2022 00:57:21 +0200 Subject: [PATCH 04/37] Allow default arguments to yarn es to be overwritten. (#130864) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Spencer --- packages/kbn-es/src/cluster.js | 42 ++++++++++++------- .../src/integration_tests/cluster.test.js | 34 ++++++++++++++- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index eecaef06be453..5c410523d70ca 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -325,29 +325,41 @@ exports.Cluster = class Cluster { this._log.info(chalk.bold('Starting')); this._log.indent(4); - const esArgs = [ - 'action.destructive_requires_name=true', - 'ingest.geoip.downloader.enabled=false', - 'search.check_ccs_compatibility=true', - 'cluster.routing.allocation.disk.threshold_enabled=false', - ].concat(options.esArgs || []); + const esArgs = new Map([ + ['action.destructive_requires_name', 'true'], + ['cluster.routing.allocation.disk.threshold_enabled', 'false'], + ['ingest.geoip.downloader.enabled', 'false'], + ['search.check_ccs_compatibility', 'true'], + ]); + + // options.esArgs overrides the default esArg values + for (const arg of [].concat(options.esArgs || [])) { + const [key, ...value] = arg.split('='); + esArgs.set(key.trim(), value.join('=').trim()); + } // Add to esArgs if ssl is enabled if (this._ssl) { - esArgs.push('xpack.security.http.ssl.enabled=true'); - - // Include default keystore settings only if keystore isn't configured. - if (!esArgs.some((arg) => arg.startsWith('xpack.security.http.ssl.keystore'))) { - esArgs.push(`xpack.security.http.ssl.keystore.path=${ES_NOPASSWORD_P12_PATH}`); - esArgs.push(`xpack.security.http.ssl.keystore.type=PKCS12`); + esArgs.set('xpack.security.http.ssl.enabled', 'true'); + // Include default keystore settings only if ssl isn't disabled by esArgs and keystore isn't configured. + if (!esArgs.get('xpack.security.http.ssl.keystore.path')) { // We are explicitly using ES_NOPASSWORD_P12_PATH instead of ES_P12_PATH + ES_P12_PASSWORD. The reasoning for this is that setting // the keystore password using environment variables causes Elasticsearch to emit deprecation warnings. + esArgs.set(`xpack.security.http.ssl.keystore.path`, ES_NOPASSWORD_P12_PATH); + esArgs.set(`xpack.security.http.ssl.keystore.type`, `PKCS12`); } } - const args = parseSettings(extractConfigFiles(esArgs, installPath, { log: this._log }), { - filter: SettingsFilter.NonSecureOnly, - }).reduce( + const args = parseSettings( + extractConfigFiles( + Array.from(esArgs).map((e) => e.join('=')), + installPath, + { log: this._log } + ), + { + filter: SettingsFilter.NonSecureOnly, + } + ).reduce( (acc, [settingName, settingValue]) => acc.concat(['-E', `${settingName}=${settingValue}`]), [] ); diff --git a/packages/kbn-es/src/integration_tests/cluster.test.js b/packages/kbn-es/src/integration_tests/cluster.test.js index 250bc9ac883b3..1a871667bd7a9 100644 --- a/packages/kbn-es/src/integration_tests/cluster.test.js +++ b/packages/kbn-es/src/integration_tests/cluster.test.js @@ -304,9 +304,41 @@ describe('#start(installPath)', () => { Array [ Array [ "action.destructive_requires_name=true", + "cluster.routing.allocation.disk.threshold_enabled=false", "ingest.geoip.downloader.enabled=false", "search.check_ccs_compatibility=true", + ], + undefined, + Object { + "log": , + }, + ], + ] + `); + }); + + it(`allows overriding search.check_ccs_compatibility`, async () => { + mockEsBin({ start: true }); + + extractConfigFiles.mockReturnValueOnce([]); + + const cluster = new Cluster({ + log, + ssl: false, + }); + + await cluster.start(undefined, { + esArgs: ['search.check_ccs_compatibility=false'], + }); + + expect(extractConfigFiles.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Array [ + "action.destructive_requires_name=true", "cluster.routing.allocation.disk.threshold_enabled=false", + "ingest.geoip.downloader.enabled=false", + "search.check_ccs_compatibility=false", ], undefined, Object { @@ -384,9 +416,9 @@ describe('#run()', () => { Array [ Array [ "action.destructive_requires_name=true", + "cluster.routing.allocation.disk.threshold_enabled=false", "ingest.geoip.downloader.enabled=false", "search.check_ccs_compatibility=true", - "cluster.routing.allocation.disk.threshold_enabled=false", ], undefined, Object { From 912979a8cc2105d31f9ff93e6ebfb4325439b754 Mon Sep 17 00:00:00 2001 From: Faisal Kanout Date: Thu, 19 May 2022 02:15:04 +0300 Subject: [PATCH 05/37] Add Execution history table to rule details page (#132245) --- .../public/pages/rule_details/index.tsx | 18 ++++++++++++++++-- .../triggers_actions_ui/public/index.ts | 1 + 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx index ce7049bd61056..9cce5bfb99c92 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -23,6 +23,7 @@ import { EuiHorizontalRule, EuiTabbedContent, EuiEmptyPrompt, + EuiLoadingSpinner, } from '@elastic/eui'; import { @@ -33,9 +34,11 @@ import { deleteRules, useLoadRuleTypes, RuleType, + RuleEventLogListProps, } from '@kbn/triggers-actions-ui-plugin/public'; // TODO: use a Delete modal from triggersActionUI when it's sharable import { ALERTS_FEATURE_ID } from '@kbn/alerting-plugin/common'; + import { DeleteModalConfirmation } from '../rules/components/delete_modal_confirmation'; import { CenterJustifiedSpinner } from '../rules/components/center_justified_spinner'; import { getHealthColor, OBSERVABILITY_SOLUTIONS } from '../rules/config'; @@ -63,7 +66,12 @@ import { export function RuleDetailsPage() { const { http, - triggersActionsUi: { ruleTypeRegistry, getRuleStatusDropdown, getEditAlertFlyout }, + triggersActionsUi: { + ruleTypeRegistry, + getRuleStatusDropdown, + getEditAlertFlyout, + getRuleEventLogList, + }, application: { capabilities, navigateToUrl }, notifications: { toasts }, } = useKibana().services; @@ -163,7 +171,13 @@ export function RuleDetailsPage() { defaultMessage: 'Execution history', }), 'data-test-subj': 'eventLogListTab', - content: Execution history, + content: rule ? ( + getRuleEventLogList({ + rule, + } as RuleEventLogListProps) + ) : ( + + ), }, { id: ALERT_LIST_TAB, diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index f14b5482fd6fd..001f63bc6cc6f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -36,6 +36,7 @@ export type { RuleSummary, AlertStatus, AlertsTableConfigurationRegistryContract, + RuleEventLogListProps, } from './types'; export { From b29c645bdad0fa3fa66826dfedeb56d947ec9654 Mon Sep 17 00:00:00 2001 From: John Dorlus Date: Wed, 18 May 2022 19:29:30 -0400 Subject: [PATCH 06/37] Fix for Console Test (#129276) * Added some wait conditions to ensure that the comma is present before trying to make assertion. * Added check to verify inner html. * Switched wait to retry. * Fixed duplicate declaration. * Fixed PR per nits. --- test/functional/apps/console/_autocomplete.ts | 23 ++++++++++++++++--- test/functional/page_objects/console_page.ts | 16 +++++++++++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/test/functional/apps/console/_autocomplete.ts b/test/functional/apps/console/_autocomplete.ts index 7bf872373c6c7..85be77d9522a7 100644 --- a/test/functional/apps/console/_autocomplete.ts +++ b/test/functional/apps/console/_autocomplete.ts @@ -14,6 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const retry = getService('retry'); const PageObjects = getPageObjects(['common', 'console']); + const find = getService('find'); describe('console autocomplete feature', function describeIndexTests() { this.tags('includeFirefox'); @@ -34,14 +35,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(PageObjects.console.isAutocompleteVisible()).to.be.eql(true); }); - // FLAKY: https://github.com/elastic/kibana/issues/126414 - describe.skip('with a missing comma in query', () => { + describe('with a missing comma in query', () => { const LINE_NUMBER = 4; beforeEach(async () => { await PageObjects.console.clearTextArea(); await PageObjects.console.enterRequest(); await PageObjects.console.pressEnter(); }); + it('should add a comma after previous non empty line', async () => { await PageObjects.console.enterText(`{\n\t"query": {\n\t\t"match": {}`); await PageObjects.console.pressEnter(); @@ -49,7 +50,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.console.pressEnter(); await PageObjects.console.promptAutocomplete(); await PageObjects.console.pressEnter(); - + await retry.try(async () => { + let conApp = await find.byCssSelector('.conApp'); + const firstInnerHtml = await conApp.getAttribute('innerHTML'); + await PageObjects.common.sleep(500); + conApp = await find.byCssSelector('.conApp'); + const secondInnerHtml = await conApp.getAttribute('innerHTML'); + return firstInnerHtml === secondInnerHtml; + }); + const textAreaString = await PageObjects.console.getAllVisibleText(); + log.debug('Text Area String Value==================\n'); + log.debug(textAreaString); + expect(textAreaString).to.contain(','); const text = await PageObjects.console.getVisibleTextAt(LINE_NUMBER); const lastChar = text.charAt(text.length - 1); expect(lastChar).to.be.eql(','); @@ -61,6 +73,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.console.promptAutocomplete(); await PageObjects.console.pressEnter(); + await retry.waitForWithTimeout('text area to contain comma', 25000, async () => { + const textAreaString = await PageObjects.console.getAllVisibleText(); + return textAreaString.includes(','); + }); + const text = await PageObjects.console.getVisibleTextAt(LINE_NUMBER); const lastChar = text.charAt(text.length - 1); expect(lastChar).to.be.eql(','); diff --git a/test/functional/page_objects/console_page.ts b/test/functional/page_objects/console_page.ts index 7aaf842f28d14..218a1077d63ef 100644 --- a/test/functional/page_objects/console_page.ts +++ b/test/functional/page_objects/console_page.ts @@ -119,10 +119,22 @@ export class ConsolePageObject extends FtrService { return await this.testSubjects.find('console-textarea'); } - public async getVisibleTextAt(lineIndex: number) { + public async getAllTextLines() { const editor = await this.getEditor(); - const lines = await editor.findAllByClassName('ace_line_group'); + return await editor.findAllByClassName('ace_line_group'); + } + public async getAllVisibleText() { + let textString = ''; + const textLineElements = await this.getAllTextLines(); + for (let i = 0; i < textLineElements.length; i++) { + textString = textString.concat(await textLineElements[i].getVisibleText()); + } + return textString; + } + + public async getVisibleTextAt(lineIndex: number) { + const lines = await this.getAllTextLines(); if (lines.length < lineIndex) { throw new Error(`No line with index: ${lineIndex}`); } From d8a62589b3e1ed4dfe9090be39fa551afb92a1ba Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 19 May 2022 00:51:07 +0100 Subject: [PATCH 07/37] docs(NA): adding @kbn/ambient-storybook-types into ops docs (#132483) --- dev_docs/operations/operations_landing.mdx | 1 + nav-kibana-dev.docnav.json | 3 ++- packages/kbn-ambient-storybook-types/README.md | 3 --- .../kbn-ambient-storybook-types/README.mdx | 18 ++++++++++++++++++ 4 files changed, 21 insertions(+), 4 deletions(-) delete mode 100644 packages/kbn-ambient-storybook-types/README.md create mode 100644 packages/kbn-ambient-storybook-types/README.mdx diff --git a/dev_docs/operations/operations_landing.mdx b/dev_docs/operations/operations_landing.mdx index 85676d179074b..cda44a96fe4dd 100644 --- a/dev_docs/operations/operations_landing.mdx +++ b/dev_docs/operations/operations_landing.mdx @@ -43,6 +43,7 @@ layout: landing { pageId: "kibDevDocsToolingLog" }, { pageId: "kibDevDocsOpsJestSerializers"}, { pageId: "kibDevDocsOpsExpect" }, + { pageId: "kibDevDocsOpsAmbientStorybookTypes" }, { pageId: "kibDevDocsOpsAmbientUiTypes"}, ]} /> \ No newline at end of file diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index f565026115a84..4704430ba94b6 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -197,8 +197,9 @@ "label": "Utilities", "items": [ { "id": "kibDevDocsToolingLog" }, - { "id": "kibDevDocsOpsJestSerializers"}, + { "id": "kibDevDocsOpsJestSerializers" }, { "id": "kibDevDocsOpsExpect" }, + { "id": "kibDevDocsOpsAmbientStorybookTypes" }, { "id": "kibDevDocsOpsAmbientUiTypes" } ] } diff --git a/packages/kbn-ambient-storybook-types/README.md b/packages/kbn-ambient-storybook-types/README.md deleted file mode 100644 index 865cf8d522d1b..0000000000000 --- a/packages/kbn-ambient-storybook-types/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# @kbn/ambient-storybook-types - -Ambient types needed to use storybook. \ No newline at end of file diff --git a/packages/kbn-ambient-storybook-types/README.mdx b/packages/kbn-ambient-storybook-types/README.mdx new file mode 100644 index 0000000000000..f0db9b552d6ee --- /dev/null +++ b/packages/kbn-ambient-storybook-types/README.mdx @@ -0,0 +1,18 @@ +--- +id: kibDevDocsOpsAmbientStorybookTypes +slug: /kibana-dev-docs/ops/ambient-storybook-types +title: "@kbn/ambient-storybook-types" +description: A package holding ambient type definitions for storybooks +date: 2022-05-18 +tags: ['kibana', 'dev', 'contributor', 'operations', 'ambient', 'storybook', 'types'] +--- + +This package holds ambient typescript definitions needed to use storybooks. + +## Packages + +To include these types in a package: + +- add `"//packages/kbn-ambient-storybook-types"` to the `RUNTIME_DEPS` portion of the `BUILD.bazel` file. +- add `"//packages/kbn-ambient-storybook-types:npm_module_types"` to the `TYPES_DEPS` portion of the `BUILD.bazel` file. +- add `"@kbn/ambient-storybook-types"` to the `types` portion of the `tsconfig.json` file. From 5ecde4b053d77f86f05f1b04c8417c6a5e4c5d92 Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Thu, 19 May 2022 09:21:50 +0200 Subject: [PATCH 08/37] [Osquery] Add multiline query support (#131224) --- .../utils/build_query/remove_multilines.ts | 9 + .../plugins/osquery/public/editor/index.tsx | 4 +- .../packs/pack_queries_status_table.tsx | 267 ++++++++++++------ .../queries/ecs_mapping_editor_field.tsx | 5 +- .../server/routes/pack/create_pack_route.ts | 4 +- .../server/routes/pack/update_pack_route.ts | 4 +- .../osquery/server/routes/pack/utils.test.ts | 57 ++++ .../osquery/server/routes/pack/utils.ts | 11 +- .../translations/translations/fr-FR.json | 3 - .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - x-pack/test/api_integration/apis/index.ts | 1 + .../api_integration/apis/osquery/index.js | 12 + .../api_integration/apis/osquery/packs.ts | 152 ++++++++++ 14 files changed, 428 insertions(+), 107 deletions(-) create mode 100644 x-pack/plugins/osquery/common/utils/build_query/remove_multilines.ts create mode 100644 x-pack/plugins/osquery/server/routes/pack/utils.test.ts create mode 100644 x-pack/test/api_integration/apis/osquery/index.js create mode 100644 x-pack/test/api_integration/apis/osquery/packs.ts diff --git a/x-pack/plugins/osquery/common/utils/build_query/remove_multilines.ts b/x-pack/plugins/osquery/common/utils/build_query/remove_multilines.ts new file mode 100644 index 0000000000000..66208a0c7524d --- /dev/null +++ b/x-pack/plugins/osquery/common/utils/build_query/remove_multilines.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const removeMultilines = (query: string): string => + query.replaceAll('\n', ' ').replaceAll(/ +/g, ' '); diff --git a/x-pack/plugins/osquery/public/editor/index.tsx b/x-pack/plugins/osquery/public/editor/index.tsx index 191fe1e7ea548..9718e80926d06 100644 --- a/x-pack/plugins/osquery/public/editor/index.tsx +++ b/x-pack/plugins/osquery/public/editor/index.tsx @@ -35,9 +35,7 @@ const OsqueryEditorComponent: React.FC = ({ }) => { const [editorValue, setEditorValue] = useState(defaultValue ?? ''); - useDebounce(() => onChange(editorValue.replaceAll('\n', ' ').replaceAll(' ', ' ')), 500, [ - editorValue, - ]); + useDebounce(() => onChange(editorValue), 500, [editorValue]); useEffect(() => setEditorValue(defaultValue), [defaultValue]); diff --git a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx index 70282ab0819fd..3aa345f986493 100644 --- a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx +++ b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx @@ -21,7 +21,7 @@ import { EuiPanel, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage, FormattedDate, FormattedTime, FormattedRelative } from '@kbn/i18n-react'; +import { FormattedDate, FormattedTime, FormattedRelative } from '@kbn/i18n-react'; import moment from 'moment-timezone'; import type { @@ -32,6 +32,7 @@ import type { } from '@kbn/lens-plugin/public'; import { DOCUMENT_FIELD_NAME as RECORDS_FIELD } from '@kbn/lens-plugin/common/constants'; import { FilterStateStore, DataView } from '@kbn/data-plugin/common'; +import { removeMultilines } from '../../common/utils/build_query/remove_multilines'; import { useKibana } from '../common/lib/kibana'; import { OsqueryManagerPackagePolicyInputStream } from '../../common/types'; import { ScheduledQueryErrorsTable } from './scheduled_query_errors_table'; @@ -384,6 +385,13 @@ const ScheduledQueryExpandedContent = React.memo = ({ actionId, - queryId, interval, logsDataView, - toggleErrors, - expanded, }) => { const { data: lastResultsData, isLoading } = usePackQueryLastResults({ actionId, @@ -406,22 +411,11 @@ const ScheduledQueryLastResults: React.FC = ({ logsDataView, }); - const { data: errorsData, isLoading: errorsLoading } = usePackQueryErrors({ - actionId, - interval, - logsDataView, - }); - - const handleErrorsToggle = useCallback( - () => toggleErrors({ queryId, interval }), - [queryId, interval, toggleErrors] - ); - - if (isLoading || errorsLoading) { + if (isLoading) { return ; } - if (!lastResultsData && !errorsData?.total) { + if (!lastResultsData) { return <>{'-'}; } @@ -448,73 +442,115 @@ const ScheduledQueryLastResults: React.FC = ({ '-' )} - - - - - {lastResultsData?.docCount ?? 0} - - - - - - - + + ); +}; - - - - - {lastResultsData?.uniqueAgentsCount ?? 0} - - - - - - +const DocsColumnResults: React.FC = ({ + actionId, + interval, + logsDataView, +}) => { + const { data: lastResultsData, isLoading } = usePackQueryLastResults({ + actionId, + interval, + logsDataView, + }); + if (isLoading) { + return ; + } + + if (!lastResultsData) { + return <>{'-'}; + } + + return ( + + + + {lastResultsData?.docCount ?? 0} + + + ); +}; - - - - - {errorsData?.total ?? 0} - - - - - {' '} - - - - - - - +const AgentsColumnResults: React.FC = ({ + actionId, + interval, + logsDataView, +}) => { + const { data: lastResultsData, isLoading } = usePackQueryLastResults({ + actionId, + interval, + logsDataView, + }); + if (isLoading) { + return ; + } + + if (!lastResultsData) { + return <>{'-'}; + } + + return ( + + + + {lastResultsData?.uniqueAgentsCount ?? 0} + ); }; +const ErrorsColumnResults: React.FC = ({ + actionId, + interval, + queryId, + toggleErrors, + expanded, + logsDataView, +}) => { + const handleErrorsToggle = useCallback( + () => toggleErrors({ queryId, interval }), + [toggleErrors, queryId, interval] + ); + + const { data: errorsData, isLoading: errorsLoading } = usePackQueryErrors({ + actionId, + interval, + logsDataView, + }); + if (errorsLoading) { + return ; + } + + if (!errorsData?.total) { + return <>{'-'}; + } + + return ( + + + + + {errorsData?.total ?? 0} + + + + + + + + + ); +}; + const getPackActionId = (actionId: string, packName: string) => `pack_${packName}_${actionId}`; interface PackViewInActionProps { @@ -625,14 +661,18 @@ const PackQueriesStatusTableComponent: React.FC = ( fetchLogsDataView(); }, [dataViews]); - const renderQueryColumn = useCallback( - (query: string) => ( - - {query} - - ), - [] - ); + const renderQueryColumn = useCallback((query: string, item) => { + const singleLine = removeMultilines(query); + const content = singleLine.length > 55 ? `${singleLine.substring(0, 55)}...` : singleLine; + + return ( + {query}}> + + {content} + + + ); + }, []); const toggleErrors = useCallback( ({ queryId, interval }: { queryId: string; interval: number }) => { @@ -658,14 +698,44 @@ const PackQueriesStatusTableComponent: React.FC = ( (item) => ( + ), + [packName, logsDataView] + ); + const renderDocsColumn = useCallback( + (item) => ( + + ), + [logsDataView, packName] + ); + const renderAgentsColumn = useCallback( + (item) => ( + + ), + [logsDataView, packName] + ); + const renderErrorsColumn = useCallback( + (item) => ( + ), - [itemIdToExpandedRowMap, packName, toggleErrors, logsDataView] + [itemIdToExpandedRowMap, logsDataView, packName, toggleErrors] ); const renderDiscoverResultsAction = useCallback( @@ -705,6 +775,7 @@ const PackQueriesStatusTableComponent: React.FC = ( defaultMessage: 'ID', }), width: '15%', + truncateText: true, }, { field: 'interval', @@ -719,13 +790,32 @@ const PackQueriesStatusTableComponent: React.FC = ( defaultMessage: 'Query', }), render: renderQueryColumn, - width: '20%', + width: '40%', }, { name: i18n.translate('xpack.osquery.pack.queriesTable.lastResultsColumnTitle', { defaultMessage: 'Last results', }), render: renderLastResultsColumn, + width: '12%', + }, + { + name: i18n.translate('xpack.osquery.pack.queriesTable.docsResultsColumnTitle', { + defaultMessage: 'Docs', + }), + render: renderDocsColumn, + }, + { + name: i18n.translate('xpack.osquery.pack.queriesTable.agentsResultsColumnTitle', { + defaultMessage: 'Agents', + }), + render: renderAgentsColumn, + }, + { + name: i18n.translate('xpack.osquery.pack.queriesTable.errorsResultsColumnTitle', { + defaultMessage: 'Errors', + }), + render: renderErrorsColumn, }, { name: i18n.translate('xpack.osquery.pack.queriesTable.viewResultsColumnTitle', { @@ -745,6 +835,9 @@ const PackQueriesStatusTableComponent: React.FC = ( [ renderQueryColumn, renderLastResultsColumn, + renderDocsColumn, + renderAgentsColumn, + renderErrorsColumn, renderDiscoverResultsAction, renderLensResultsAction, ] diff --git a/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx b/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx index 5aaab625d3ef5..15dca629821b2 100644 --- a/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx +++ b/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx @@ -59,6 +59,7 @@ import { FormArrayField, } from '../../shared_imports'; import { OsqueryIcon } from '../../components/osquery_icon'; +import { removeMultilines } from '../../../common/utils/build_query/remove_multilines'; export const CommonUseField = getUseField({ component: Field }); @@ -773,11 +774,13 @@ export const ECSMappingEditorField = React.memo( return; } + const oneLineQuery = removeMultilines(query); + // eslint-disable-next-line @typescript-eslint/no-explicit-any let ast: Record | undefined; try { - ast = sqliteParser(query)?.statement?.[0]; + ast = sqliteParser(oneLineQuery)?.statement?.[0]; } catch (e) { return; } diff --git a/x-pack/plugins/osquery/server/routes/pack/create_pack_route.ts b/x-pack/plugins/osquery/server/routes/pack/create_pack_route.ts index bf34152078582..67ae97b9af5cd 100644 --- a/x-pack/plugins/osquery/server/routes/pack/create_pack_route.ts +++ b/x-pack/plugins/osquery/server/routes/pack/create_pack_route.ts @@ -19,7 +19,7 @@ import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; import { OSQUERY_INTEGRATION_NAME } from '../../../common'; import { PLUGIN_ID } from '../../../common'; import { packSavedObjectType } from '../../../common/types'; -import { convertPackQueriesToSO } from './utils'; +import { convertPackQueriesToSO, convertSOQueriesToPack } from './utils'; import { getInternalSavedObjectsClient } from '../../usage/collector'; export const createPackRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { @@ -138,7 +138,7 @@ export const createPackRoute = (router: IRouter, osqueryContext: OsqueryAppConte } set(draft, `inputs[0].config.osquery.value.packs.${packSO.attributes.name}`, { - queries, + queries: convertSOQueriesToPack(queries, { removeMultiLines: true }), }); return draft; diff --git a/x-pack/plugins/osquery/server/routes/pack/update_pack_route.ts b/x-pack/plugins/osquery/server/routes/pack/update_pack_route.ts index 82d880c70fbd6..cb79165f3dca1 100644 --- a/x-pack/plugins/osquery/server/routes/pack/update_pack_route.ts +++ b/x-pack/plugins/osquery/server/routes/pack/update_pack_route.ts @@ -282,7 +282,9 @@ export const updatePackRoute = (router: IRouter, osqueryContext: OsqueryAppConte draft, `inputs[0].config.osquery.value.packs.${updatedPackSO.attributes.name}`, { - queries: updatedPackSO.attributes.queries, + queries: convertSOQueriesToPack(updatedPackSO.attributes.queries, { + removeMultiLines: true, + }), } ); diff --git a/x-pack/plugins/osquery/server/routes/pack/utils.test.ts b/x-pack/plugins/osquery/server/routes/pack/utils.test.ts new file mode 100644 index 0000000000000..97905fde6bf02 --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/pack/utils.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { convertSOQueriesToPack } from './utils'; + +const getTestQueries = (additionalFields?: Record, packName = 'default') => ({ + [packName]: { + ...additionalFields, + query: + 'select u.username,\n' + + ' p.pid,\n' + + ' p.name,\n' + + ' pos.local_address,\n' + + ' pos.local_port,\n' + + ' p.path,\n' + + ' p.cmdline,\n' + + ' pos.remote_address,\n' + + ' pos.remote_port\n' + + 'from processes as p\n' + + 'join users as u\n' + + ' on u.uid=p.uid\n' + + 'join process_open_sockets as pos\n' + + ' on pos.pid=p.pid\n' + + "where pos.remote_port !='0'\n" + + 'limit 1000;', + interval: 3600, + }, +}); + +const oneLiner = { + default: { + ecs_mapping: {}, + interval: 3600, + query: `select u.username, p.pid, p.name, pos.local_address, pos.local_port, p.path, p.cmdline, pos.remote_address, pos.remote_port from processes as p join users as u on u.uid=p.uid join process_open_sockets as pos on pos.pid=p.pid where pos.remote_port !='0' limit 1000;`, + }, +}; + +describe('Pack utils', () => { + describe('convertSOQueriesToPack', () => { + test('converts to pack with empty ecs_mapping', () => { + const convertedQueries = convertSOQueriesToPack(getTestQueries()); + expect(convertedQueries).toStrictEqual(getTestQueries({ ecs_mapping: {} })); + }); + test('converts to pack with converting query to single line', () => { + const convertedQueries = convertSOQueriesToPack(getTestQueries(), { removeMultiLines: true }); + expect(convertedQueries).toStrictEqual(oneLiner); + }); + test('converts to object with pack names after query.id', () => { + const convertedQueries = convertSOQueriesToPack(getTestQueries({ id: 'testId' })); + expect(convertedQueries).toStrictEqual(getTestQueries({ ecs_mapping: {} }, 'testId')); + }); + }); +}); diff --git a/x-pack/plugins/osquery/server/routes/pack/utils.ts b/x-pack/plugins/osquery/server/routes/pack/utils.ts index 9edb750263209..84466a6ce4ad1 100644 --- a/x-pack/plugins/osquery/server/routes/pack/utils.ts +++ b/x-pack/plugins/osquery/server/routes/pack/utils.ts @@ -6,6 +6,7 @@ */ import { pick, reduce } from 'lodash'; +import { removeMultilines } from '../../../common/utils/build_query/remove_multilines'; import { convertECSMappingToArray, convertECSMappingToObject } from '../utils'; // @ts-expect-error update types @@ -27,13 +28,15 @@ export const convertPackQueriesToSO = (queries) => ); // @ts-expect-error update types -export const convertSOQueriesToPack = (queries) => +export const convertSOQueriesToPack = (queries, options?: { removeMultiLines?: boolean }) => reduce( queries, // eslint-disable-next-line @typescript-eslint/naming-convention - (acc, { id: queryId, ecs_mapping, ...query }) => { - acc[queryId] = { - ...query, + (acc, { id: queryId, ecs_mapping, query, ...rest }, key) => { + const index = queryId ? queryId : key; + acc[index] = { + ...rest, + query: options?.removeMultiLines ? removeMultilines(query) : query, ecs_mapping: convertECSMappingToObject(ecs_mapping), }; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 258ea8b4bdeea..a9d350146c0d9 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -21991,9 +21991,6 @@ "xpack.osquery.packUploader.unsupportedFileTypeText": "Le type de fichier {fileType} n'est pas pris en charge, veuillez charger le fichier config {supportedFileTypes}", "xpack.osquery.permissionDeniedErrorMessage": "Vous n'êtes pas autorisé à accéder à cette page.", "xpack.osquery.permissionDeniedErrorTitle": "Autorisation refusée", - "xpack.osquery.queriesStatusTable.agentsLabelText": "{count, plural, one {Agent} other {Agents}}", - "xpack.osquery.queriesStatusTable.documentLabelText": "{count, plural, one {Document} other {Documents}}", - "xpack.osquery.queriesStatusTable.errorsLabelText": "{count, plural, one {Erreur} other {Erreurs}}", "xpack.osquery.queriesTable.osqueryVersionAllLabel": "TOUS", "xpack.osquery.queryFlyoutForm.addFormTitle": "Attacher la recherche suivante", "xpack.osquery.queryFlyoutForm.cancelButtonLabel": "Annuler", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 30b0d5ad9a48b..89813c1104606 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -22129,9 +22129,6 @@ "xpack.osquery.packUploader.unsupportedFileTypeText": "ファイルタイプ{fileType}はサポートされていません。{supportedFileTypes}構成ファイルをアップロードしてください", "xpack.osquery.permissionDeniedErrorMessage": "このページへのアクセスが許可されていません。", "xpack.osquery.permissionDeniedErrorTitle": "パーミッションが拒否されました", - "xpack.osquery.queriesStatusTable.agentsLabelText": "{count, plural, other {エージェント}}", - "xpack.osquery.queriesStatusTable.documentLabelText": "{count, plural, other {ドキュメント}}", - "xpack.osquery.queriesStatusTable.errorsLabelText": "{count, plural, other {エラー}}", "xpack.osquery.queriesTable.osqueryVersionAllLabel": "すべて", "xpack.osquery.queryFlyoutForm.addFormTitle": "次のクエリを関連付ける", "xpack.osquery.queryFlyoutForm.cancelButtonLabel": "キャンセル", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index cff374e41bf98..a9278d13031f4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -22160,9 +22160,6 @@ "xpack.osquery.packUploader.unsupportedFileTypeText": "文件类型 {fileType} 不受支持,请上传 {supportedFileTypes} 配置文件", "xpack.osquery.permissionDeniedErrorMessage": "您无权访问此页面。", "xpack.osquery.permissionDeniedErrorTitle": "权限被拒绝", - "xpack.osquery.queriesStatusTable.agentsLabelText": "{count, plural, other {代理}}", - "xpack.osquery.queriesStatusTable.documentLabelText": "{count, plural, other {文档}}", - "xpack.osquery.queriesStatusTable.errorsLabelText": "{count, plural, other {错误}}", "xpack.osquery.queriesTable.osqueryVersionAllLabel": "全部", "xpack.osquery.queryFlyoutForm.addFormTitle": "附加下一个查询", "xpack.osquery.queryFlyoutForm.cancelButtonLabel": "取消", diff --git a/x-pack/test/api_integration/apis/index.ts b/x-pack/test/api_integration/apis/index.ts index b3566ff30aea2..6bec2ebe80a13 100644 --- a/x-pack/test/api_integration/apis/index.ts +++ b/x-pack/test/api_integration/apis/index.ts @@ -35,5 +35,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./ml')); loadTestFile(require.resolve('./watcher')); loadTestFile(require.resolve('./logs_ui')); + loadTestFile(require.resolve('./osquery')); }); } diff --git a/x-pack/test/api_integration/apis/osquery/index.js b/x-pack/test/api_integration/apis/osquery/index.js new file mode 100644 index 0000000000000..afe684aa9bd68 --- /dev/null +++ b/x-pack/test/api_integration/apis/osquery/index.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export default function ({ loadTestFile }) { + describe('Osquery Endpoints', () => { + loadTestFile(require.resolve('./packs')); + }); +} diff --git a/x-pack/test/api_integration/apis/osquery/packs.ts b/x-pack/test/api_integration/apis/osquery/packs.ts new file mode 100644 index 0000000000000..543c01ac92c41 --- /dev/null +++ b/x-pack/test/api_integration/apis/osquery/packs.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +const getDefaultPack = ({ policyIds = [] }: { policyIds?: string[] }) => ({ + name: 'TestPack', + description: 'TestPack Description', + enabled: true, + policy_ids: policyIds, + queries: { + testQuery: { + query: multiLineQuery, + interval: 600, + platform: 'windows', + version: '1', + }, + }, +}); + +const singleLineQuery = + "select u.username, p.pid, p.name, pos.local_address, pos.local_port, p.path, p.cmdline, pos.remote_address, pos.remote_port from processes as p join users as u on u.uid=p.uid join process_open_sockets as pos on pos.pid=p.pid where pos.remote_port !='0' limit 1000;"; +const multiLineQuery = `select u.username, + p.pid, + p.name, + pos.local_address, + pos.local_port, + p.path, + p.cmdline, + pos.remote_address, + pos.remote_port +from processes as p +join users as u + on u.uid=p.uid +join process_open_sockets as pos + on pos.pid=p.pid +where pos.remote_port !='0' +limit 1000;`; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('Packs', () => { + let packId: string = ''; + let hostedPolicy: Record; + let packagePolicyId: string; + before(async () => { + await getService('esArchiver').load('x-pack/test/functional/es_archives/empty_kibana'); + await getService('esArchiver').load( + 'x-pack/test/functional/es_archives/fleet/empty_fleet_server' + ); + }); + after(async () => { + await getService('esArchiver').unload('x-pack/test/functional/es_archives/empty_kibana'); + await getService('esArchiver').unload( + 'x-pack/test/functional/es_archives/fleet/empty_fleet_server' + ); + }); + + after(async function () { + await supertest + .post(`/api/fleet/agent_policies/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ agentPolicyId: hostedPolicy.id }); + }); + + it('create route should return 200 and multi line query, but single line query in packs config', async () => { + const { + body: { item: agentPolicy }, + } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Hosted policy from ${Date.now()}`, + namespace: 'default', + }); + hostedPolicy = agentPolicy; + + const { + body: { item: packagePolicy }, + } = await supertest + .post('/api/fleet/package_policies') + .set('kbn-xsrf', 'true') + .send({ + enabled: true, + package: { + name: 'osquery_manager', + version: '1.2.1', + title: 'test', + }, + inputs: [], + namespace: 'default', + output_id: '', + policy_id: hostedPolicy.id, + name: 'TEST', + description: '123', + id: '123', + }); + packagePolicyId = packagePolicy.id; + + const createPackResponse = await supertest + .post('/internal/osquery/packs') + .set('kbn-xsrf', 'true') + .send(getDefaultPack({ policyIds: [hostedPolicy.id] })); + + packId = createPackResponse.body.id; + expect(createPackResponse.status).to.be(200); + + const pack = await supertest.get('/internal/osquery/packs/' + packId).set('kbn-xsrf', 'true'); + + expect(pack.status).to.be(200); + expect(pack.body.queries.testQuery.query).to.be(multiLineQuery); + + const { + body: { + item: { inputs }, + }, + } = await supertest.get(`/api/fleet/package_policies/${packagePolicyId}`); + + expect(inputs[0].config.osquery.value.packs.TestPack.queries.testQuery.query).to.be( + singleLineQuery + ); + }); + + it('update route should return 200 and multi line query, but single line query in packs config', async () => { + const updatePackResponse = await supertest + .put('/internal/osquery/packs/' + packId) + .set('kbn-xsrf', 'true') + .send(getDefaultPack({ policyIds: [hostedPolicy.id] })); + + expect(updatePackResponse.status).to.be(200); + expect(updatePackResponse.body.id).to.be(packId); + const pack = await supertest.get('/internal/osquery/packs/' + packId).set('kbn-xsrf', 'true'); + + expect(pack.body.queries.testQuery.query).to.be(multiLineQuery); + const { + body: { + item: { inputs }, + }, + } = await supertest.get(`/api/fleet/package_policies/${packagePolicyId}`); + + expect(inputs[0].config.osquery.value.packs.TestPack.queries.testQuery.query).to.be( + singleLineQuery + ); + }); + }); +} From fdf2086eb0caace2092ea9a1cdb1066979d678fc Mon Sep 17 00:00:00 2001 From: Dmitry Tomashevich <39378793+Dmitriynj@users.noreply.github.com> Date: Thu, 19 May 2022 10:24:55 +0300 Subject: [PATCH 09/37] [Discover] Cancel long running requests in Discover alert (#130077) * [Discover] improve long running requests for search source within alert rule * [Discover] add tests * [Discover] fix linting * [Discover] fix unit test * [Discover] add getMetrics test * [Discover] fix unit test * [Discover] merge search clients metrics * [Discover] wrap searchSourceClient * [Discover] add unit tests * [Discover] replace searchSourceUtils with searchSourceClient in tests * [Discover] apply suggestions --- x-pack/plugins/alerting/server/lib/types.ts | 15 ++ .../server/lib/wrap_scoped_cluster_client.ts | 11 +- .../lib/wrap_search_source_client.test.ts | 157 ++++++++++++++++ .../server/lib/wrap_search_source_client.ts | 174 ++++++++++++++++++ x-pack/plugins/alerting/server/mocks.ts | 9 +- .../server/task_runner/task_runner.ts | 32 +++- x-pack/plugins/alerting/server/types.ts | 8 +- .../utils/create_lifecycle_rule_type.test.ts | 2 +- .../server/utils/rule_executor_test_utils.ts | 9 +- .../routes/rules/preview_rules_route.ts | 8 +- .../utils/wrap_search_source_client.test.ts | 108 +++++++++++ .../rules/utils/wrap_search_source_client.ts | 120 ++++++++++++ .../server/alert_types/es_query/executor.ts | 5 +- .../es_query/lib/fetch_search_source_query.ts | 6 +- 14 files changed, 622 insertions(+), 42 deletions(-) create mode 100644 x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts create mode 100644 x-pack/plugins/alerting/server/lib/wrap_search_source_client.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_search_source_client.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_search_source_client.ts diff --git a/x-pack/plugins/alerting/server/lib/types.ts b/x-pack/plugins/alerting/server/lib/types.ts index 701ac32e6974e..173ba1119a72a 100644 --- a/x-pack/plugins/alerting/server/lib/types.ts +++ b/x-pack/plugins/alerting/server/lib/types.ts @@ -7,6 +7,9 @@ import * as t from 'io-ts'; import { either } from 'fp-ts/lib/Either'; +import { Rule } from '../types'; +import { RuleRunMetrics } from './rule_run_metrics_store'; + // represents a Date from an ISO string export const DateFromString = new t.Type( 'DateFromString', @@ -24,3 +27,15 @@ export const DateFromString = new t.Type( ), (valueToEncode) => valueToEncode.toISOString() ); + +export type RuleInfo = Pick & { spaceId: string }; + +export interface LogSearchMetricsOpts { + esSearchDuration: number; + totalSearchDuration: number; +} + +export type SearchMetrics = Pick< + RuleRunMetrics, + 'numSearches' | 'totalSearchDurationMs' | 'esSearchDurationMs' +>; diff --git a/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts b/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts index 28c5301e9a8b9..e1156d177116c 100644 --- a/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts +++ b/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts @@ -20,15 +20,8 @@ import type { SearchRequest as SearchRequestWithBody, AggregationsAggregate, } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { IScopedClusterClient, ElasticsearchClient, Logger } from '@kbn/core/server'; -import { Rule } from '../types'; -import { RuleRunMetrics } from './rule_run_metrics_store'; - -type RuleInfo = Pick & { spaceId: string }; -type SearchMetrics = Pick< - RuleRunMetrics, - 'numSearches' | 'totalSearchDurationMs' | 'esSearchDurationMs' ->; +import type { IScopedClusterClient, ElasticsearchClient, Logger } from '@kbn/core/server'; +import { SearchMetrics, RuleInfo } from './types'; interface WrapScopedClusterClientFactoryOpts { scopedClusterClient: IScopedClusterClient; diff --git a/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts b/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts new file mode 100644 index 0000000000000..9c10e619e3ebb --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts @@ -0,0 +1,157 @@ +/* + * Copyright 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 { loggingSystemMock } from '@kbn/core/server/mocks'; +import { ISearchStartSearchSource } from '@kbn/data-plugin/common'; +import { createSearchSourceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; +import { of, throwError } from 'rxjs'; +import { wrapSearchSourceClient } from './wrap_search_source_client'; + +const logger = loggingSystemMock.create().get(); + +const rule = { + name: 'test-rule', + alertTypeId: '.test-rule-type', + id: 'abcdefg', + spaceId: 'my-space', +}; + +const createSearchSourceClientMock = () => { + const searchSourceMock = createSearchSourceMock(); + searchSourceMock.fetch$ = jest.fn().mockImplementation(() => of({ rawResponse: { took: 5 } })); + + return { + searchSourceMock, + searchSourceClientMock: { + create: jest.fn().mockReturnValue(searchSourceMock), + createEmpty: jest.fn().mockReturnValue(searchSourceMock), + } as unknown as ISearchStartSearchSource, + }; +}; + +describe('wrapSearchSourceClient', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('searches with provided abort controller', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + + const { searchSourceClient } = wrapSearchSourceClient({ + logger, + rule, + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await searchSourceClient.createEmpty(); + await wrappedSearchSource.fetch(); + + expect(searchSourceMock.fetch$).toHaveBeenCalledWith({ + abortSignal: abortController.signal, + }); + }); + + test('uses search options when specified', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + + const { searchSourceClient } = wrapSearchSourceClient({ + logger, + rule, + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await searchSourceClient.create(); + await wrappedSearchSource.fetch({ isStored: true }); + + expect(searchSourceMock.fetch$).toHaveBeenCalledWith({ + isStored: true, + abortSignal: abortController.signal, + }); + }); + + test('keeps track of number of queries', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + searchSourceMock.fetch$ = jest + .fn() + .mockImplementation(() => of({ rawResponse: { took: 333 } })); + + const { searchSourceClient, getMetrics } = wrapSearchSourceClient({ + logger, + rule, + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await searchSourceClient.create(); + await wrappedSearchSource.fetch(); + await wrappedSearchSource.fetch(); + await wrappedSearchSource.fetch(); + + expect(searchSourceMock.fetch$).toHaveBeenCalledWith({ + abortSignal: abortController.signal, + }); + + const stats = getMetrics(); + expect(stats.numSearches).toEqual(3); + expect(stats.esSearchDurationMs).toEqual(999); + + expect(logger.debug).toHaveBeenCalledWith( + `executing query for rule .test-rule-type:abcdefg in space my-space - with options {}` + ); + }); + + test('re-throws error when search throws error', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + searchSourceMock.fetch$ = jest + .fn() + .mockReturnValue(throwError(new Error('something went wrong!'))); + + const { searchSourceClient } = wrapSearchSourceClient({ + logger, + rule, + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await searchSourceClient.create(); + const fetch = wrappedSearchSource.fetch(); + + await expect(fetch).rejects.toThrowErrorMatchingInlineSnapshot('"something went wrong!"'); + }); + + test('throws error when search throws abort error', async () => { + const abortController = new AbortController(); + abortController.abort(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + searchSourceMock.fetch$ = jest + .fn() + .mockReturnValue(throwError(new Error('Request has been aborted by the user'))); + + const { searchSourceClient } = wrapSearchSourceClient({ + logger, + rule, + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await searchSourceClient.create(); + const fetch = wrappedSearchSource.fetch(); + + await expect(fetch).rejects.toThrowErrorMatchingInlineSnapshot( + '"Search has been aborted due to cancelled execution"' + ); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/wrap_search_source_client.ts b/x-pack/plugins/alerting/server/lib/wrap_search_source_client.ts new file mode 100644 index 0000000000000..442f0c3e292bf --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/wrap_search_source_client.ts @@ -0,0 +1,174 @@ +/* + * Copyright 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 { Logger } from '@kbn/core/server'; +import { + ISearchOptions, + ISearchSource, + ISearchStartSearchSource, + SearchSource, + SerializedSearchSourceFields, +} from '@kbn/data-plugin/common'; +import { catchError, tap, throwError } from 'rxjs'; +import { LogSearchMetricsOpts, RuleInfo, SearchMetrics } from './types'; + +interface Props { + logger: Logger; + rule: RuleInfo; + abortController: AbortController; + searchSourceClient: ISearchStartSearchSource; +} + +interface WrapParams { + logger: Logger; + rule: RuleInfo; + abortController: AbortController; + pureSearchSource: T; + logMetrics: (metrics: LogSearchMetricsOpts) => void; +} + +export function wrapSearchSourceClient({ + logger, + rule, + abortController, + searchSourceClient: pureSearchSourceClient, +}: Props) { + let numSearches: number = 0; + let esSearchDurationMs: number = 0; + let totalSearchDurationMs: number = 0; + + function logMetrics(metrics: LogSearchMetricsOpts) { + numSearches++; + esSearchDurationMs += metrics.esSearchDuration; + totalSearchDurationMs += metrics.totalSearchDuration; + } + + const wrapParams = { + logMetrics, + logger, + rule, + abortController, + }; + + const wrappedSearchSourceClient: ISearchStartSearchSource = Object.create(pureSearchSourceClient); + + wrappedSearchSourceClient.createEmpty = () => { + const pureSearchSource = pureSearchSourceClient.createEmpty(); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource, + }); + }; + + wrappedSearchSourceClient.create = async (fields?: SerializedSearchSourceFields) => { + const pureSearchSource = await pureSearchSourceClient.create(fields); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource, + }); + }; + + return { + searchSourceClient: wrappedSearchSourceClient, + getMetrics: (): SearchMetrics => ({ + esSearchDurationMs, + totalSearchDurationMs, + numSearches, + }), + }; +} + +function wrapSearchSource({ + pureSearchSource, + ...wrapParams +}: WrapParams): T { + const wrappedSearchSource = Object.create(pureSearchSource); + + wrappedSearchSource.createChild = wrapCreateChild({ ...wrapParams, pureSearchSource }); + wrappedSearchSource.createCopy = wrapCreateCopy({ ...wrapParams, pureSearchSource }); + wrappedSearchSource.create = wrapCreate({ ...wrapParams, pureSearchSource }); + wrappedSearchSource.fetch$ = wrapFetch$({ ...wrapParams, pureSearchSource }); + + return wrappedSearchSource; +} + +function wrapCreate({ pureSearchSource, ...wrapParams }: WrapParams) { + return function () { + const pureCreatedSearchSource = pureSearchSource.create(); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource: pureCreatedSearchSource, + }); + }; +} + +function wrapCreateChild({ pureSearchSource, ...wrapParams }: WrapParams) { + return function (options?: {}) { + const pureSearchSourceChild = pureSearchSource.createChild(options); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource: pureSearchSourceChild, + }); + }; +} + +function wrapCreateCopy({ pureSearchSource, ...wrapParams }: WrapParams) { + return function () { + const pureSearchSourceChild = pureSearchSource.createCopy(); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource: pureSearchSourceChild, + }) as SearchSource; + }; +} + +function wrapFetch$({ + logger, + rule, + abortController, + pureSearchSource, + logMetrics, +}: WrapParams) { + return (options?: ISearchOptions) => { + const searchOptions = options ?? {}; + const start = Date.now(); + + logger.debug( + `executing query for rule ${rule.alertTypeId}:${rule.id} in space ${ + rule.spaceId + } - with options ${JSON.stringify(searchOptions)}` + ); + + return pureSearchSource + .fetch$({ + ...searchOptions, + abortSignal: abortController.signal, + }) + .pipe( + catchError((error) => { + if (abortController.signal.aborted) { + return throwError( + () => new Error('Search has been aborted due to cancelled execution') + ); + } + return throwError(() => error); + }), + tap((result) => { + const durationMs = Date.now() - start; + logMetrics({ + esSearchDuration: result.rawResponse.took ?? 0, + totalSearchDuration: durationMs, + }); + }) + ); + }; +} diff --git a/x-pack/plugins/alerting/server/mocks.ts b/x-pack/plugins/alerting/server/mocks.ts index f7525c2c5f570..fd554783111d2 100644 --- a/x-pack/plugins/alerting/server/mocks.ts +++ b/x-pack/plugins/alerting/server/mocks.ts @@ -9,9 +9,8 @@ import { elasticsearchServiceMock, savedObjectsClientMock, uiSettingsServiceMock, - httpServerMock, } from '@kbn/core/server/mocks'; -import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; +import { searchSourceCommonMock } from '@kbn/data-plugin/common/search/search_source/mocks'; import { rulesClientMock } from './rules_client.mock'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { Alert, AlertFactoryDoneUtils } from './alert'; @@ -113,11 +112,7 @@ const createRuleExecutorServicesMock = < shouldWriteAlerts: () => true, shouldStopExecution: () => true, search: createAbortableSearchServiceMock(), - searchSourceClient: Promise.resolve( - dataPluginMock - .createStartContract() - .search.searchSource.asScoped(httpServerMock.createKibanaRequest()) - ), + searchSourceClient: searchSourceCommonMock, }; }; export type RuleExecutorServicesMock = ReturnType; 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 525c252b40b66..bd83b269ce10d 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -17,7 +17,6 @@ import { TaskRunnerContext } from './task_runner_factory'; import { createExecutionHandler, ExecutionHandler } from './create_execution_handler'; import { Alert, createAlertFactory } from '../alert'; import { - createWrappedScopedClusterClientFactory, ElasticsearchError, ErrorWithReason, executionStatusFromError, @@ -69,9 +68,12 @@ import { RuleRunResult, RuleTaskStateAndMetrics, } from './types'; +import { createWrappedScopedClusterClientFactory } from '../lib/wrap_scoped_cluster_client'; import { IExecutionStatusAndMetrics } from '../lib/rule_execution_status'; import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { wrapSearchSourceClient } from '../lib/wrap_search_source_client'; import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; +import { SearchMetrics } from '../lib/types'; const FALLBACK_RETRY_INTERVAL = '5m'; const CONNECTIVITY_RETRY_INTERVAL = '5m'; @@ -337,9 +339,7 @@ export class TaskRunner< const ruleLabel = `${this.ruleType.id}:${ruleId}: '${name}'`; - const scopedClusterClient = this.context.elasticsearch.client.asScoped(fakeRequest); - const wrappedScopedClusterClient = createWrappedScopedClusterClientFactory({ - scopedClusterClient, + const wrappedClientOptions = { rule: { name: rule.name, alertTypeId: rule.alertTypeId, @@ -348,6 +348,16 @@ export class TaskRunner< }, logger: this.logger, abortController: this.searchAbortController, + }; + const scopedClusterClient = this.context.elasticsearch.client.asScoped(fakeRequest); + const wrappedScopedClusterClient = createWrappedScopedClusterClientFactory({ + ...wrappedClientOptions, + scopedClusterClient, + }); + const searchSourceClient = await this.context.data.search.searchSource.asScoped(fakeRequest); + const wrappedSearchSourceClient = wrapSearchSourceClient({ + ...wrappedClientOptions, + searchSourceClient, }); let updatedRuleTypeState: void | Record; @@ -371,9 +381,9 @@ export class TaskRunner< executionId: this.executionId, services: { savedObjectsClient, + searchSourceClient: wrappedSearchSourceClient.searchSourceClient, uiSettingsClient: this.context.uiSettings.asScopedToClient(savedObjectsClient), scopedClusterClient: wrappedScopedClusterClient.client(), - searchSourceClient: this.context.data.search.searchSource.asScoped(fakeRequest), alertFactory: createAlertFactory< InstanceState, InstanceContext, @@ -426,9 +436,19 @@ export class TaskRunner< this.alertingEventLogger.setExecutionSucceeded(`rule executed: ${ruleLabel}`); + const scopedClusterClientMetrics = wrappedScopedClusterClient.getMetrics(); + const searchSourceClientMetrics = wrappedSearchSourceClient.getMetrics(); + const searchMetrics: SearchMetrics = { + numSearches: scopedClusterClientMetrics.numSearches + searchSourceClientMetrics.numSearches, + totalSearchDurationMs: + scopedClusterClientMetrics.totalSearchDurationMs + + searchSourceClientMetrics.totalSearchDurationMs, + esSearchDurationMs: + scopedClusterClientMetrics.esSearchDurationMs + + searchSourceClientMetrics.esSearchDurationMs, + }; const ruleRunMetricsStore = new RuleRunMetricsStore(); - const searchMetrics = wrappedScopedClusterClient.getMetrics(); ruleRunMetricsStore.setNumSearches(searchMetrics.numSearches); ruleRunMetricsStore.setTotalSearchDurationMs(searchMetrics.totalSearchDurationMs); ruleRunMetricsStore.setEsSearchDurationMs(searchMetrics.esSearchDurationMs); diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 7b1725e42bd5e..b7e06aa602f27 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -10,13 +10,15 @@ import type { CustomRequestHandlerContext, SavedObjectReference, IUiSettingsClient, +} from '@kbn/core/server'; +import { ISearchStartSearchSource } from '@kbn/data-plugin/common'; +import { LicenseType } from '@kbn/licensing-plugin/server'; +import { IScopedClusterClient, SavedObjectAttributes, SavedObjectsClientContract, } from '@kbn/core/server'; import type { PublicMethodsOf } from '@kbn/utility-types'; -import { ISearchStartSearchSource } from '@kbn/data-plugin/common'; -import { LicenseType } from '@kbn/licensing-plugin/server'; import { AlertFactoryDoneUtils, PublicAlert } from './alert'; import { RuleTypeRegistry as OrigruleTypeRegistry } from './rule_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; @@ -72,7 +74,7 @@ export interface RuleExecutorServices< InstanceContext extends AlertInstanceContext = AlertInstanceContext, ActionGroupIds extends string = never > { - searchSourceClient: Promise; + searchSourceClient: ISearchStartSearchSource; savedObjectsClient: SavedObjectsClientContract; uiSettingsClient: IUiSettingsClient; scopedClusterClient: IScopedClusterClient; diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts index 7894478aedf22..9387a9ce8c0ed 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts @@ -118,7 +118,7 @@ function createRule(shouldWriteAlerts: boolean = true) { shouldWriteAlerts: () => shouldWriteAlerts, shouldStopExecution: () => false, search: {} as any, - searchSourceClient: Promise.resolve({} as ISearchStartSearchSource), + searchSourceClient: {} as ISearchStartSearchSource, }, spaceId: 'spaceId', state, diff --git a/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts b/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts index 05c069d80ed3e..b2c25973f7cc4 100644 --- a/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts +++ b/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts @@ -7,7 +7,6 @@ import { elasticsearchServiceMock, savedObjectsClientMock, - httpServerMock, uiSettingsServiceMock, } from '@kbn/core/server/mocks'; import { @@ -18,7 +17,7 @@ import { RuleTypeState, } from '@kbn/alerting-plugin/server'; import { alertsMock } from '@kbn/alerting-plugin/server/mocks'; -import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; +import { searchSourceCommonMock } from '@kbn/data-plugin/common/search/search_source/mocks'; export const createDefaultAlertExecutorOptions = < Params extends RuleTypeParams = never, @@ -77,11 +76,7 @@ export const createDefaultAlertExecutorOptions = < scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), shouldWriteAlerts: () => shouldWriteAlerts, shouldStopExecution: () => false, - searchSourceClient: Promise.resolve( - dataPluginMock - .createStartContract() - .search.searchSource.asScoped(httpServerMock.createKibanaRequest()) - ), + searchSourceClient: searchSourceCommonMock, }, state, updatedBy: null, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts index 00fc13315ff36..de60e82e336ef 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts @@ -54,6 +54,7 @@ import { import { createSecurityRuleTypeWrapper } from '../../rule_types/create_security_rule_type_wrapper'; import { RULE_PREVIEW_INVOCATION_COUNT } from '../../../../../common/detection_engine/constants'; import { RuleExecutionContext, StatusChangeArgs } from '../../rule_execution_log'; +import { wrapSearchSourceClient } from './utils/wrap_search_source_client'; const PREVIEW_TIMEOUT_SECONDS = 60; @@ -86,7 +87,7 @@ export const previewRulesRoute = async ( } try { const [, { data, security: securityService }] = await getStartServices(); - const searchSourceClient = data.search.searchSource.asScoped(request); + const searchSourceClient = await data.search.searchSource.asScoped(request); const savedObjectsClient = coreContext.savedObjects.client; const siemClient = (await context.securitySolution).getAppClient(); @@ -242,7 +243,10 @@ export const previewRulesRoute = async ( abortController, scopedClusterClient: coreContext.elasticsearch.client, }), - searchSourceClient, + searchSourceClient: wrapSearchSourceClient({ + abortController, + searchSourceClient, + }), uiSettingsClient: coreContext.uiSettings.client, }, spaceId, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_search_source_client.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_search_source_client.test.ts new file mode 100644 index 0000000000000..c8fff85476957 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_search_source_client.test.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 { ISearchStartSearchSource } from '@kbn/data-plugin/common'; +import { createSearchSourceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; +import { of, throwError } from 'rxjs'; +import { wrapSearchSourceClient } from './wrap_search_source_client'; + +const createSearchSourceClientMock = () => { + const searchSourceMock = createSearchSourceMock(); + searchSourceMock.fetch$ = jest.fn().mockImplementation(() => of({})); + + return { + searchSourceMock, + searchSourceClientMock: { + create: jest.fn().mockReturnValue(searchSourceMock), + createEmpty: jest.fn().mockReturnValue(searchSourceMock), + } as unknown as ISearchStartSearchSource, + }; +}; + +describe('wrapSearchSourceClient', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('searches with provided abort controller', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + + const wrappedSearchClient = wrapSearchSourceClient({ + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await wrappedSearchClient.createEmpty(); + await wrappedSearchSource.fetch(); + + expect(searchSourceMock.fetch$).toHaveBeenCalledWith({ + abortSignal: abortController.signal, + }); + }); + + test('uses search options when specified', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + + const wrappedSearchClient = wrapSearchSourceClient({ + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await wrappedSearchClient.create(); + await wrappedSearchSource.fetch({ isStored: true }); + + expect(searchSourceMock.fetch$).toHaveBeenCalledWith({ + isStored: true, + abortSignal: abortController.signal, + }); + }); + + test('re-throws error when search throws error', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + searchSourceMock.fetch$ = jest + .fn() + .mockReturnValue(throwError(new Error('something went wrong!'))); + + const wrappedSearchClient = wrapSearchSourceClient({ + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await wrappedSearchClient.create(); + const fetch = wrappedSearchSource.fetch(); + + await expect(fetch).rejects.toThrowErrorMatchingInlineSnapshot('"something went wrong!"'); + }); + + test('throws error when search throws abort error', async () => { + const abortController = new AbortController(); + abortController.abort(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + searchSourceMock.fetch$ = jest + .fn() + .mockReturnValue(throwError(new Error('Request has been aborted by the user'))); + + const wrappedSearchClient = wrapSearchSourceClient({ + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await wrappedSearchClient.create(); + const fetch = wrappedSearchSource.fetch(); + + await expect(fetch).rejects.toThrowErrorMatchingInlineSnapshot( + '"Search has been aborted due to cancelled execution"' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_search_source_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_search_source_client.ts new file mode 100644 index 0000000000000..619a4dee788f7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/wrap_search_source_client.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 { + ISearchOptions, + ISearchSource, + ISearchStartSearchSource, + SearchSource, + SerializedSearchSourceFields, +} from '@kbn/data-plugin/common'; +import { catchError, throwError } from 'rxjs'; + +interface Props { + abortController: AbortController; + searchSourceClient: ISearchStartSearchSource; +} + +interface WrapParams { + abortController: AbortController; + pureSearchSource: T; +} + +export function wrapSearchSourceClient({ + abortController, + searchSourceClient: pureSearchSourceClient, +}: Props) { + const wrappedSearchSourceClient: ISearchStartSearchSource = Object.create(pureSearchSourceClient); + + wrappedSearchSourceClient.createEmpty = () => { + const pureSearchSource = pureSearchSourceClient.createEmpty(); + + return wrapSearchSource({ + abortController, + pureSearchSource, + }); + }; + + wrappedSearchSourceClient.create = async (fields?: SerializedSearchSourceFields) => { + const pureSearchSource = await pureSearchSourceClient.create(fields); + + return wrapSearchSource({ + abortController, + pureSearchSource, + }); + }; + + return wrappedSearchSourceClient; +} + +function wrapSearchSource({ + pureSearchSource, + ...wrapParams +}: WrapParams): T { + const wrappedSearchSource = Object.create(pureSearchSource); + + wrappedSearchSource.createChild = wrapCreateChild({ ...wrapParams, pureSearchSource }); + wrappedSearchSource.createCopy = wrapCreateCopy({ ...wrapParams, pureSearchSource }); + wrappedSearchSource.create = wrapCreate({ ...wrapParams, pureSearchSource }); + wrappedSearchSource.fetch$ = wrapFetch$({ ...wrapParams, pureSearchSource }); + + return wrappedSearchSource; +} + +function wrapCreate({ pureSearchSource, ...wrapParams }: WrapParams) { + return function () { + const pureCreatedSearchSource = pureSearchSource.create(); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource: pureCreatedSearchSource, + }); + }; +} + +function wrapCreateChild({ pureSearchSource, ...wrapParams }: WrapParams) { + return function (options?: {}) { + const pureSearchSourceChild = pureSearchSource.createChild(options); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource: pureSearchSourceChild, + }); + }; +} + +function wrapCreateCopy({ pureSearchSource, ...wrapParams }: WrapParams) { + return function () { + const pureSearchSourceChild = pureSearchSource.createCopy(); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource: pureSearchSourceChild, + }) as SearchSource; + }; +} + +function wrapFetch$({ abortController, pureSearchSource }: WrapParams) { + return (options?: ISearchOptions) => { + const searchOptions = options ?? {}; + return pureSearchSource + .fetch$({ + ...searchOptions, + abortSignal: abortController.signal, + }) + .pipe( + catchError((error) => { + if (abortController.signal.aborted) { + return throwError( + () => new Error('Search has been aborted due to cancelled execution') + ); + } + return throwError(() => error); + }) + ); + }; +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts index 4f203b064592d..44708a1df90fd 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts @@ -51,10 +51,7 @@ export async function executor( alertId, params as OnlySearchSourceAlertParams, latestTimestamp, - { - searchSourceClient, - logger, - } + { searchSourceClient, logger } ); // apply the alert condition diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/lib/fetch_search_source_query.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/lib/fetch_search_source_query.ts index cff24f8975f0f..66e5ae8023a47 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/lib/fetch_search_source_query.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/lib/fetch_search_source_query.ts @@ -20,12 +20,12 @@ export async function fetchSearchSourceQuery( latestTimestamp: string | undefined, services: { logger: Logger; - searchSourceClient: Promise; + searchSourceClient: ISearchStartSearchSource; } ) { const { logger, searchSourceClient } = services; - const client = await searchSourceClient; - const initialSearchSource = await client.create(params.searchConfiguration); + + const initialSearchSource = await searchSourceClient.create(params.searchConfiguration); const { searchSource, dateStart, dateEnd } = updateSearchSource( initialSearchSource, From 12509f78c62252e1284f21e8033131de72bcb75e Mon Sep 17 00:00:00 2001 From: Faisal Kanout Date: Thu, 19 May 2022 10:33:17 +0300 Subject: [PATCH 10/37] Show "No actions" message instead of 0 (#132445) --- .../public/pages/rule_details/components/actions.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx index e3aadb60f8c4c..5a692e570281a 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx @@ -15,6 +15,7 @@ import { EuiLoadingSpinner, } from '@elastic/eui'; import { intersectionBy } from 'lodash'; +import { i18n } from '@kbn/i18n'; import { ActionsProps } from '../types'; import { useFetchRuleActions } from '../../../hooks/use_fetch_rule_actions'; import { useKibana } from '../../../utils/kibana_react'; @@ -37,7 +38,16 @@ export function Actions({ ruleActions }: ActionsProps) { notifications: { toasts }, } = useKibana().services; const { isLoadingActions, allActions, errorActions } = useFetchRuleActions({ http }); - if (ruleActions && ruleActions.length <= 0) return 0; + if (ruleActions && ruleActions.length <= 0) + return ( + + + {i18n.translate('xpack.observability.ruleDetails.noActions', { + defaultMessage: 'No actions', + })} + + + ); const actions = intersectionBy(allActions, ruleActions, 'actionTypeId'); if (isLoadingActions) return ; return ( From 1660bd9013a8c8df41e8d5992b5f78dea7b5cf92 Mon Sep 17 00:00:00 2001 From: Nodir Latipov Date: Thu, 19 May 2022 12:40:10 +0500 Subject: [PATCH 11/37] [Unified Search] Hide 'Include time filter' checkbox when Data view has no time field (#131276) * feat: add hide 'Include time filter' checkbox, if index pattern has no time field * feat: added checking DataView exists and has any element * fix: added a check for the absence of a timeFieldName value for each dataViews * feat: changed logic for check DataViews have value TimeFieldName * refactor: shouldRenderTimeFilterInSavedQueryForm * refact: minor * refact: minor * Update src/plugins/unified_search/public/search_bar/search_bar.tsx --- .../public/search_bar/search_bar.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index a8681319ebc21..a6ca444612402 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -10,8 +10,8 @@ import { compact } from 'lodash'; import { InjectedIntl, injectI18n } from '@kbn/i18n-react'; import classNames from 'classnames'; import React, { Component } from 'react'; -import { get, isEqual } from 'lodash'; import { EuiIconProps, withEuiTheme, WithEuiThemeProps } from '@elastic/eui'; +import { get, isEqual } from 'lodash'; import memoizeOne from 'memoize-one'; import { METRIC_TYPE } from '@kbn/analytics'; @@ -213,11 +213,18 @@ class SearchBarUI extends Component { * in case you the date range (from/to) */ private shouldRenderTimeFilterInSavedQueryForm() { - const { dateRangeFrom, dateRangeTo, showDatePicker } = this.props; - return ( - showDatePicker || - (!showDatePicker && dateRangeFrom !== undefined && dateRangeTo !== undefined) - ); + const { dateRangeFrom, dateRangeTo, showDatePicker, indexPatterns } = this.props; + + if (!showDatePicker && dateRangeFrom !== undefined && dateRangeTo !== undefined) { + return false; + } + + if (indexPatterns?.length) { + // return true if at least one of the DateView has timeFieldName + return indexPatterns.some((dataView) => Boolean(dataView.timeFieldName)); + } + + return true; } public onSave = async (savedQueryMeta: SavedQueryMeta, saveAsNew = false) => { From 59120c9340499c5b8e17e45178a427df15c687e2 Mon Sep 17 00:00:00 2001 From: Pete Harverson Date: Thu, 19 May 2022 09:17:51 +0100 Subject: [PATCH 12/37] [ML] Transforms: Fix width of icon column in Messages table (#132444) --- .../components/transform_list/expanded_row_messages_pane.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_messages_pane.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_messages_pane.tsx index 093f6da2233da..2261b399e5ff9 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_messages_pane.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_messages_pane.tsx @@ -81,7 +81,7 @@ export const ExpandedRowMessagesPane: React.FC = ({ transformId }) => { { name: '', render: (message: TransformMessage) => , - width: `${theme.euiSizeXL}px`, + width: theme.euiSizeXL, }, { name: i18n.translate( From b7866ac7f07409b9f14a62538a02811a84272a61 Mon Sep 17 00:00:00 2001 From: Muhammad Ibragimov <53621505+mibragimov@users.noreply.github.com> Date: Thu, 19 May 2022 14:30:59 +0500 Subject: [PATCH 13/37] [Console] Refactor retrieval of mappings, aliases, templates, data-streams for autocomplete (#130633) * Create a specific route for fetching mappings, aliases, templates, etc... * Encapsulate data streams * Encapsulate the mappings data into a class * Setup up autocompleteInfo service and provide its instance through context * Migrate the logic from mappings.js to Kibana server * Translate the logic to consume the appropriate ES client method * Update related test cases * Lint * Address comments * Fix server proxy/mock * Add API integration tests for /api/console/autocomplete_entities * Lint * Add tests * Add API integration tests for autocomplete_entities API * Add deleted tests Co-authored-by: Muhammad Ibragimov Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- api_docs/deprecations_by_plugin.mdx | 4 +- .../console_editor/editor.test.mock.tsx | 4 - .../editor/legacy/console_editor/editor.tsx | 16 +- .../application/containers/settings.tsx | 94 ++-- .../contexts/services_context.mock.ts | 2 + .../application/contexts/services_context.tsx | 3 +- .../use_send_current_request.ts | 17 +- .../console/public/application/index.tsx | 5 +- .../models/sense_editor/integration.test.js | 12 +- ...mponent_template_autocomplete_component.js | 4 +- .../data_stream_autocomplete_component.js | 4 +- .../field_autocomplete_component.js | 4 +- .../index_autocomplete_component.js | 6 +- .../index_template_autocomplete_component.js | 4 +- .../legacy_template_autocomplete_component.js | 4 +- .../components/type_autocomplete_component.js | 2 +- .../username_autocomplete_component.js | 6 +- .../public/lib/autocomplete_entities/alias.ts | 65 +++ .../autocomplete_entities.test.js | 315 ++++++++++++++ .../autocomplete_entities/base_template.ts | 21 + .../component_template.ts | 16 + .../lib/autocomplete_entities/data_stream.ts | 25 ++ .../autocomplete_entities/expand_aliases.ts | 41 ++ .../public/lib/autocomplete_entities/index.ts | 15 + .../autocomplete_entities/index_template.ts | 16 + .../lib/autocomplete_entities/legacy/index.ts | 9 + .../legacy/legacy_template.ts | 16 + .../lib/autocomplete_entities/mapping.ts | 164 +++++++ .../public/lib/autocomplete_entities/type.ts | 44 ++ .../public/lib/autocomplete_entities/types.ts | 39 ++ src/plugins/console/public/lib/kb/kb.test.js | 14 +- .../public/lib/mappings/mapping.test.js | 278 ------------ .../console/public/lib/mappings/mappings.js | 410 ------------------ src/plugins/console/public/plugin.ts | 6 + .../public/services/autocomplete.mock.ts | 17 + .../console/public/services/autocomplete.ts | 107 +++++ src/plugins/console/public/services/index.ts | 1 + src/plugins/console/server/plugin.ts | 4 + .../console/autocomplete_entities/index.ts | 9 + .../register_get_route.ts | 95 ++++ .../register_mappings_route.ts | 14 + .../server/routes/api/console/proxy/mocks.ts | 2 + src/plugins/console/server/routes/index.ts | 6 + src/plugins/console/server/shared_imports.ts | 9 + .../apis/console/autocomplete_entities.ts | 133 ++++++ test/api_integration/apis/console/index.ts | 1 + 46 files changed, 1306 insertions(+), 777 deletions(-) create mode 100644 src/plugins/console/public/lib/autocomplete_entities/alias.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js create mode 100644 src/plugins/console/public/lib/autocomplete_entities/base_template.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/component_template.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/data_stream.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/expand_aliases.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/index.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/index_template.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/legacy/index.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/legacy/legacy_template.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/mapping.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/type.ts create mode 100644 src/plugins/console/public/lib/autocomplete_entities/types.ts delete mode 100644 src/plugins/console/public/lib/mappings/mapping.test.js delete mode 100644 src/plugins/console/public/lib/mappings/mappings.js create mode 100644 src/plugins/console/public/services/autocomplete.mock.ts create mode 100644 src/plugins/console/public/services/autocomplete.ts create mode 100644 src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts create mode 100644 src/plugins/console/server/routes/api/console/autocomplete_entities/register_get_route.ts create mode 100644 src/plugins/console/server/routes/api/console/autocomplete_entities/register_mappings_route.ts create mode 100644 src/plugins/console/server/shared_imports.ts create mode 100644 test/api_integration/apis/console/autocomplete_entities.ts diff --git a/api_docs/deprecations_by_plugin.mdx b/api_docs/deprecations_by_plugin.mdx index bc9d1dac3a021..4904da587db13 100644 --- a/api_docs/deprecations_by_plugin.mdx +++ b/api_docs/deprecations_by_plugin.mdx @@ -130,12 +130,12 @@ warning: This document is auto-generated and is meant to be viewed inside our ex | Deprecated API | Reference location(s) | Remove By | | ---------------|-----------|-----------| -| | [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField)+ 16 more | - | +| | [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField)+ 16 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternsContract), [create_search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.ts#:~:text=IndexPatternsContract), [create_search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.ts#:~:text=IndexPatternsContract), [search_source_service.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source_service.ts#:~:text=IndexPatternsContract), [search_source_service.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source_service.ts#:~:text=IndexPatternsContract), [esaggs_fn.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/esaggs/esaggs_fn.ts#:~:text=IndexPatternsContract), [esaggs_fn.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/esaggs/esaggs_fn.ts#:~:text=IndexPatternsContract), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/search/types.ts#:~:text=IndexPatternsContract), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/search/types.ts#:~:text=IndexPatternsContract), [create_search_source.test.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.test.ts#:~:text=IndexPatternsContract)+ 19 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternsService), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/server/index.ts#:~:text=IndexPatternsService), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/server/index.ts#:~:text=IndexPatternsService), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/server/index.ts#:~:text=IndexPatternsService), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/index.ts#:~:text=IndexPatternsService) | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPattern), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/types.ts#:~:text=IndexPattern), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/types.ts#:~:text=IndexPattern), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/types.ts#:~:text=IndexPattern), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/types.ts#:~:text=IndexPattern), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPattern), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPattern), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPattern), [tabify_docs.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/tabify/tabify_docs.ts#:~:text=IndexPattern), [tabify_docs.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/tabify/tabify_docs.ts#:~:text=IndexPattern)+ 89 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IFieldType), [date_histogram.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/buckets/date_histogram.ts#:~:text=IFieldType), [date_histogram.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/buckets/date_histogram.ts#:~:text=IFieldType), [generate_filters.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/query/filter_manager/lib/generate_filters.ts#:~:text=IFieldType), [generate_filters.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/query/filter_manager/lib/generate_filters.ts#:~:text=IFieldType), [generate_filters.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/query/filter_manager/lib/generate_filters.ts#:~:text=IFieldType), [generate_filters.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/query/filter_manager/lib/generate_filters.ts#:~:text=IFieldType), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/index.ts#:~:text=IFieldType), [create_filters_from_range_select.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts#:~:text=IFieldType), [create_filters_from_range_select.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts#:~:text=IFieldType)+ 6 more | 8.2 | -| | [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField)+ 16 more | - | +| | [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField)+ 16 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IIndexPattern), [get_time.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/query/timefilter/get_time.ts#:~:text=IIndexPattern), [get_time.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/query/timefilter/get_time.ts#:~:text=IIndexPattern), [get_time.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/query/timefilter/get_time.ts#:~:text=IIndexPattern), [get_time.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/query/timefilter/get_time.ts#:~:text=IIndexPattern), [normalize_sort_request.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/normalize_sort_request.ts#:~:text=IIndexPattern), [normalize_sort_request.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/normalize_sort_request.ts#:~:text=IIndexPattern), [normalize_sort_request.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/normalize_sort_request.ts#:~:text=IIndexPattern), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IIndexPattern), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IIndexPattern)+ 23 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternAttributes), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/index.ts#:~:text=IndexPatternAttributes), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/server/index.ts#:~:text=IndexPatternAttributes) | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternsContract), [create_search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.ts#:~:text=IndexPatternsContract), [create_search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.ts#:~:text=IndexPatternsContract), [search_source_service.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source_service.ts#:~:text=IndexPatternsContract), [search_source_service.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source_service.ts#:~:text=IndexPatternsContract), [esaggs_fn.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/esaggs/esaggs_fn.ts#:~:text=IndexPatternsContract), [esaggs_fn.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/esaggs/esaggs_fn.ts#:~:text=IndexPatternsContract), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/search/types.ts#:~:text=IndexPatternsContract), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/search/types.ts#:~:text=IndexPatternsContract), [create_search_source.test.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.test.ts#:~:text=IndexPatternsContract)+ 19 more | - | diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.mock.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.mock.tsx index b410e240151d7..fe88d651c12f1 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.mock.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.mock.tsx @@ -16,10 +16,6 @@ jest.mock('../../../../contexts/editor_context/editor_registry', () => ({ }, })); jest.mock('../../../../components/editor_example', () => {}); -jest.mock('../../../../../lib/mappings/mappings', () => ({ - retrieveAutoCompleteInfo: () => {}, - clearSubscriptions: () => {}, -})); jest.mock('../../../../models/sense_editor', () => { return { create: () => ({ diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx index d01a40bdd44b3..9219c6e076ca0 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx @@ -20,8 +20,6 @@ import { decompressFromEncodedURIComponent } from 'lz-string'; import { parse } from 'query-string'; import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'; import { ace } from '@kbn/es-ui-shared-plugin/public'; -// @ts-ignore -import { retrieveAutoCompleteInfo, clearSubscriptions } from '../../../../../lib/mappings/mappings'; import { ConsoleMenu } from '../../../../components'; import { useEditorReadContext, useServicesContext } from '../../../../contexts'; import { @@ -66,7 +64,14 @@ const inputId = 'ConAppInputTextarea'; function EditorUI({ initialTextValue, setEditorInstance }: EditorProps) { const { - services: { history, notifications, settings: settingsService, esHostService, http }, + services: { + history, + notifications, + settings: settingsService, + esHostService, + http, + autocompleteInfo, + }, docLinkVersion, } = useServicesContext(); @@ -196,14 +201,14 @@ function EditorUI({ initialTextValue, setEditorInstance }: EditorProps) { setInputEditor(editor); setTextArea(editorRef.current!.querySelector('textarea')); - retrieveAutoCompleteInfo(http, settingsService, settingsService.getAutocomplete()); + autocompleteInfo.retrieve(settingsService, settingsService.getAutocomplete()); const unsubscribeResizer = subscribeResizeChecker(editorRef.current!, editor); setupAutosave(); return () => { unsubscribeResizer(); - clearSubscriptions(); + autocompleteInfo.clearSubscriptions(); window.removeEventListener('hashchange', onHashChange); if (editorInstanceRef.current) { editorInstanceRef.current.getCoreEditor().destroy(); @@ -217,6 +222,7 @@ function EditorUI({ initialTextValue, setEditorInstance }: EditorProps) { setInputEditor, settingsService, http, + autocompleteInfo, ]); useEffect(() => { diff --git a/src/plugins/console/public/application/containers/settings.tsx b/src/plugins/console/public/application/containers/settings.tsx index b4cbea5833f32..b9a9d68294e6d 100644 --- a/src/plugins/console/public/application/containers/settings.tsx +++ b/src/plugins/console/public/application/containers/settings.tsx @@ -8,11 +8,8 @@ import React from 'react'; -import type { HttpSetup } from '@kbn/core/public'; import { AutocompleteOptions, DevToolsSettingsModal } from '../components'; -// @ts-ignore -import { retrieveAutoCompleteInfo } from '../../lib/mappings/mappings'; import { useServicesContext, useEditorActionContext } from '../contexts'; import { DevToolsSettings, Settings as SettingsService } from '../../services'; import type { SenseEditor } from '../models'; @@ -27,48 +24,6 @@ const getAutocompleteDiff = ( }) as AutocompleteOptions[]; }; -const refreshAutocompleteSettings = ( - http: HttpSetup, - settings: SettingsService, - selectedSettings: DevToolsSettings['autocomplete'] -) => { - retrieveAutoCompleteInfo(http, settings, selectedSettings); -}; - -const fetchAutocompleteSettingsIfNeeded = ( - http: HttpSetup, - settings: SettingsService, - newSettings: DevToolsSettings, - prevSettings: DevToolsSettings -) => { - // We'll only retrieve settings if polling is on. The expectation here is that if the user - // disables polling it's because they want manual control over the fetch request (possibly - // because it's a very expensive request given their cluster and bandwidth). In that case, - // they would be unhappy with any request that's sent automatically. - if (newSettings.polling) { - const autocompleteDiff = getAutocompleteDiff(newSettings, prevSettings); - - const isSettingsChanged = autocompleteDiff.length > 0; - const isPollingChanged = prevSettings.polling !== newSettings.polling; - - if (isSettingsChanged) { - // If the user has changed one of the autocomplete settings, then we'll fetch just the - // ones which have changed. - const changedSettings: DevToolsSettings['autocomplete'] = autocompleteDiff.reduce( - (changedSettingsAccum, setting) => { - changedSettingsAccum[setting] = newSettings.autocomplete[setting]; - return changedSettingsAccum; - }, - {} as DevToolsSettings['autocomplete'] - ); - retrieveAutoCompleteInfo(http, settings, changedSettings); - } else if (isPollingChanged && newSettings.polling) { - // If the user has turned polling on, then we'll fetch all selected autocomplete settings. - retrieveAutoCompleteInfo(http, settings, settings.getAutocomplete()); - } - } -}; - export interface Props { onClose: () => void; editorInstance: SenseEditor | null; @@ -76,14 +31,57 @@ export interface Props { export function Settings({ onClose, editorInstance }: Props) { const { - services: { settings, http }, + services: { settings, autocompleteInfo }, } = useServicesContext(); const dispatch = useEditorActionContext(); + const refreshAutocompleteSettings = ( + settingsService: SettingsService, + selectedSettings: DevToolsSettings['autocomplete'] + ) => { + autocompleteInfo.retrieve(settingsService, selectedSettings); + }; + + const fetchAutocompleteSettingsIfNeeded = ( + settingsService: SettingsService, + newSettings: DevToolsSettings, + prevSettings: DevToolsSettings + ) => { + // We'll only retrieve settings if polling is on. The expectation here is that if the user + // disables polling it's because they want manual control over the fetch request (possibly + // because it's a very expensive request given their cluster and bandwidth). In that case, + // they would be unhappy with any request that's sent automatically. + if (newSettings.polling) { + const autocompleteDiff = getAutocompleteDiff(newSettings, prevSettings); + + const isSettingsChanged = autocompleteDiff.length > 0; + const isPollingChanged = prevSettings.polling !== newSettings.polling; + + if (isSettingsChanged) { + // If the user has changed one of the autocomplete settings, then we'll fetch just the + // ones which have changed. + const changedSettings: DevToolsSettings['autocomplete'] = autocompleteDiff.reduce( + (changedSettingsAccum, setting) => { + changedSettingsAccum[setting] = newSettings.autocomplete[setting]; + return changedSettingsAccum; + }, + {} as DevToolsSettings['autocomplete'] + ); + autocompleteInfo.retrieve(settingsService, { + ...settingsService.getAutocomplete(), + ...changedSettings, + }); + } else if (isPollingChanged && newSettings.polling) { + // If the user has turned polling on, then we'll fetch all selected autocomplete settings. + autocompleteInfo.retrieve(settingsService, settingsService.getAutocomplete()); + } + } + }; + const onSaveSettings = (newSettings: DevToolsSettings) => { const prevSettings = settings.toJSON(); - fetchAutocompleteSettingsIfNeeded(http, settings, newSettings, prevSettings); + fetchAutocompleteSettingsIfNeeded(settings, newSettings, prevSettings); // Update the new settings in localStorage settings.updateSettings(newSettings); @@ -101,7 +99,7 @@ export function Settings({ onClose, editorInstance }: Props) { onClose={onClose} onSaveSettings={onSaveSettings} refreshAutocompleteSettings={(selectedSettings) => - refreshAutocompleteSettings(http, settings, selectedSettings) + refreshAutocompleteSettings(settings, selectedSettings) } settings={settings.toJSON()} editorInstance={editorInstance} diff --git a/src/plugins/console/public/application/contexts/services_context.mock.ts b/src/plugins/console/public/application/contexts/services_context.mock.ts index 5ede7f58d4bdc..5d3c7ea6e172d 100644 --- a/src/plugins/console/public/application/contexts/services_context.mock.ts +++ b/src/plugins/console/public/application/contexts/services_context.mock.ts @@ -17,6 +17,7 @@ import type { ObjectStorageClient } from '../../../common/types'; import { HistoryMock } from '../../services/history.mock'; import { SettingsMock } from '../../services/settings.mock'; import { StorageMock } from '../../services/storage.mock'; +import { AutocompleteInfoMock } from '../../services/autocomplete.mock'; import { createApi, createEsHostService } from '../lib'; import { ContextValue } from './services_context'; @@ -38,6 +39,7 @@ export const serviceContextMock = { notifications: notificationServiceMock.createSetupContract(), objectStorageClient: {} as unknown as ObjectStorageClient, http, + autocompleteInfo: new AutocompleteInfoMock(), }, docLinkVersion: 'NA', theme$: themeServiceMock.create().start().theme$, diff --git a/src/plugins/console/public/application/contexts/services_context.tsx b/src/plugins/console/public/application/contexts/services_context.tsx index c60e41d8f14bb..f133a49ca1fe1 100644 --- a/src/plugins/console/public/application/contexts/services_context.tsx +++ b/src/plugins/console/public/application/contexts/services_context.tsx @@ -10,7 +10,7 @@ import React, { createContext, useContext, useEffect } from 'react'; import { Observable } from 'rxjs'; import type { NotificationsSetup, CoreTheme, DocLinksStart, HttpSetup } from '@kbn/core/public'; -import { History, Settings, Storage } from '../../services'; +import { AutocompleteInfo, History, Settings, Storage } from '../../services'; import { ObjectStorageClient } from '../../../common/types'; import { MetricsTracker } from '../../types'; import { EsHostService } from '../lib'; @@ -24,6 +24,7 @@ interface ContextServices { trackUiMetric: MetricsTracker; esHostService: EsHostService; http: HttpSetup; + autocompleteInfo: AutocompleteInfo; } export interface ContextValue { diff --git a/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.ts b/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.ts index ed08304d8d660..6cd1eaddc3583 100644 --- a/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.ts +++ b/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.ts @@ -11,8 +11,6 @@ import { useCallback } from 'react'; import { toMountPoint } from '../../../shared_imports'; import { isQuotaExceededError } from '../../../services/history'; -// @ts-ignore -import { retrieveAutoCompleteInfo } from '../../../lib/mappings/mappings'; import { instance as registry } from '../../contexts/editor_context/editor_registry'; import { useRequestActionContext, useServicesContext } from '../../contexts'; import { StorageQuotaError } from '../../components/storage_quota_error'; @@ -21,7 +19,7 @@ import { track } from './track'; export const useSendCurrentRequest = () => { const { - services: { history, settings, notifications, trackUiMetric, http }, + services: { history, settings, notifications, trackUiMetric, http, autocompleteInfo }, theme$, } = useServicesContext(); @@ -102,7 +100,7 @@ export const useSendCurrentRequest = () => { // or templates may have changed, so we'll need to update this data. Assume that if // the user disables polling they're trying to optimize performance or otherwise // preserve resources, so they won't want this request sent either. - retrieveAutoCompleteInfo(http, settings, settings.getAutocomplete()); + autocompleteInfo.retrieve(settings, settings.getAutocomplete()); } dispatch({ @@ -129,5 +127,14 @@ export const useSendCurrentRequest = () => { }); } } - }, [dispatch, http, settings, notifications.toasts, trackUiMetric, history, theme$]); + }, [ + dispatch, + http, + settings, + notifications.toasts, + trackUiMetric, + history, + theme$, + autocompleteInfo, + ]); }; diff --git a/src/plugins/console/public/application/index.tsx b/src/plugins/console/public/application/index.tsx index 1950ab0c37951..e9f37c232eeaa 100644 --- a/src/plugins/console/public/application/index.tsx +++ b/src/plugins/console/public/application/index.tsx @@ -19,7 +19,7 @@ import { import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import { KibanaThemeProvider } from '../shared_imports'; -import { createStorage, createHistory, createSettings } from '../services'; +import { createStorage, createHistory, createSettings, AutocompleteInfo } from '../services'; import { createUsageTracker } from '../services/tracker'; import * as localStorageObjectClient from '../lib/local_storage_object_client'; import { Main } from './containers'; @@ -35,6 +35,7 @@ export interface BootDependencies { element: HTMLElement; theme$: Observable; docLinks: DocLinksStart['links']; + autocompleteInfo: AutocompleteInfo; } export function renderApp({ @@ -46,6 +47,7 @@ export function renderApp({ http, theme$, docLinks, + autocompleteInfo, }: BootDependencies) { const trackUiMetric = createUsageTracker(usageCollection); trackUiMetric.load('opened_app'); @@ -76,6 +78,7 @@ export function renderApp({ trackUiMetric, objectStorageClient, http, + autocompleteInfo, }, theme$, }} diff --git a/src/plugins/console/public/application/models/sense_editor/integration.test.js b/src/plugins/console/public/application/models/sense_editor/integration.test.js index e60b4175f668f..9159e0d08740e 100644 --- a/src/plugins/console/public/application/models/sense_editor/integration.test.js +++ b/src/plugins/console/public/application/models/sense_editor/integration.test.js @@ -12,10 +12,12 @@ import _ from 'lodash'; import $ from 'jquery'; import * as kb from '../../../lib/kb/kb'; -import * as mappings from '../../../lib/mappings/mappings'; +import { AutocompleteInfo, setAutocompleteInfo } from '../../../services'; describe('Integration', () => { let senseEditor; + let autocompleteInfo; + beforeEach(() => { // Set up our document body document.body.innerHTML = @@ -24,10 +26,14 @@ describe('Integration', () => { senseEditor = create(document.querySelector('#ConAppEditor')); $(senseEditor.getCoreEditor().getContainer()).show(); senseEditor.autocomplete._test.removeChangeListener(); + autocompleteInfo = new AutocompleteInfo(); + setAutocompleteInfo(autocompleteInfo); }); afterEach(() => { $(senseEditor.getCoreEditor().getContainer()).hide(); senseEditor.autocomplete._test.addChangeListener(); + autocompleteInfo = null; + setAutocompleteInfo(null); }); function processContextTest(data, mapping, kbSchemes, requestLine, testToRun) { @@ -45,8 +51,8 @@ describe('Integration', () => { testToRun.cursor.lineNumber += lineOffset; - mappings.clear(); - mappings.loadMappings(mapping); + autocompleteInfo.clear(); + autocompleteInfo.mapping.loadMappings(mapping); const json = {}; json[test.name] = kbSchemes || {}; const testApi = kb._test.loadApisFromJson(json); diff --git a/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js index ca59e077116e4..2b547d698415c 100644 --- a/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { getComponentTemplates } from '../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../services'; import { ListComponent } from './list_component'; export class ComponentTemplateAutocompleteComponent extends ListComponent { constructor(name, parent) { - super(name, getComponentTemplates, parent, true, true); + super(name, getAutocompleteInfo().getEntityProvider('componentTemplates'), parent, true, true); } getContextKey() { diff --git a/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js index 015136b7670f5..0b043410c3b25 100644 --- a/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { getDataStreams } from '../../mappings/mappings'; import { ListComponent } from './list_component'; +import { getAutocompleteInfo } from '../../../services'; export class DataStreamAutocompleteComponent extends ListComponent { constructor(name, parent, multiValued) { - super(name, getDataStreams, parent, multiValued); + super(name, getAutocompleteInfo().getEntityProvider('dataStreams'), parent, multiValued); } getContextKey() { diff --git a/src/plugins/console/public/lib/autocomplete/components/field_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/field_autocomplete_component.js index 76cd37b7e8d99..e3257b2bd86b8 100644 --- a/src/plugins/console/public/lib/autocomplete/components/field_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/field_autocomplete_component.js @@ -7,11 +7,11 @@ */ import _ from 'lodash'; -import { getFields } from '../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../services'; import { ListComponent } from './list_component'; function FieldGenerator(context) { - return _.map(getFields(context.indices, context.types), function (field) { + return _.map(getAutocompleteInfo().getEntityProvider('fields', context), function (field) { return { name: field.name, meta: field.type }; }); } diff --git a/src/plugins/console/public/lib/autocomplete/components/index_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/index_autocomplete_component.js index 0ec53be7e56af..c2a7e2fb14286 100644 --- a/src/plugins/console/public/lib/autocomplete/components/index_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/index_autocomplete_component.js @@ -7,14 +7,16 @@ */ import _ from 'lodash'; -import { getIndices } from '../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../services'; import { ListComponent } from './list_component'; + function nonValidIndexType(token) { return !(token === '_all' || token[0] !== '_'); } + export class IndexAutocompleteComponent extends ListComponent { constructor(name, parent, multiValued) { - super(name, getIndices, parent, multiValued); + super(name, getAutocompleteInfo().getEntityProvider('indices'), parent, multiValued); } validateTokens(tokens) { if (!this.multiValued && tokens.length > 1) { diff --git a/src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js index 444e40e756f7b..7bb3c32239751 100644 --- a/src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { getIndexTemplates } from '../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../services'; import { ListComponent } from './list_component'; export class IndexTemplateAutocompleteComponent extends ListComponent { constructor(name, parent) { - super(name, getIndexTemplates, parent, true, true); + super(name, getAutocompleteInfo().getEntityProvider('indexTemplates'), parent, true, true); } getContextKey() { diff --git a/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js index b68ae952702f5..73a9e3ea65c17 100644 --- a/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { getLegacyTemplates } from '../../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../../services'; import { ListComponent } from '../list_component'; export class LegacyTemplateAutocompleteComponent extends ListComponent { constructor(name, parent) { - super(name, getLegacyTemplates, parent, true, true); + super(name, getAutocompleteInfo().getEntityProvider('legacyTemplates'), parent, true, true); } getContextKey() { return 'template'; diff --git a/src/plugins/console/public/lib/autocomplete/components/type_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/type_autocomplete_component.js index bab45f28710e0..f7caf05e5805f 100644 --- a/src/plugins/console/public/lib/autocomplete/components/type_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/type_autocomplete_component.js @@ -8,7 +8,7 @@ import _ from 'lodash'; import { ListComponent } from './list_component'; -import { getTypes } from '../../mappings/mappings'; +import { getTypes } from '../../autocomplete_entities'; function TypeGenerator(context) { return getTypes(context.indices); } diff --git a/src/plugins/console/public/lib/autocomplete/components/username_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/username_autocomplete_component.js index 78b24f26444d6..c505f66a68b0c 100644 --- a/src/plugins/console/public/lib/autocomplete/components/username_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/username_autocomplete_component.js @@ -7,14 +7,16 @@ */ import _ from 'lodash'; -import { getIndices } from '../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../services'; import { ListComponent } from './list_component'; + function nonValidUsernameType(token) { return token[0] === '_'; } + export class UsernameAutocompleteComponent extends ListComponent { constructor(name, parent, multiValued) { - super(name, getIndices, parent, multiValued); + super(name, getAutocompleteInfo().getEntityProvider('indices'), parent, multiValued); } validateTokens(tokens) { if (!this.multiValued && tokens.length > 1) { diff --git a/src/plugins/console/public/lib/autocomplete_entities/alias.ts b/src/plugins/console/public/lib/autocomplete_entities/alias.ts new file mode 100644 index 0000000000000..9bce35ab510c0 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/alias.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IndicesGetAliasResponse } from '@elastic/elasticsearch/lib/api/types'; +import type { BaseMapping } from './mapping'; + +interface BaseAlias { + getIndices(includeAliases: boolean, collaborator: BaseMapping): string[]; + loadAliases(aliases: IndicesGetAliasResponse, collaborator: BaseMapping): void; + clearAliases(): void; +} + +export class Alias implements BaseAlias { + public perAliasIndexes: Record = {}; + + getIndices = (includeAliases: boolean, collaborator: BaseMapping): string[] => { + const ret: string[] = []; + const perIndexTypes = collaborator.perIndexTypes; + Object.keys(perIndexTypes).forEach((index) => { + // ignore .ds* indices in the suggested indices list. + if (!index.startsWith('.ds')) { + ret.push(index); + } + }); + + if (typeof includeAliases === 'undefined' ? true : includeAliases) { + Object.keys(this.perAliasIndexes).forEach((alias) => { + ret.push(alias); + }); + } + return ret; + }; + + loadAliases = (aliases: IndicesGetAliasResponse, collaborator: BaseMapping) => { + this.perAliasIndexes = {}; + const perIndexTypes = collaborator.perIndexTypes; + + Object.entries(aliases).forEach(([index, indexAliases]) => { + // verify we have an index defined. useful when mapping loading is disabled + perIndexTypes[index] = perIndexTypes[index] || {}; + Object.keys(indexAliases.aliases || {}).forEach((alias) => { + if (alias === index) { + return; + } // alias which is identical to index means no index. + let curAliases = this.perAliasIndexes[alias]; + if (!curAliases) { + curAliases = []; + this.perAliasIndexes[alias] = curAliases; + } + curAliases.push(index); + }); + }); + const includeAliases = false; + this.perAliasIndexes._all = this.getIndices(includeAliases, collaborator); + }; + + clearAliases = () => { + this.perAliasIndexes = {}; + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js b/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js new file mode 100644 index 0000000000000..5349538799d9b --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js @@ -0,0 +1,315 @@ +/* + * Copyright 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 '../../application/models/sense_editor/sense_editor.test.mocks'; +import { setAutocompleteInfo, AutocompleteInfo } from '../../services'; +import { expandAliases } from './expand_aliases'; + +function fc(f1, f2) { + if (f1.name < f2.name) { + return -1; + } + if (f1.name > f2.name) { + return 1; + } + return 0; +} + +function f(name, type) { + return { name, type: type || 'string' }; +} + +describe('Autocomplete entities', () => { + let mapping; + let alias; + let legacyTemplate; + let indexTemplate; + let componentTemplate; + let dataStream; + let autocompleteInfo; + beforeEach(() => { + autocompleteInfo = new AutocompleteInfo(); + setAutocompleteInfo(autocompleteInfo); + mapping = autocompleteInfo.mapping; + alias = autocompleteInfo.alias; + legacyTemplate = autocompleteInfo.legacyTemplate; + indexTemplate = autocompleteInfo.indexTemplate; + componentTemplate = autocompleteInfo.componentTemplate; + dataStream = autocompleteInfo.dataStream; + }); + afterEach(() => { + autocompleteInfo.clear(); + autocompleteInfo = null; + }); + + describe('Mappings', function () { + test('Multi fields 1.0 style', function () { + mapping.loadMappings({ + index: { + properties: { + first_name: { + type: 'string', + index: 'analyzed', + path: 'just_name', + fields: { + any_name: { type: 'string', index: 'analyzed' }, + }, + }, + last_name: { + type: 'string', + index: 'no', + fields: { + raw: { type: 'string', index: 'analyzed' }, + }, + }, + }, + }, + }); + + expect(mapping.getMappings('index').sort(fc)).toEqual([ + f('any_name', 'string'), + f('first_name', 'string'), + f('last_name', 'string'), + f('last_name.raw', 'string'), + ]); + }); + + test('Simple fields', function () { + mapping.loadMappings({ + index: { + properties: { + str: { + type: 'string', + }, + number: { + type: 'int', + }, + }, + }, + }); + + expect(mapping.getMappings('index').sort(fc)).toEqual([ + f('number', 'int'), + f('str', 'string'), + ]); + }); + + test('Simple fields - 1.0 style', function () { + mapping.loadMappings({ + index: { + mappings: { + properties: { + str: { + type: 'string', + }, + number: { + type: 'int', + }, + }, + }, + }, + }); + + expect(mapping.getMappings('index').sort(fc)).toEqual([ + f('number', 'int'), + f('str', 'string'), + ]); + }); + + test('Nested fields', function () { + mapping.loadMappings({ + index: { + properties: { + person: { + type: 'object', + properties: { + name: { + properties: { + first_name: { type: 'string' }, + last_name: { type: 'string' }, + }, + }, + sid: { type: 'string', index: 'not_analyzed' }, + }, + }, + message: { type: 'string' }, + }, + }, + }); + + expect(mapping.getMappings('index', []).sort(fc)).toEqual([ + f('message'), + f('person.name.first_name'), + f('person.name.last_name'), + f('person.sid'), + ]); + }); + + test('Enabled fields', function () { + mapping.loadMappings({ + index: { + properties: { + person: { + type: 'object', + properties: { + name: { + type: 'object', + enabled: false, + }, + sid: { type: 'string', index: 'not_analyzed' }, + }, + }, + message: { type: 'string' }, + }, + }, + }); + + expect(mapping.getMappings('index', []).sort(fc)).toEqual([f('message'), f('person.sid')]); + }); + + test('Path tests', function () { + mapping.loadMappings({ + index: { + properties: { + name1: { + type: 'object', + path: 'just_name', + properties: { + first1: { type: 'string' }, + last1: { type: 'string', index_name: 'i_last_1' }, + }, + }, + name2: { + type: 'object', + path: 'full', + properties: { + first2: { type: 'string' }, + last2: { type: 'string', index_name: 'i_last_2' }, + }, + }, + }, + }, + }); + + expect(mapping.getMappings().sort(fc)).toEqual([ + f('first1'), + f('i_last_1'), + f('name2.first2'), + f('name2.i_last_2'), + ]); + }); + + test('Use index_name tests', function () { + mapping.loadMappings({ + index: { + properties: { + last1: { type: 'string', index_name: 'i_last_1' }, + }, + }, + }); + + expect(mapping.getMappings().sort(fc)).toEqual([f('i_last_1')]); + }); + }); + + describe('Aliases', function () { + test('Aliases', function () { + alias.loadAliases( + { + test_index1: { + aliases: { + alias1: {}, + }, + }, + test_index2: { + aliases: { + alias2: { + filter: { + term: { + FIELD: 'VALUE', + }, + }, + }, + alias1: {}, + }, + }, + }, + mapping + ); + mapping.loadMappings({ + test_index1: { + properties: { + last1: { type: 'string', index_name: 'i_last_1' }, + }, + }, + test_index2: { + properties: { + last1: { type: 'string', index_name: 'i_last_1' }, + }, + }, + }); + + expect(alias.getIndices(true, mapping).sort()).toEqual([ + '_all', + 'alias1', + 'alias2', + 'test_index1', + 'test_index2', + ]); + expect(alias.getIndices(false, mapping).sort()).toEqual(['test_index1', 'test_index2']); + expect(expandAliases(['alias1', 'test_index2']).sort()).toEqual([ + 'test_index1', + 'test_index2', + ]); + expect(expandAliases('alias2')).toEqual('test_index2'); + }); + }); + + describe('Templates', function () { + test('legacy templates, index templates, component templates', function () { + legacyTemplate.loadTemplates({ + test_index1: { order: 0 }, + test_index2: { order: 0 }, + test_index3: { order: 0 }, + }); + + indexTemplate.loadTemplates({ + index_templates: [ + { name: 'test_index1' }, + { name: 'test_index2' }, + { name: 'test_index3' }, + ], + }); + + componentTemplate.loadTemplates({ + component_templates: [ + { name: 'test_index1' }, + { name: 'test_index2' }, + { name: 'test_index3' }, + ], + }); + + const expectedResult = ['test_index1', 'test_index2', 'test_index3']; + + expect(legacyTemplate.getTemplates()).toEqual(expectedResult); + expect(indexTemplate.getTemplates()).toEqual(expectedResult); + expect(componentTemplate.getTemplates()).toEqual(expectedResult); + }); + }); + + describe('Data streams', function () { + test('data streams', function () { + dataStream.loadDataStreams({ + data_streams: [{ name: 'test_index1' }, { name: 'test_index2' }, { name: 'test_index3' }], + }); + + const expectedResult = ['test_index1', 'test_index2', 'test_index3']; + expect(dataStream.getDataStreams()).toEqual(expectedResult); + }); + }); +}); diff --git a/src/plugins/console/public/lib/autocomplete_entities/base_template.ts b/src/plugins/console/public/lib/autocomplete_entities/base_template.ts new file mode 100644 index 0000000000000..2304150d94e77 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/base_template.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export abstract class BaseTemplate { + protected templates: string[] = []; + + public abstract loadTemplates(templates: T): void; + + public getTemplates = (): string[] => { + return [...this.templates]; + }; + + public clearTemplates = () => { + this.templates = []; + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/component_template.ts b/src/plugins/console/public/lib/autocomplete_entities/component_template.ts new file mode 100644 index 0000000000000..b6699438de011 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/component_template.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ClusterGetComponentTemplateResponse } from '@elastic/elasticsearch/lib/api/types'; +import { BaseTemplate } from './base_template'; + +export class ComponentTemplate extends BaseTemplate { + loadTemplates = (templates: ClusterGetComponentTemplateResponse) => { + this.templates = (templates.component_templates ?? []).map(({ name }) => name).sort(); + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/data_stream.ts b/src/plugins/console/public/lib/autocomplete_entities/data_stream.ts new file mode 100644 index 0000000000000..2b65d086aeb13 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/data_stream.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IndicesGetDataStreamResponse } from '@elastic/elasticsearch/lib/api/types'; + +export class DataStream { + private dataStreams: string[] = []; + + getDataStreams = (): string[] => { + return [...this.dataStreams]; + }; + + loadDataStreams = (dataStreams: IndicesGetDataStreamResponse) => { + this.dataStreams = (dataStreams.data_streams ?? []).map(({ name }) => name).sort(); + }; + + clearDataStreams = () => { + this.dataStreams = []; + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/expand_aliases.ts b/src/plugins/console/public/lib/autocomplete_entities/expand_aliases.ts new file mode 100644 index 0000000000000..27f8211f533a9 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/expand_aliases.ts @@ -0,0 +1,41 @@ +/* + * Copyright 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 { getAutocompleteInfo } from '../../services'; + +export function expandAliases(indicesOrAliases: string | string[]) { + // takes a list of indices or aliases or a string which may be either and returns a list of indices + // returns a list for multiple values or a string for a single. + const perAliasIndexes = getAutocompleteInfo().alias.perAliasIndexes; + if (!indicesOrAliases) { + return indicesOrAliases; + } + + if (typeof indicesOrAliases === 'string') { + indicesOrAliases = [indicesOrAliases]; + } + + indicesOrAliases = indicesOrAliases.flatMap((iOrA) => { + if (perAliasIndexes[iOrA]) { + return perAliasIndexes[iOrA]; + } + return [iOrA]; + }); + + let ret = ([] as string[]).concat.apply([], indicesOrAliases); + ret.sort(); + ret = ret.reduce((result, value, index, array) => { + const last = array[index - 1]; + if (last !== value) { + result.push(value); + } + return result; + }, [] as string[]); + + return ret.length > 1 ? ret : ret[0]; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/index.ts b/src/plugins/console/public/lib/autocomplete_entities/index.ts new file mode 100644 index 0000000000000..e523ce42ddc79 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { Alias } from './alias'; +export { Mapping } from './mapping'; +export { DataStream } from './data_stream'; +export { LegacyTemplate } from './legacy'; +export { IndexTemplate } from './index_template'; +export { ComponentTemplate } from './component_template'; +export { getTypes } from './type'; diff --git a/src/plugins/console/public/lib/autocomplete_entities/index_template.ts b/src/plugins/console/public/lib/autocomplete_entities/index_template.ts new file mode 100644 index 0000000000000..ab3081841f0d4 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/index_template.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IndicesGetIndexTemplateResponse } from '@elastic/elasticsearch/lib/api/types'; +import { BaseTemplate } from './base_template'; + +export class IndexTemplate extends BaseTemplate { + loadTemplates = (templates: IndicesGetIndexTemplateResponse) => { + this.templates = (templates.index_templates ?? []).map(({ name }) => name).sort(); + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/legacy/index.ts b/src/plugins/console/public/lib/autocomplete_entities/legacy/index.ts new file mode 100644 index 0000000000000..9f0c06ad6a518 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/legacy/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { LegacyTemplate } from './legacy_template'; diff --git a/src/plugins/console/public/lib/autocomplete_entities/legacy/legacy_template.ts b/src/plugins/console/public/lib/autocomplete_entities/legacy/legacy_template.ts new file mode 100644 index 0000000000000..73d17745702a8 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/legacy/legacy_template.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IndicesGetTemplateResponse } from '@elastic/elasticsearch/lib/api/types'; +import { BaseTemplate } from '../base_template'; + +export class LegacyTemplate extends BaseTemplate { + loadTemplates = (templates: IndicesGetTemplateResponse) => { + this.templates = Object.keys(templates).sort(); + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/mapping.ts b/src/plugins/console/public/lib/autocomplete_entities/mapping.ts new file mode 100644 index 0000000000000..ddb6905fa6e53 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/mapping.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import _ from 'lodash'; +import type { IndicesGetMappingResponse } from '@elastic/elasticsearch/lib/api/types'; +import { expandAliases } from './expand_aliases'; +import type { Field, FieldMapping } from './types'; + +function getFieldNamesFromProperties(properties: Record = {}) { + const fieldList = Object.entries(properties).flatMap(([fieldName, fieldMapping]) => { + return getFieldNamesFromFieldMapping(fieldName, fieldMapping); + }); + + // deduping + return _.uniqBy(fieldList, function (f) { + return f.name + ':' + f.type; + }); +} + +function getFieldNamesFromFieldMapping( + fieldName: string, + fieldMapping: FieldMapping +): Array<{ name: string; type: string | undefined }> { + if (fieldMapping.enabled === false) { + return []; + } + let nestedFields; + + function applyPathSettings(nestedFieldNames: Array<{ name: string; type: string | undefined }>) { + const pathType = fieldMapping.path || 'full'; + if (pathType === 'full') { + return nestedFieldNames.map((f) => { + f.name = fieldName + '.' + f.name; + return f; + }); + } + return nestedFieldNames; + } + + if (fieldMapping.properties) { + // derived object type + nestedFields = getFieldNamesFromProperties(fieldMapping.properties); + return applyPathSettings(nestedFields); + } + + const fieldType = fieldMapping.type; + + const ret = { name: fieldName, type: fieldType }; + + if (fieldMapping.index_name) { + ret.name = fieldMapping.index_name; + } + + if (fieldMapping.fields) { + nestedFields = Object.entries(fieldMapping.fields).flatMap(([name, mapping]) => { + return getFieldNamesFromFieldMapping(name, mapping); + }); + nestedFields = applyPathSettings(nestedFields); + nestedFields.unshift(ret); + return nestedFields; + } + + return [ret]; +} + +export interface BaseMapping { + perIndexTypes: Record; + getMappings(indices: string | string[], types?: string | string[]): Field[]; + loadMappings(mappings: IndicesGetMappingResponse): void; + clearMappings(): void; +} + +export class Mapping implements BaseMapping { + public perIndexTypes: Record = {}; + + getMappings = (indices: string | string[], types?: string | string[]) => { + // get fields for indices and types. Both can be a list, a string or null (meaning all). + let ret: Field[] = []; + indices = expandAliases(indices); + + if (typeof indices === 'string') { + const typeDict = this.perIndexTypes[indices] as Record; + if (!typeDict) { + return []; + } + + if (typeof types === 'string') { + const f = typeDict[types]; + if (Array.isArray(f)) { + ret = f; + } + } else { + // filter what we need + Object.entries(typeDict).forEach(([type, fields]) => { + if (!types || types.length === 0 || types.includes(type)) { + ret.push(fields as Field); + } + }); + + ret = ([] as Field[]).concat.apply([], ret); + } + } else { + // multi index mode. + Object.keys(this.perIndexTypes).forEach((index) => { + if (!indices || indices.length === 0 || indices.includes(index)) { + ret.push(this.getMappings(index, types) as unknown as Field); + } + }); + + ret = ([] as Field[]).concat.apply([], ret); + } + + return _.uniqBy(ret, function (f) { + return f.name + ':' + f.type; + }); + }; + + loadMappings = (mappings: IndicesGetMappingResponse) => { + const maxMappingSize = Object.keys(mappings).length > 10 * 1024 * 1024; + let mappingsResponse; + if (maxMappingSize) { + // eslint-disable-next-line no-console + console.warn( + `Mapping size is larger than 10MB (${ + Object.keys(mappings).length / 1024 / 1024 + } MB). Ignoring...` + ); + mappingsResponse = {}; + } else { + mappingsResponse = mappings; + } + + this.perIndexTypes = {}; + + Object.entries(mappingsResponse).forEach(([index, indexMapping]) => { + const normalizedIndexMappings: Record = {}; + let transformedMapping: Record = indexMapping; + + // Migrate 1.0.0 mappings. This format has changed, so we need to extract the underlying mapping. + if (indexMapping.mappings && Object.keys(indexMapping).length === 1) { + transformedMapping = indexMapping.mappings; + } + + Object.entries(transformedMapping).forEach(([typeName, typeMapping]) => { + if (typeName === 'properties') { + const fieldList = getFieldNamesFromProperties(typeMapping); + normalizedIndexMappings[typeName] = fieldList; + } else { + normalizedIndexMappings[typeName] = []; + } + }); + this.perIndexTypes[index] = normalizedIndexMappings; + }); + }; + + clearMappings = () => { + this.perIndexTypes = {}; + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/type.ts b/src/plugins/console/public/lib/autocomplete_entities/type.ts new file mode 100644 index 0000000000000..5f1d8b1308d77 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/type.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import _ from 'lodash'; +import { getAutocompleteInfo } from '../../services'; +import { expandAliases } from './expand_aliases'; + +export function getTypes(indices: string | string[]) { + let ret: string[] = []; + const perIndexTypes = getAutocompleteInfo().mapping.perIndexTypes; + indices = expandAliases(indices); + if (typeof indices === 'string') { + const typeDict = perIndexTypes[indices]; + if (!typeDict) { + return []; + } + + // filter what we need + if (Array.isArray(typeDict)) { + typeDict.forEach((type) => { + ret.push(type); + }); + } else if (typeof typeDict === 'object') { + Object.keys(typeDict).forEach((type) => { + ret.push(type); + }); + } + } else { + // multi index mode. + Object.keys(perIndexTypes).forEach((index) => { + if (!indices || indices.includes(index)) { + ret.push(getTypes(index) as unknown as string); + } + }); + ret = ([] as string[]).concat.apply([], ret); + } + + return _.uniq(ret); +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/types.ts b/src/plugins/console/public/lib/autocomplete_entities/types.ts new file mode 100644 index 0000000000000..e49f8f106f37a --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/types.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + ClusterGetComponentTemplateResponse, + IndicesGetAliasResponse, + IndicesGetDataStreamResponse, + IndicesGetIndexTemplateResponse, + IndicesGetMappingResponse, + IndicesGetTemplateResponse, +} from '@elastic/elasticsearch/lib/api/types'; + +export interface Field { + name: string; + type: string; +} + +export interface FieldMapping { + enabled?: boolean; + path?: string; + properties?: Record; + type?: string; + index_name?: string; + fields?: FieldMapping[]; +} + +export interface MappingsApiResponse { + mappings: IndicesGetMappingResponse; + aliases: IndicesGetAliasResponse; + dataStreams: IndicesGetDataStreamResponse; + legacyTemplates: IndicesGetTemplateResponse; + indexTemplates: IndicesGetIndexTemplateResponse; + componentTemplates: ClusterGetComponentTemplateResponse; +} diff --git a/src/plugins/console/public/lib/kb/kb.test.js b/src/plugins/console/public/lib/kb/kb.test.js index ff0ddba37281a..8b1af7103c40b 100644 --- a/src/plugins/console/public/lib/kb/kb.test.js +++ b/src/plugins/console/public/lib/kb/kb.test.js @@ -11,16 +11,20 @@ import { populateContext } from '../autocomplete/engine'; import '../../application/models/sense_editor/sense_editor.test.mocks'; import * as kb from '.'; -import * as mappings from '../mappings/mappings'; +import { AutocompleteInfo, setAutocompleteInfo } from '../../services'; describe('Knowledge base', () => { + let autocompleteInfo; beforeEach(() => { - mappings.clear(); kb.setActiveApi(kb._test.loadApisFromJson({})); + autocompleteInfo = new AutocompleteInfo(); + setAutocompleteInfo(autocompleteInfo); + autocompleteInfo.mapping.clearMappings(); }); afterEach(() => { - mappings.clear(); kb.setActiveApi(kb._test.loadApisFromJson({})); + autocompleteInfo = null; + setAutocompleteInfo(null); }); const MAPPING = { @@ -122,7 +126,7 @@ describe('Knowledge base', () => { kb.setActiveApi(testApi); - mappings.loadMappings(MAPPING); + autocompleteInfo.mapping.loadMappings(MAPPING); testUrlContext(tokenPath, otherTokenValues, expectedContext); }); } @@ -165,7 +169,7 @@ describe('Knowledge base', () => { ); kb.setActiveApi(testApi); - mappings.loadMappings(MAPPING); + autocompleteInfo.mapping.loadMappings(MAPPING); testUrlContext(tokenPath, otherTokenValues, expectedContext); }); diff --git a/src/plugins/console/public/lib/mappings/mapping.test.js b/src/plugins/console/public/lib/mappings/mapping.test.js deleted file mode 100644 index e2def74e892cc..0000000000000 --- a/src/plugins/console/public/lib/mappings/mapping.test.js +++ /dev/null @@ -1,278 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import '../../application/models/sense_editor/sense_editor.test.mocks'; -import * as mappings from './mappings'; - -describe('Mappings', () => { - beforeEach(() => { - mappings.clear(); - }); - afterEach(() => { - mappings.clear(); - }); - - function fc(f1, f2) { - if (f1.name < f2.name) { - return -1; - } - if (f1.name > f2.name) { - return 1; - } - return 0; - } - - function f(name, type) { - return { name: name, type: type || 'string' }; - } - - test('Multi fields 1.0 style', function () { - mappings.loadMappings({ - index: { - properties: { - first_name: { - type: 'string', - index: 'analyzed', - path: 'just_name', - fields: { - any_name: { type: 'string', index: 'analyzed' }, - }, - }, - last_name: { - type: 'string', - index: 'no', - fields: { - raw: { type: 'string', index: 'analyzed' }, - }, - }, - }, - }, - }); - - expect(mappings.getFields('index').sort(fc)).toEqual([ - f('any_name', 'string'), - f('first_name', 'string'), - f('last_name', 'string'), - f('last_name.raw', 'string'), - ]); - }); - - test('Simple fields', function () { - mappings.loadMappings({ - index: { - properties: { - str: { - type: 'string', - }, - number: { - type: 'int', - }, - }, - }, - }); - - expect(mappings.getFields('index').sort(fc)).toEqual([f('number', 'int'), f('str', 'string')]); - }); - - test('Simple fields - 1.0 style', function () { - mappings.loadMappings({ - index: { - mappings: { - properties: { - str: { - type: 'string', - }, - number: { - type: 'int', - }, - }, - }, - }, - }); - - expect(mappings.getFields('index').sort(fc)).toEqual([f('number', 'int'), f('str', 'string')]); - }); - - test('Nested fields', function () { - mappings.loadMappings({ - index: { - properties: { - person: { - type: 'object', - properties: { - name: { - properties: { - first_name: { type: 'string' }, - last_name: { type: 'string' }, - }, - }, - sid: { type: 'string', index: 'not_analyzed' }, - }, - }, - message: { type: 'string' }, - }, - }, - }); - - expect(mappings.getFields('index', []).sort(fc)).toEqual([ - f('message'), - f('person.name.first_name'), - f('person.name.last_name'), - f('person.sid'), - ]); - }); - - test('Enabled fields', function () { - mappings.loadMappings({ - index: { - properties: { - person: { - type: 'object', - properties: { - name: { - type: 'object', - enabled: false, - }, - sid: { type: 'string', index: 'not_analyzed' }, - }, - }, - message: { type: 'string' }, - }, - }, - }); - - expect(mappings.getFields('index', []).sort(fc)).toEqual([f('message'), f('person.sid')]); - }); - - test('Path tests', function () { - mappings.loadMappings({ - index: { - properties: { - name1: { - type: 'object', - path: 'just_name', - properties: { - first1: { type: 'string' }, - last1: { type: 'string', index_name: 'i_last_1' }, - }, - }, - name2: { - type: 'object', - path: 'full', - properties: { - first2: { type: 'string' }, - last2: { type: 'string', index_name: 'i_last_2' }, - }, - }, - }, - }, - }); - - expect(mappings.getFields().sort(fc)).toEqual([ - f('first1'), - f('i_last_1'), - f('name2.first2'), - f('name2.i_last_2'), - ]); - }); - - test('Use index_name tests', function () { - mappings.loadMappings({ - index: { - properties: { - last1: { type: 'string', index_name: 'i_last_1' }, - }, - }, - }); - - expect(mappings.getFields().sort(fc)).toEqual([f('i_last_1')]); - }); - - test('Aliases', function () { - mappings.loadAliases({ - test_index1: { - aliases: { - alias1: {}, - }, - }, - test_index2: { - aliases: { - alias2: { - filter: { - term: { - FIELD: 'VALUE', - }, - }, - }, - alias1: {}, - }, - }, - }); - mappings.loadMappings({ - test_index1: { - properties: { - last1: { type: 'string', index_name: 'i_last_1' }, - }, - }, - test_index2: { - properties: { - last1: { type: 'string', index_name: 'i_last_1' }, - }, - }, - }); - - expect(mappings.getIndices().sort()).toEqual([ - '_all', - 'alias1', - 'alias2', - 'test_index1', - 'test_index2', - ]); - expect(mappings.getIndices(false).sort()).toEqual(['test_index1', 'test_index2']); - expect(mappings.expandAliases(['alias1', 'test_index2']).sort()).toEqual([ - 'test_index1', - 'test_index2', - ]); - expect(mappings.expandAliases('alias2')).toEqual('test_index2'); - }); - - test('Templates', function () { - mappings.loadLegacyTemplates({ - test_index1: { order: 0 }, - test_index2: { order: 0 }, - test_index3: { order: 0 }, - }); - - mappings.loadIndexTemplates({ - index_templates: [{ name: 'test_index1' }, { name: 'test_index2' }, { name: 'test_index3' }], - }); - - mappings.loadComponentTemplates({ - component_templates: [ - { name: 'test_index1' }, - { name: 'test_index2' }, - { name: 'test_index3' }, - ], - }); - - const expectedResult = ['test_index1', 'test_index2', 'test_index3']; - - expect(mappings.getLegacyTemplates()).toEqual(expectedResult); - expect(mappings.getIndexTemplates()).toEqual(expectedResult); - expect(mappings.getComponentTemplates()).toEqual(expectedResult); - }); - - test('Data streams', function () { - mappings.loadDataStreams({ - data_streams: [{ name: 'test_index1' }, { name: 'test_index2' }, { name: 'test_index3' }], - }); - - const expectedResult = ['test_index1', 'test_index2', 'test_index3']; - expect(mappings.getDataStreams()).toEqual(expectedResult); - }); -}); diff --git a/src/plugins/console/public/lib/mappings/mappings.js b/src/plugins/console/public/lib/mappings/mappings.js deleted file mode 100644 index 289bfb9aa17bb..0000000000000 --- a/src/plugins/console/public/lib/mappings/mappings.js +++ /dev/null @@ -1,410 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import _ from 'lodash'; -import * as es from '../es/es'; - -let pollTimeoutId; - -let perIndexTypes = {}; -let perAliasIndexes = {}; -let legacyTemplates = []; -let indexTemplates = []; -let componentTemplates = []; -let dataStreams = []; - -export function expandAliases(indicesOrAliases) { - // takes a list of indices or aliases or a string which may be either and returns a list of indices - // returns a list for multiple values or a string for a single. - - if (!indicesOrAliases) { - return indicesOrAliases; - } - - if (typeof indicesOrAliases === 'string') { - indicesOrAliases = [indicesOrAliases]; - } - - indicesOrAliases = indicesOrAliases.map((iOrA) => { - if (perAliasIndexes[iOrA]) { - return perAliasIndexes[iOrA]; - } - return [iOrA]; - }); - let ret = [].concat.apply([], indicesOrAliases); - ret.sort(); - ret = ret.reduce((result, value, index, array) => { - const last = array[index - 1]; - if (last !== value) { - result.push(value); - } - return result; - }, []); - - return ret.length > 1 ? ret : ret[0]; -} - -export function getLegacyTemplates() { - return [...legacyTemplates]; -} - -export function getIndexTemplates() { - return [...indexTemplates]; -} - -export function getComponentTemplates() { - return [...componentTemplates]; -} - -export function getDataStreams() { - return [...dataStreams]; -} - -export function getFields(indices, types) { - // get fields for indices and types. Both can be a list, a string or null (meaning all). - let ret = []; - indices = expandAliases(indices); - - if (typeof indices === 'string') { - const typeDict = perIndexTypes[indices]; - if (!typeDict) { - return []; - } - - if (typeof types === 'string') { - const f = typeDict[types]; - ret = f ? f : []; - } else { - // filter what we need - Object.entries(typeDict).forEach(([type, fields]) => { - if (!types || types.length === 0 || types.includes(type)) { - ret.push(fields); - } - }); - - ret = [].concat.apply([], ret); - } - } else { - // multi index mode. - Object.keys(perIndexTypes).forEach((index) => { - if (!indices || indices.length === 0 || indices.includes(index)) { - ret.push(getFields(index, types)); - } - }); - - ret = [].concat.apply([], ret); - } - - return _.uniqBy(ret, function (f) { - return f.name + ':' + f.type; - }); -} - -export function getTypes(indices) { - let ret = []; - indices = expandAliases(indices); - if (typeof indices === 'string') { - const typeDict = perIndexTypes[indices]; - if (!typeDict) { - return []; - } - - // filter what we need - if (Array.isArray(typeDict)) { - typeDict.forEach((type) => { - ret.push(type); - }); - } else if (typeof typeDict === 'object') { - Object.keys(typeDict).forEach((type) => { - ret.push(type); - }); - } - } else { - // multi index mode. - Object.keys(perIndexTypes).forEach((index) => { - if (!indices || indices.includes(index)) { - ret.push(getTypes(index)); - } - }); - ret = [].concat.apply([], ret); - } - - return _.uniq(ret); -} - -export function getIndices(includeAliases) { - const ret = []; - Object.keys(perIndexTypes).forEach((index) => { - // ignore .ds* indices in the suggested indices list. - if (!index.startsWith('.ds')) { - ret.push(index); - } - }); - - if (typeof includeAliases === 'undefined' ? true : includeAliases) { - Object.keys(perAliasIndexes).forEach((alias) => { - ret.push(alias); - }); - } - return ret; -} - -function getFieldNamesFromFieldMapping(fieldName, fieldMapping) { - if (fieldMapping.enabled === false) { - return []; - } - let nestedFields; - - function applyPathSettings(nestedFieldNames) { - const pathType = fieldMapping.path || 'full'; - if (pathType === 'full') { - return nestedFieldNames.map((f) => { - f.name = fieldName + '.' + f.name; - return f; - }); - } - return nestedFieldNames; - } - - if (fieldMapping.properties) { - // derived object type - nestedFields = getFieldNamesFromProperties(fieldMapping.properties); - return applyPathSettings(nestedFields); - } - - const fieldType = fieldMapping.type; - - const ret = { name: fieldName, type: fieldType }; - - if (fieldMapping.index_name) { - ret.name = fieldMapping.index_name; - } - - if (fieldMapping.fields) { - nestedFields = Object.entries(fieldMapping.fields).flatMap(([fieldName, fieldMapping]) => { - return getFieldNamesFromFieldMapping(fieldName, fieldMapping); - }); - nestedFields = applyPathSettings(nestedFields); - nestedFields.unshift(ret); - return nestedFields; - } - - return [ret]; -} - -function getFieldNamesFromProperties(properties = {}) { - const fieldList = Object.entries(properties).flatMap(([fieldName, fieldMapping]) => { - return getFieldNamesFromFieldMapping(fieldName, fieldMapping); - }); - - // deduping - return _.uniqBy(fieldList, function (f) { - return f.name + ':' + f.type; - }); -} - -export function loadLegacyTemplates(templatesObject = {}) { - legacyTemplates = Object.keys(templatesObject); -} - -export function loadIndexTemplates(data) { - indexTemplates = (data.index_templates ?? []).map(({ name }) => name); -} - -export function loadComponentTemplates(data) { - componentTemplates = (data.component_templates ?? []).map(({ name }) => name); -} - -export function loadDataStreams(data) { - dataStreams = (data.data_streams ?? []).map(({ name }) => name); -} - -export function loadMappings(mappings) { - perIndexTypes = {}; - - Object.entries(mappings).forEach(([index, indexMapping]) => { - const normalizedIndexMappings = {}; - - // Migrate 1.0.0 mappings. This format has changed, so we need to extract the underlying mapping. - if (indexMapping.mappings && Object.keys(indexMapping).length === 1) { - indexMapping = indexMapping.mappings; - } - - Object.entries(indexMapping).forEach(([typeName, typeMapping]) => { - if (typeName === 'properties') { - const fieldList = getFieldNamesFromProperties(typeMapping); - normalizedIndexMappings[typeName] = fieldList; - } else { - normalizedIndexMappings[typeName] = []; - } - }); - perIndexTypes[index] = normalizedIndexMappings; - }); -} - -export function loadAliases(aliases) { - perAliasIndexes = {}; - Object.entries(aliases).forEach(([index, omdexAliases]) => { - // verify we have an index defined. useful when mapping loading is disabled - perIndexTypes[index] = perIndexTypes[index] || {}; - - Object.keys(omdexAliases.aliases || {}).forEach((alias) => { - if (alias === index) { - return; - } // alias which is identical to index means no index. - let curAliases = perAliasIndexes[alias]; - if (!curAliases) { - curAliases = []; - perAliasIndexes[alias] = curAliases; - } - curAliases.push(index); - }); - }); - - perAliasIndexes._all = getIndices(false); -} - -export function clear() { - perIndexTypes = {}; - perAliasIndexes = {}; - legacyTemplates = []; - indexTemplates = []; - componentTemplates = []; -} - -function retrieveSettings(http, settingsKey, settingsToRetrieve) { - const settingKeyToPathMap = { - fields: '_mapping', - indices: '_aliases', - legacyTemplates: '_template', - indexTemplates: '_index_template', - componentTemplates: '_component_template', - dataStreams: '_data_stream', - }; - // Fetch autocomplete info if setting is set to true, and if user has made changes. - if (settingsToRetrieve[settingsKey] === true) { - // Use pretty=false in these request in order to compress the response by removing whitespace - const path = `${settingKeyToPathMap[settingsKey]}?pretty=false`; - const method = 'GET'; - const asSystemRequest = true; - const withProductOrigin = true; - - return es.send({ http, method, path, asSystemRequest, withProductOrigin }); - } else { - if (settingsToRetrieve[settingsKey] === false) { - // If the user doesn't want autocomplete suggestions, then clear any that exist - return Promise.resolve({}); - // return settingsPromise.resolveWith(this, [{}]); - } else { - // If the user doesn't want autocomplete suggestions, then clear any that exist - return Promise.resolve(); - } - } -} - -// Retrieve all selected settings by default. -// TODO: We should refactor this to be easier to consume. Ideally this function should retrieve -// whatever settings are specified, otherwise just use the saved settings. This requires changing -// the behavior to not *clear* whatever settings have been unselected, but it's hard to tell if -// this is possible without altering the autocomplete behavior. These are the scenarios we need to -// support: -// 1. Manual refresh. Specify what we want. Fetch specified, leave unspecified alone. -// 2. Changed selection and saved: Specify what we want. Fetch changed and selected, leave -// unchanged alone (both selected and unselected). -// 3. Poll: Use saved. Fetch selected. Ignore unselected. - -export function clearSubscriptions() { - if (pollTimeoutId) { - clearTimeout(pollTimeoutId); - } -} - -const retrieveMappings = async (http, settingsToRetrieve) => { - const mappings = await retrieveSettings(http, 'fields', settingsToRetrieve); - - if (mappings) { - const maxMappingSize = Object.keys(mappings).length > 10 * 1024 * 1024; - let mappingsResponse; - if (maxMappingSize) { - console.warn( - `Mapping size is larger than 10MB (${ - Object.keys(mappings).length / 1024 / 1024 - } MB). Ignoring...` - ); - mappingsResponse = '{}'; - } else { - mappingsResponse = mappings; - } - loadMappings(mappingsResponse); - } -}; - -const retrieveAliases = async (http, settingsToRetrieve) => { - const aliases = await retrieveSettings(http, 'indices', settingsToRetrieve); - - if (aliases) { - loadAliases(aliases); - } -}; - -const retrieveTemplates = async (http, settingsToRetrieve) => { - const legacyTemplates = await retrieveSettings(http, 'legacyTemplates', settingsToRetrieve); - const indexTemplates = await retrieveSettings(http, 'indexTemplates', settingsToRetrieve); - const componentTemplates = await retrieveSettings(http, 'componentTemplates', settingsToRetrieve); - - if (legacyTemplates) { - loadLegacyTemplates(legacyTemplates); - } - - if (indexTemplates) { - loadIndexTemplates(indexTemplates); - } - - if (componentTemplates) { - loadComponentTemplates(componentTemplates); - } -}; - -const retrieveDataStreams = async (http, settingsToRetrieve) => { - const dataStreams = await retrieveSettings(http, 'dataStreams', settingsToRetrieve); - - if (dataStreams) { - loadDataStreams(dataStreams); - } -}; -/** - * - * @param settings Settings A way to retrieve the current settings - * @param settingsToRetrieve any - */ -export function retrieveAutoCompleteInfo(http, settings, settingsToRetrieve) { - clearSubscriptions(); - - const templatesSettingToRetrieve = { - ...settingsToRetrieve, - legacyTemplates: settingsToRetrieve.templates, - indexTemplates: settingsToRetrieve.templates, - componentTemplates: settingsToRetrieve.templates, - }; - - Promise.allSettled([ - retrieveMappings(http, settingsToRetrieve), - retrieveAliases(http, settingsToRetrieve), - retrieveTemplates(http, templatesSettingToRetrieve), - retrieveDataStreams(http, settingsToRetrieve), - ]).then(() => { - // Schedule next request. - pollTimeoutId = setTimeout(() => { - // This looks strange/inefficient, but it ensures correct behavior because we don't want to send - // a scheduled request if the user turns off polling. - if (settings.getPolling()) { - retrieveAutoCompleteInfo(http, settings, settings.getAutocomplete()); - } - }, settings.getPollInterval()); - }); -} diff --git a/src/plugins/console/public/plugin.ts b/src/plugins/console/public/plugin.ts index e6a4d7fff61b0..33ee5446dc268 100644 --- a/src/plugins/console/public/plugin.ts +++ b/src/plugins/console/public/plugin.ts @@ -15,8 +15,10 @@ import { ConsolePluginSetup, ConsoleUILocatorParams, } from './types'; +import { AutocompleteInfo, setAutocompleteInfo } from './services'; export class ConsoleUIPlugin implements Plugin { + private readonly autocompleteInfo = new AutocompleteInfo(); constructor(private ctx: PluginInitializerContext) {} public setup( @@ -27,6 +29,9 @@ export class ConsoleUIPlugin implements Plugin(); + this.autocompleteInfo.setup(http); + setAutocompleteInfo(this.autocompleteInfo); + if (isConsoleUiEnabled) { if (home) { home.featureCatalogue.register({ @@ -70,6 +75,7 @@ export class ConsoleUIPlugin implements Plugin | undefined; + + public setup(http: HttpSetup) { + this.http = http; + } + + public getEntityProvider( + type: string, + context: { indices: string[]; types: string[] } = { indices: [], types: [] } + ) { + switch (type) { + case 'indices': + const includeAliases = true; + const collaborator = this.mapping; + return () => this.alias.getIndices(includeAliases, collaborator); + case 'fields': + return this.mapping.getMappings(context.indices, context.types); + case 'indexTemplates': + return () => this.indexTemplate.getTemplates(); + case 'componentTemplates': + return () => this.componentTemplate.getTemplates(); + case 'legacyTemplates': + return () => this.legacyTemplate.getTemplates(); + case 'dataStreams': + return () => this.dataStream.getDataStreams(); + default: + throw new Error(`Unsupported type: ${type}`); + } + } + + public retrieve(settings: Settings, settingsToRetrieve: DevToolsSettings['autocomplete']) { + this.clearSubscriptions(); + this.http + .get(`${API_BASE_PATH}/autocomplete_entities`, { + query: { ...settingsToRetrieve }, + }) + .then((data) => { + this.load(data); + // Schedule next request. + this.pollTimeoutId = setTimeout(() => { + // This looks strange/inefficient, but it ensures correct behavior because we don't want to send + // a scheduled request if the user turns off polling. + if (settings.getPolling()) { + this.retrieve(settings, settings.getAutocomplete()); + } + }, settings.getPollInterval()); + }); + } + + public clearSubscriptions() { + if (this.pollTimeoutId) { + clearTimeout(this.pollTimeoutId); + } + } + + private load(data: MappingsApiResponse) { + this.mapping.loadMappings(data.mappings); + const collaborator = this.mapping; + this.alias.loadAliases(data.aliases, collaborator); + this.indexTemplate.loadTemplates(data.indexTemplates); + this.componentTemplate.loadTemplates(data.componentTemplates); + this.legacyTemplate.loadTemplates(data.legacyTemplates); + this.dataStream.loadDataStreams(data.dataStreams); + } + + public clear() { + this.alias.clearAliases(); + this.mapping.clearMappings(); + this.dataStream.clearDataStreams(); + this.legacyTemplate.clearTemplates(); + this.indexTemplate.clearTemplates(); + this.componentTemplate.clearTemplates(); + } +} + +export const [getAutocompleteInfo, setAutocompleteInfo] = + createGetterSetter('AutocompleteInfo'); diff --git a/src/plugins/console/public/services/index.ts b/src/plugins/console/public/services/index.ts index c37c9d9359a16..2447ab1438ba4 100644 --- a/src/plugins/console/public/services/index.ts +++ b/src/plugins/console/public/services/index.ts @@ -10,3 +10,4 @@ export { createHistory, History } from './history'; export { createStorage, Storage, StorageKeys } from './storage'; export type { DevToolsSettings } from './settings'; export { createSettings, Settings, DEFAULT_SETTINGS } from './settings'; +export { AutocompleteInfo, getAutocompleteInfo, setAutocompleteInfo } from './autocomplete'; diff --git a/src/plugins/console/server/plugin.ts b/src/plugins/console/server/plugin.ts index c1ae53bbaabc6..2ab87d4e9fcc5 100644 --- a/src/plugins/console/server/plugin.ts +++ b/src/plugins/console/server/plugin.ts @@ -16,6 +16,7 @@ import { ConsoleConfig, ConsoleConfig7x } from './config'; import { registerRoutes } from './routes'; import { ESConfigForProxy, ConsoleSetup, ConsoleStart } from './types'; +import { handleEsError } from './shared_imports'; export class ConsoleServerPlugin implements Plugin { log: Logger; @@ -58,6 +59,9 @@ export class ConsoleServerPlugin implements Plugin { esLegacyConfigService: this.esLegacyConfigService, specDefinitionService: this.specDefinitionsService, }, + lib: { + handleEsError, + }, proxy: { readLegacyESConfig: async (): Promise => { const legacyConfig = await this.esLegacyConfigService.readConfig(); diff --git a/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts b/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts new file mode 100644 index 0000000000000..796451b2721f3 --- /dev/null +++ b/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { registerMappingsRoute } from './register_mappings_route'; diff --git a/src/plugins/console/server/routes/api/console/autocomplete_entities/register_get_route.ts b/src/plugins/console/server/routes/api/console/autocomplete_entities/register_get_route.ts new file mode 100644 index 0000000000000..9d5778f0a9b0f --- /dev/null +++ b/src/plugins/console/server/routes/api/console/autocomplete_entities/register_get_route.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { IScopedClusterClient } from '@kbn/core/server'; +import { parse } from 'query-string'; +import type { RouteDependencies } from '../../..'; +import { API_BASE_PATH } from '../../../../../common/constants'; + +interface Settings { + indices: boolean; + fields: boolean; + templates: boolean; + dataStreams: boolean; +} + +async function getMappings(esClient: IScopedClusterClient, settings: Settings) { + if (settings.fields) { + return esClient.asInternalUser.indices.getMapping(); + } + // If the user doesn't want autocomplete suggestions, then clear any that exist. + return Promise.resolve({}); +} + +async function getAliases(esClient: IScopedClusterClient, settings: Settings) { + if (settings.indices) { + return esClient.asInternalUser.indices.getAlias(); + } + // If the user doesn't want autocomplete suggestions, then clear any that exist. + return Promise.resolve({}); +} + +async function getDataStreams(esClient: IScopedClusterClient, settings: Settings) { + if (settings.dataStreams) { + return esClient.asInternalUser.indices.getDataStream(); + } + // If the user doesn't want autocomplete suggestions, then clear any that exist. + return Promise.resolve({}); +} + +async function getTemplates(esClient: IScopedClusterClient, settings: Settings) { + if (settings.templates) { + return Promise.all([ + esClient.asInternalUser.indices.getTemplate(), + esClient.asInternalUser.indices.getIndexTemplate(), + esClient.asInternalUser.cluster.getComponentTemplate(), + ]); + } + // If the user doesn't want autocomplete suggestions, then clear any that exist. + return Promise.resolve([]); +} + +export function registerGetRoute({ router, lib: { handleEsError } }: RouteDependencies) { + router.get( + { + path: `${API_BASE_PATH}/autocomplete_entities`, + validate: false, + }, + async (ctx, request, response) => { + try { + const settings = parse(request.url.search, { parseBooleans: true }) as unknown as Settings; + + // If no settings are provided return 400 + if (Object.keys(settings).length === 0) { + return response.badRequest({ + body: 'Request must contain a query param of autocomplete settings', + }); + } + + const esClient = (await ctx.core).elasticsearch.client; + const mappings = await getMappings(esClient, settings); + const aliases = await getAliases(esClient, settings); + const dataStreams = await getDataStreams(esClient, settings); + const [legacyTemplates = {}, indexTemplates = {}, componentTemplates = {}] = + await getTemplates(esClient, settings); + + return response.ok({ + body: { + mappings, + aliases, + dataStreams, + legacyTemplates, + indexTemplates, + componentTemplates, + }, + }); + } catch (e) { + return handleEsError({ error: e, response }); + } + } + ); +} diff --git a/src/plugins/console/server/routes/api/console/autocomplete_entities/register_mappings_route.ts b/src/plugins/console/server/routes/api/console/autocomplete_entities/register_mappings_route.ts new file mode 100644 index 0000000000000..53d12f69d30e5 --- /dev/null +++ b/src/plugins/console/server/routes/api/console/autocomplete_entities/register_mappings_route.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { RouteDependencies } from '../../..'; +import { registerGetRoute } from './register_get_route'; + +export function registerMappingsRoute(deps: RouteDependencies) { + registerGetRoute(deps); +} diff --git a/src/plugins/console/server/routes/api/console/proxy/mocks.ts b/src/plugins/console/server/routes/api/console/proxy/mocks.ts index cef9ea34a11ca..61f8e510f9735 100644 --- a/src/plugins/console/server/routes/api/console/proxy/mocks.ts +++ b/src/plugins/console/server/routes/api/console/proxy/mocks.ts @@ -17,6 +17,7 @@ import { MAJOR_VERSION } from '../../../../../common/constants'; import { ProxyConfigCollection } from '../../../../lib'; import { RouteDependencies, ProxyDependencies } from '../../..'; import { EsLegacyConfigService, SpecDefinitionsService } from '../../../../services'; +import { handleEsError } from '../../../../shared_imports'; const kibanaVersion = new SemVer(MAJOR_VERSION); @@ -65,5 +66,6 @@ export const getProxyRouteHandlerDeps = ({ : defaultProxyValue, log, kibanaVersion, + lib: { handleEsError }, }; }; diff --git a/src/plugins/console/server/routes/index.ts b/src/plugins/console/server/routes/index.ts index a3263fff2e435..b82b2ffbffa8e 100644 --- a/src/plugins/console/server/routes/index.ts +++ b/src/plugins/console/server/routes/index.ts @@ -12,10 +12,12 @@ import { SemVer } from 'semver'; import { EsLegacyConfigService, SpecDefinitionsService } from '../services'; import { ESConfigForProxy } from '../types'; import { ProxyConfigCollection } from '../lib'; +import { handleEsError } from '../shared_imports'; import { registerEsConfigRoute } from './api/console/es_config'; import { registerProxyRoute } from './api/console/proxy'; import { registerSpecDefinitionsRoute } from './api/console/spec_definitions'; +import { registerMappingsRoute } from './api/console/autocomplete_entities'; export interface ProxyDependencies { readLegacyESConfig: () => Promise; @@ -31,6 +33,9 @@ export interface RouteDependencies { esLegacyConfigService: EsLegacyConfigService; specDefinitionService: SpecDefinitionsService; }; + lib: { + handleEsError: typeof handleEsError; + }; kibanaVersion: SemVer; } @@ -38,4 +43,5 @@ export const registerRoutes = (dependencies: RouteDependencies) => { registerEsConfigRoute(dependencies); registerProxyRoute(dependencies); registerSpecDefinitionsRoute(dependencies); + registerMappingsRoute(dependencies); }; diff --git a/src/plugins/console/server/shared_imports.ts b/src/plugins/console/server/shared_imports.ts new file mode 100644 index 0000000000000..f709280aa013b --- /dev/null +++ b/src/plugins/console/server/shared_imports.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { handleEsError } from '@kbn/es-ui-shared-plugin/server'; diff --git a/test/api_integration/apis/console/autocomplete_entities.ts b/test/api_integration/apis/console/autocomplete_entities.ts new file mode 100644 index 0000000000000..7f74156f379a0 --- /dev/null +++ b/test/api_integration/apis/console/autocomplete_entities.ts @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import type { Response } from 'superagent'; +import type { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + + function utilTest(name: string, query: object, test: (response: Response) => void) { + it(name, async () => { + const response = await supertest.get('/api/console/autocomplete_entities').query(query); + test(response); + }); + } + + describe('/api/console/autocomplete_entities', () => { + utilTest('should not succeed if no settings are provided in query params', {}, (response) => { + const { status } = response; + expect(status).to.be(400); + }); + + utilTest( + 'should return an object with properties of "mappings", "aliases", "dataStreams", "legacyTemplates", "indexTemplates", "componentTemplates"', + { + indices: true, + fields: true, + templates: true, + dataStreams: true, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(Object.keys(body).sort()).to.eql([ + 'aliases', + 'componentTemplates', + 'dataStreams', + 'indexTemplates', + 'legacyTemplates', + 'mappings', + ]); + } + ); + + utilTest( + 'should return empty payload with all settings are set to false', + { + indices: false, + fields: false, + templates: false, + dataStreams: false, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(body.legacyTemplates).to.eql({}); + expect(body.indexTemplates).to.eql({}); + expect(body.componentTemplates).to.eql({}); + expect(body.aliases).to.eql({}); + expect(body.mappings).to.eql({}); + expect(body.dataStreams).to.eql({}); + } + ); + + utilTest( + 'should return empty templates with templates setting is set to false', + { + indices: true, + fields: true, + templates: false, + dataStreams: true, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(body.legacyTemplates).to.eql({}); + expect(body.indexTemplates).to.eql({}); + expect(body.componentTemplates).to.eql({}); + } + ); + + utilTest( + 'should return empty data streams with dataStreams setting is set to false', + { + indices: true, + fields: true, + templates: true, + dataStreams: false, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(body.dataStreams).to.eql({}); + } + ); + + utilTest( + 'should return empty aliases with indices setting is set to false', + { + indices: false, + fields: true, + templates: true, + dataStreams: true, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(body.aliases).to.eql({}); + } + ); + + utilTest( + 'should return empty mappings with fields setting is set to false', + { + indices: true, + fields: false, + templates: true, + dataStreams: true, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(body.mappings).to.eql({}); + } + ); + }); +}; diff --git a/test/api_integration/apis/console/index.ts b/test/api_integration/apis/console/index.ts index ad4f8256f97ad..81f6f17f77b87 100644 --- a/test/api_integration/apis/console/index.ts +++ b/test/api_integration/apis/console/index.ts @@ -11,5 +11,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('core', () => { loadTestFile(require.resolve('./proxy_route')); + loadTestFile(require.resolve('./autocomplete_entities')); }); } From fab11ee537a13d009b4c74f28e4f7e316ab3ca26 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 19 May 2022 12:53:15 +0300 Subject: [PATCH 14/37] [ResponseOps]: Sub action connectors framework (backend) (#129307) Co-authored-by: Xavier Mouligneau Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../builtin_action_types/servicenow/types.ts | 1 - x-pack/plugins/actions/server/index.ts | 4 + x-pack/plugins/actions/server/mocks.ts | 3 + x-pack/plugins/actions/server/plugin.ts | 29 +- .../server/sub_action_framework/README.md | 356 ++++++++++++++++++ .../server/sub_action_framework/case.test.ts | 206 ++++++++++ .../server/sub_action_framework/case.ts | 119 ++++++ .../sub_action_framework/executor.test.ts | 198 ++++++++++ .../server/sub_action_framework/executor.ts | 86 +++++ .../server/sub_action_framework/index.ts | 33 ++ .../server/sub_action_framework/mocks.ts | 194 ++++++++++ .../sub_action_framework/register.test.ts | 58 +++ .../server/sub_action_framework/register.ts | 57 +++ .../sub_action_connector.test.ts | 343 +++++++++++++++++ .../sub_action_connector.ts | 175 +++++++++ .../sub_action_framework/translations.ts | 20 + .../server/sub_action_framework/types.ts | 83 ++++ .../sub_action_framework/validators.test.ts | 98 +++++ .../server/sub_action_framework/validators.ts | 38 ++ .../alerting_api_integration/common/config.ts | 2 + .../plugins/alerts/server/action_types.ts | 12 + .../alerts/server/sub_action_connector.ts | 109 ++++++ .../group2/tests/actions/index.ts | 5 + .../actions/sub_action_framework/index.ts | 318 ++++++++++++++++ 24 files changed, 2545 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/actions/server/sub_action_framework/README.md create mode 100644 x-pack/plugins/actions/server/sub_action_framework/case.test.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/case.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/executor.test.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/executor.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/index.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/mocks.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/register.test.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/register.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.test.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/translations.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/types.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/validators.test.ts create mode 100644 x-pack/plugins/actions/server/sub_action_framework/validators.ts create mode 100644 x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/sub_action_framework/index.ts diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index ff3a92e935818..63cb0195a14f4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -245,7 +245,6 @@ export interface ImportSetApiResponseError { export type ImportSetApiResponse = ImportSetApiResponseSuccess | ImportSetApiResponseError; export interface GetApplicationInfoResponse { - id: string; name: string; scope: string; version: string; diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index 6b0070af0b022..3b9869be91413 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -55,6 +55,10 @@ export { ACTION_SAVED_OBJECT_TYPE } from './constants/saved_objects'; export const plugin = (initContext: PluginInitializerContext) => new ActionsPlugin(initContext); +export { SubActionConnector } from './sub_action_framework/sub_action_connector'; +export { CaseConnector } from './sub_action_framework/case'; +export type { ServiceParams } from './sub_action_framework/types'; + export const config: PluginConfigDescriptor = { schema: configSchema, exposeToBrowser: { diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index 00cca942fe14b..c6e5d7979c55f 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -24,7 +24,10 @@ const logger = loggingSystemMock.create().get() as jest.Mocked; const createSetupMock = () => { const mock: jest.Mocked = { registerType: jest.fn(), + registerSubActionConnectorType: jest.fn(), isPreconfiguredConnector: jest.fn(), + getSubActionConnectorClass: jest.fn(), + getCaseConnectorClass: jest.fn(), }; return mock; }; diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 131563fd3e731..4bbdb26b8e6a1 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -97,6 +97,10 @@ import { isConnectorDeprecated, ConnectorWithOptionalDeprecation, } from './lib/is_conector_deprecated'; +import { createSubActionConnectorFramework } from './sub_action_framework'; +import { IServiceAbstract, SubActionConnectorType } from './sub_action_framework/types'; +import { SubActionConnector } from './sub_action_framework/sub_action_connector'; +import { CaseConnector } from './sub_action_framework/case'; export interface PluginSetupContract { registerType< @@ -107,8 +111,15 @@ export interface PluginSetupContract { >( actionType: ActionType ): void; - + registerSubActionConnectorType< + Config extends ActionTypeConfig = ActionTypeConfig, + Secrets extends ActionTypeSecrets = ActionTypeSecrets + >( + connector: SubActionConnectorType + ): void; isPreconfiguredConnector(connectorId: string): boolean; + getSubActionConnectorClass: () => IServiceAbstract; + getCaseConnectorClass: () => IServiceAbstract; } export interface PluginStartContract { @@ -310,6 +321,12 @@ export class ActionsPlugin implements Plugin(), @@ -342,11 +359,21 @@ export class ActionsPlugin implements Plugin( + connector: SubActionConnectorType + ) => { + subActionFramework.registerConnector(connector); + }, isPreconfiguredConnector: (connectorId: string): boolean => { return !!this.preconfiguredActions.find( (preconfigured) => preconfigured.id === connectorId ); }, + getSubActionConnectorClass: () => SubActionConnector, + getCaseConnectorClass: () => CaseConnector, }; } diff --git a/x-pack/plugins/actions/server/sub_action_framework/README.md b/x-pack/plugins/actions/server/sub_action_framework/README.md new file mode 100644 index 0000000000000..90951692f5457 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/README.md @@ -0,0 +1,356 @@ +# Sub actions framework + +## Summary + +The Kibana actions plugin provides a framework to create executable actions that supports sub actions. That means you can execute different flows (sub actions) when you execute an action. The framework provides tools to aid you to focus only on the business logic of your connector. You can: + +- Register a sub action and map it to a function of your choice. +- Define a schema for the parameters of your sub action. +- Define a response schema for responses from external services. +- Create connectors that are supported by the Cases management system. + +The framework is built on top of the current actions framework and it is not a replacement of it. All practices described on the plugin's main [README](../../README.md#developing-new-action-types) applies to this framework also. + +## Classes + +The framework provides two classes. The `SubActionConnector` class and the `CaseConnector` class. When registering your connector you should provide a class that implements the business logic of your connector. The class must extend one of the two classes provided by the framework. The classes provides utility functions to register sub actions and make requests to external services. + + +If you extend the `SubActionConnector`, you should implement the following abstract methods: +- `getResponseErrorMessage(error: AxiosError): string;` + + +If you extend the `CaseConnector`, you should implement the following abstract methods: + +- `getResponseErrorMessage(error: AxiosError): string;` +- `addComment({ incidentId, comment }): Promise` +- `createIncident(incident): Promise` +- `updateIncident({ incidentId, incident }): Promise` +- `getIncident({ id }): Promise` + +where + +``` +interface ExternalServiceIncidentResponse { + id: string; + title: string; + url: string; + pushedDate: string; +} +``` + +The `CaseConnector` class registers automatically the `pushToService` sub action and implements the corresponding method that is needed by Cases. + + +### Class Diagrams + +```mermaid +classDiagram + SubActionConnector <|-- CaseConnector + + class SubActionConnector{ + -subActions + #config + #secrets + #registerSubAction(subAction) + +getResponseErrorMessage(error)* + +getSubActions() + +registerSubAction(subAction) + } + + class CaseConnector{ + +addComment(comment)* + +createIncident(incident)* + +updateIncident(incidentId, incident)* + +getIncident(incidentId)* + +pushToService(params) + } +``` + +### Examples of extending the classes + +```mermaid +classDiagram + SubActionConnector <|-- CaseConnector + SubActionConnector <|-- Tines + CaseConnector <|-- ServiceNow + + class SubActionConnector{ + -subActions + #config + #secrets + #registerSubAction(subAction) + +getSubActions() + +register(params) + } + + class CaseConnector{ + +addComment(comment)* + +createIncident(incident)* + +updateIncident(incidentId, incident)* + +getIncident(incidentId)* + +pushToService(params) + } + + class ServiceNow{ + +getFields() + +getChoices() + } + + class Tines{ + +getStories() + +getWebooks(storyId) + +runAction(actionId) + } +``` + +## Usage + +This guide assumes that you created a class that extends one of the two classes provided by the framework. + +### Register a sub action + +To register a sub action use the `registerSubAction` method provided by the base classes. It expects the name of the sub action, the name of the method of the class that will be called when the sub action is triggered, and a validation schema for the sub action parameters. Example: + +``` +this.registerSubAction({ name: 'fields', method: 'getFields', schema: schema.object({ incidentId: schema.string() }) }) +``` + +If your method does not accepts any arguments pass `null` to the schema property. Example: + +``` +this.registerSubAction({ name: 'noParams', method: 'noParams', schema: null }) +``` + +### Request to an external service + +To make a request to an external you should use the `request` method provided by the base classes. It accepts all attributes of the [request configuration object](https://github.com/axios/axios#request-config) of axios plus the expected response schema. Example: + +``` +const res = await this.request({ + auth: this.getBasicAuth(), + url: 'https://example/com/api/incident/1', + method: 'get', + responseSchema: schema.object({ id: schema.string(), name: schema.string() }) }, + }); +``` + +The message returned by the `getResponseErrorMessage` method will be used by the framework as an argument to the constructor of the `Error` class. Then the framework will thrown the `error`. + +The request method does the following: + +- Logs the request URL and method for debugging purposes. +- Asserts the URL. +- Normalizes the URL. +- Ensures that the URL is in the allow list. +- Configures proxies. +- Validates the response. + +### Error messages from external services + +Each external service has a different response schema for errors. For that reason, you have to implement the abstract method `getResponseErrorMessage` which returns a string representing the error message of the response. Example: + +``` +interface ErrorSchema { + errorMessage: string; + errorCode: number; +} + +protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } +``` + +### Remove null or undefined values from data + +There is a possibility that an external service would throw an error for fields with `null` values. For that reason, the base classes provide the `removeNullOrUndefinedFields` utility function to remove or `null` or `undefined` values from an object. Example: + +``` +// Returns { foo: 'foo' } +this.removeNullOrUndefinedFields({ toBeRemoved: null, foo: 'foo' }) +``` + +## Example: Sub action connector + +``` +import { schema, TypeOf } from '@kbn/config-schema'; +import { AxiosError } from 'axios'; +import { SubActionConnector } from './basic'; +import { CaseConnector } from './case'; +import { ExternalServiceIncidentResponse, ServiceParams } from './types'; + +export const TestConfigSchema = schema.object({ url: schema.string() }); +export const TestSecretsSchema = schema.object({ + username: schema.string(), + password: schema.string(), +}); +export type TestConfig = TypeOf; +export type TestSecrets = TypeOf; + +interface ErrorSchema { + errorMessage: string; + errorCode: number; +} + +export class TestBasicConnector extends SubActionConnector { + constructor(params: ServiceParams) { + super(params); + this.registerSubAction({ + name: 'mySubAction', + method: 'triggerSubAction', + schema: schema.object({ id: schema.string() }), + }); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } + + public async triggerSubAction({ id }: { id: string; }) { + const res = await this.request({ + url, + data, + headers: { 'X-Test-Header': 'test' }, + responseSchema: schema.object({ status: schema.string() }), + }); + + return res; + } +} +``` + +## Example: Case connector + +``` +import { schema, TypeOf } from '@kbn/config-schema'; +import { AxiosError } from 'axios'; +import { SubActionConnector } from './basic'; +import { CaseConnector } from './case'; +import { ExternalServiceIncidentResponse, ServiceParams } from './types'; + +export const TestConfigSchema = schema.object({ url: schema.string() }); +export const TestSecretsSchema = schema.object({ + username: schema.string(), + password: schema.string(), +}); +export type TestConfig = TypeOf; +export type TestSecrets = TypeOf; + +interface ErrorSchema { + errorMessage: string; + errorCode: number; +} + +export class TestCaseConnector extends CaseConnector { + constructor(params: ServiceParams) { + super(params); + this.registerSubAction({ + name: 'categories', + method: 'getCategories', + schema: null, + }); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } + + public async createIncident(incident: { + incident: Record + }): Promise { + const res = await this.request({ + method: 'post', + url: 'https://example.com/api/incident', + data: { incident }, + responseSchema: schema.object({ id: schema.string(), title: schema.string() }), + }); + + return { + id: res.data.id, + title: res.data.title, + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async addComment({ + incidentId, + comment, + }: { + incidentId: string; + comment: string; + }): Promise { + const res = await this.request({ + url: `https://example.com/api/incident/${incidentId}/comment`, + data: { comment }, + responseSchema: schema.object({ id: schema.string(), title: schema.string() }), + }); + + return { + id: res.data.id, + title: res.data.title, + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async updateIncident({ + incidentId, + incident, + }: { + incidentId: string; + incident: { category: string }; + }): Promise { + const res = await this.request({ + method: 'put', + url: `https://example.com/api/incident/${incidentId}`', + responseSchema: schema.object({ id: schema.string(), title: schema.string() }), + }); + + return { + id: res.data.id, + title: res.data.title, + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async getIncident({ id }: { id: string }): Promise { + const res = await this.request({ + url: 'https://example.com/api/incident/1', + responseSchema: schema.object({ id: schema.string(), title: schema.string() }), + }); + + return { + id: res.data.id, + title: res.data.title, + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async getCategories() { + const res = await this.request({ + url: 'https://example.com/api/categories', + responseSchema: schema.object({ categories: schema.array(schema.string()) }), + }); + + return res; + } +``` + +### Example: Register sub action connector + +The actions framework exports the `registerSubActionConnectorType` to register sub action connectors. Example: + +``` +plugins.actions.registerSubActionConnectorType({ + id: '.test-sub-action-connector', + name: 'Test: Sub action connector', + minimumLicenseRequired: 'platinum' as const, + schema: { config: TestConfigSchema, secrets: TestSecretsSchema }, + Service: TestSubActionConnector, +}); +``` + +You can see a full example in [x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts](../../../../test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts) \ No newline at end of file diff --git a/x-pack/plugins/actions/server/sub_action_framework/case.test.ts b/x-pack/plugins/actions/server/sub_action_framework/case.test.ts new file mode 100644 index 0000000000000..7de7e4f903e0d --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/case.test.ts @@ -0,0 +1,206 @@ +/* + * Copyright 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 { loggingSystemMock } from '@kbn/core/server/mocks'; +import { MockedLogger } from '@kbn/logging-mocks'; +import { actionsConfigMock } from '../actions_config.mock'; +import { actionsMock } from '../mocks'; +import { TestCaseConnector } from './mocks'; +import { ActionsConfigurationUtilities } from '../actions_config'; + +describe('CaseConnector', () => { + const pushToServiceParams = { externalId: null, comments: [] }; + let logger: MockedLogger; + let services: ReturnType; + let mockedActionsConfig: jest.Mocked; + let service: TestCaseConnector; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + + logger = loggingSystemMock.createLogger(); + services = actionsMock.createServices(); + mockedActionsConfig = actionsConfigMock.create(); + + mockedActionsConfig.getResponseSettings.mockReturnValue({ + maxContentLength: 1000000, + timeout: 360000, + }); + + service = new TestCaseConnector({ + configurationUtilities: mockedActionsConfig, + logger, + connector: { id: 'test-id', type: '.test' }, + config: { url: 'https://example.com' }, + secrets: { username: 'elastic', password: 'changeme' }, + services, + }); + }); + + describe('Sub actions', () => { + it('registers the pushToService sub action correctly', async () => { + const subActions = service.getSubActions(); + expect(subActions.get('pushToService')).toEqual({ + method: 'pushToService', + name: 'pushToService', + schema: expect.anything(), + }); + }); + + it('should validate the schema of pushToService correctly', async () => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect( + subAction?.schema?.validate({ + externalId: 'test', + comments: [{ comment: 'comment', commentId: 'comment-id' }], + }) + ).toEqual({ + externalId: 'test', + comments: [{ comment: 'comment', commentId: 'comment-id' }], + }); + }); + + it('should accept null for externalId', async () => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect(subAction?.schema?.validate({ externalId: null, comments: [] })); + }); + + it.each([[undefined], [1], [false], [{ test: 'hello' }], [['test']], [{ test: 'hello' }]])( + 'should throw if externalId is %p', + async (externalId) => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect(() => subAction?.schema?.validate({ externalId, comments: [] })); + } + ); + + it('should accept null for comments', async () => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect(subAction?.schema?.validate({ externalId: 'test', comments: null })); + }); + + it.each([ + [undefined], + [1], + [false], + [{ test: 'hello' }], + [['test']], + [{ test: 'hello' }], + [{ comment: 'comment', commentId: 'comment-id', foo: 'foo' }], + ])('should throw if comments %p', async (comments) => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect(() => subAction?.schema?.validate({ externalId: 'test', comments })); + }); + + it('should allow any field in the params', async () => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect( + subAction?.schema?.validate({ + externalId: 'test', + comments: [{ comment: 'comment', commentId: 'comment-id' }], + foo: 'foo', + bar: 1, + baz: [{ test: 'hello' }, 1, 'test', false], + isValid: false, + val: null, + }) + ).toEqual({ + externalId: 'test', + comments: [{ comment: 'comment', commentId: 'comment-id' }], + foo: 'foo', + bar: 1, + baz: [{ test: 'hello' }, 1, 'test', false], + isValid: false, + val: null, + }); + }); + }); + + describe('pushToService', () => { + it('should create an incident if externalId is null', async () => { + const res = await service.pushToService(pushToServiceParams); + expect(res).toEqual({ + id: 'create-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }); + }); + + it('should update an incident if externalId is not null', async () => { + const res = await service.pushToService({ ...pushToServiceParams, externalId: 'test-id' }); + expect(res).toEqual({ + id: 'update-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }); + }); + + it('should add comments', async () => { + const res = await service.pushToService({ + ...pushToServiceParams, + comments: [ + { comment: 'comment-1', commentId: 'comment-id-1' }, + { comment: 'comment-2', commentId: 'comment-id-2' }, + ], + }); + + expect(res).toEqual({ + id: 'create-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + comments: [ + { + commentId: 'comment-id-1', + pushedDate: '2022-05-06T09:41:00.401Z', + }, + { + commentId: 'comment-id-2', + pushedDate: '2022-05-06T09:41:00.401Z', + }, + ], + }); + }); + + it.each([[undefined], [null]])('should throw if externalId is %p', async (comments) => { + const res = await service.pushToService({ + ...pushToServiceParams, + // @ts-expect-error + comments, + }); + + expect(res).toEqual({ + id: 'create-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }); + }); + + it('should not add comments if comments are an empty array', async () => { + const res = await service.pushToService({ + ...pushToServiceParams, + comments: [], + }); + + expect(res).toEqual({ + id: 'create-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/case.ts b/x-pack/plugins/actions/server/sub_action_framework/case.ts new file mode 100644 index 0000000000000..49e6586926645 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/case.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { + ExternalServiceIncidentResponse, + PushToServiceParams, + PushToServiceResponse, +} from './types'; +import { SubActionConnector } from './sub_action_connector'; +import { ServiceParams } from './types'; + +export interface CaseConnectorInterface { + addComment: ({ + incidentId, + comment, + }: { + incidentId: string; + comment: string; + }) => Promise; + createIncident: (incident: Record) => Promise; + updateIncident: ({ + incidentId, + incident, + }: { + incidentId: string; + incident: Record; + }) => Promise; + getIncident: ({ id }: { id: string }) => Promise; + pushToService: (params: PushToServiceParams) => Promise; +} + +export abstract class CaseConnector + extends SubActionConnector + implements CaseConnectorInterface +{ + constructor(params: ServiceParams) { + super(params); + + this.registerSubAction({ + name: 'pushToService', + method: 'pushToService', + schema: schema.object( + { + externalId: schema.nullable(schema.string()), + comments: schema.nullable( + schema.arrayOf( + schema.object({ + comment: schema.string(), + commentId: schema.string(), + }) + ) + ), + }, + { unknowns: 'allow' } + ), + }); + } + + public abstract addComment({ + incidentId, + comment, + }: { + incidentId: string; + comment: string; + }): Promise; + + public abstract createIncident( + incident: Record + ): Promise; + public abstract updateIncident({ + incidentId, + incident, + }: { + incidentId: string; + incident: Record; + }): Promise; + public abstract getIncident({ id }: { id: string }): Promise; + + public async pushToService(params: PushToServiceParams) { + const { externalId, comments, ...rest } = params; + + let res: PushToServiceResponse; + + if (externalId != null) { + res = await this.updateIncident({ + incidentId: externalId, + incident: rest, + }); + } else { + res = await this.createIncident(rest); + } + + if (comments && Array.isArray(comments) && comments.length > 0) { + res.comments = []; + + for (const currentComment of comments) { + await this.addComment({ + incidentId: res.id, + comment: currentComment.comment, + }); + + res.comments = [ + ...(res.comments ?? []), + { + commentId: currentComment.commentId, + pushedDate: res.pushedDate, + }, + ]; + } + } + + return res; + } +} diff --git a/x-pack/plugins/actions/server/sub_action_framework/executor.test.ts b/x-pack/plugins/actions/server/sub_action_framework/executor.test.ts new file mode 100644 index 0000000000000..410bcda0f30d7 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/executor.test.ts @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { MockedLogger } from '@kbn/logging-mocks'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { actionsConfigMock } from '../actions_config.mock'; +import { actionsMock } from '../mocks'; +import { buildExecutor } from './executor'; +import { + TestSecretsSchema, + TestConfigSchema, + TestNoSubActions, + TestConfig, + TestSecrets, + TestExecutor, +} from './mocks'; +import { IService } from './types'; + +describe('Executor', () => { + const actionId = 'test-action-id'; + const config = { url: 'https://example.com' }; + const secrets = { username: 'elastic', password: 'changeme' }; + const params = { subAction: 'testUrl', subActionParams: { url: 'https://example.com' } }; + let logger: MockedLogger; + let services: ReturnType; + let mockedActionsConfig: jest.Mocked; + + const createExecutor = (Service: IService) => { + const connector = { + id: '.test', + name: 'Test', + minimumLicenseRequired: 'basic' as const, + schema: { + config: TestConfigSchema, + secrets: TestSecretsSchema, + }, + Service, + }; + + return buildExecutor({ configurationUtilities: mockedActionsConfig, logger, connector }); + }; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + + logger = loggingSystemMock.createLogger(); + services = actionsMock.createServices(); + mockedActionsConfig = actionsConfigMock.create(); + }); + + it('should execute correctly', async () => { + const executor = createExecutor(TestExecutor); + + const res = await executor({ + actionId, + params: { subAction: 'echo', subActionParams: { id: 'test-id' } }, + config, + secrets, + services, + }); + + expect(res).toEqual({ + actionId: 'test-action-id', + data: { + id: 'test-id', + }, + status: 'ok', + }); + }); + + it('should execute correctly without schema validation', async () => { + const executor = createExecutor(TestExecutor); + + const res = await executor({ + actionId, + params: { subAction: 'noSchema', subActionParams: { id: 'test-id' } }, + config, + secrets, + services, + }); + + expect(res).toEqual({ + actionId: 'test-action-id', + data: { + id: 'test-id', + }, + status: 'ok', + }); + }); + + it('should return an empty object if the func returns undefined', async () => { + const executor = createExecutor(TestExecutor); + + const res = await executor({ + actionId, + params: { ...params, subAction: 'noData' }, + config, + secrets, + services, + }); + + expect(res).toEqual({ + actionId: 'test-action-id', + data: {}, + status: 'ok', + }); + }); + + it('should execute a non async function', async () => { + const executor = createExecutor(TestExecutor); + + const res = await executor({ + actionId, + params: { ...params, subAction: 'noAsync' }, + config, + secrets, + services, + }); + + expect(res).toEqual({ + actionId: 'test-action-id', + data: {}, + status: 'ok', + }); + }); + + it('throws if the are not sub actions registered', async () => { + const executor = createExecutor(TestNoSubActions); + + await expect(async () => + executor({ actionId, params, config, secrets, services }) + ).rejects.toThrowError('You should register at least one subAction for your connector type'); + }); + + it('throws if the sub action is not registered', async () => { + const executor = createExecutor(TestExecutor); + + await expect(async () => + executor({ + actionId, + params: { subAction: 'not-exist', subActionParams: {} }, + config, + secrets, + services, + }) + ).rejects.toThrowError( + 'Sub action "not-exist" is not registered. Connector id: test-action-id. Connector name: Test. Connector type: .test' + ); + }); + + it('throws if the method does not exists', async () => { + const executor = createExecutor(TestExecutor); + + await expect(async () => + executor({ + actionId, + params, + config, + secrets, + services, + }) + ).rejects.toThrowError( + 'Method "not-exist" does not exists in service. Sub action: "testUrl". Connector id: test-action-id. Connector name: Test. Connector type: .test' + ); + }); + + it('throws if the registered method is not a function', async () => { + const executor = createExecutor(TestExecutor); + + await expect(async () => + executor({ + actionId, + params: { ...params, subAction: 'notAFunction' }, + config, + secrets, + services, + }) + ).rejects.toThrowError( + 'Method "notAFunction" must be a function. Connector id: test-action-id. Connector name: Test. Connector type: .test' + ); + }); + + it('throws if the sub actions params are not valid', async () => { + const executor = createExecutor(TestExecutor); + + await expect(async () => + executor({ actionId, params: { ...params, subAction: 'echo' }, config, secrets, services }) + ).rejects.toThrowError( + 'Request validation failed (Error: [id]: expected value of type [string] but got [undefined])' + ); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/executor.ts b/x-pack/plugins/actions/server/sub_action_framework/executor.ts new file mode 100644 index 0000000000000..469cc383e3d93 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/executor.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from '@kbn/core/server'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { ExecutorType } from '../types'; +import { ExecutorParams, SubActionConnectorType } from './types'; + +const isFunction = (v: unknown): v is Function => { + return typeof v === 'function'; +}; + +const getConnectorErrorMsg = (actionId: string, connector: { id: string; name: string }) => + `Connector id: ${actionId}. Connector name: ${connector.name}. Connector type: ${connector.id}`; + +export const buildExecutor = ({ + configurationUtilities, + connector, + logger, +}: { + connector: SubActionConnectorType; + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; +}): ExecutorType => { + return async ({ actionId, params, config, secrets, services }) => { + const subAction = params.subAction; + const subActionParams = params.subActionParams; + + const service = new connector.Service({ + connector: { id: actionId, type: connector.id }, + config, + secrets, + configurationUtilities, + logger, + services, + }); + + const subActions = service.getSubActions(); + + if (subActions.size === 0) { + throw new Error('You should register at least one subAction for your connector type'); + } + + const action = subActions.get(subAction); + + if (!action) { + throw new Error( + `Sub action "${subAction}" is not registered. ${getConnectorErrorMsg(actionId, connector)}` + ); + } + + const method = action.method; + + if (!service[method]) { + throw new Error( + `Method "${method}" does not exists in service. Sub action: "${subAction}". ${getConnectorErrorMsg( + actionId, + connector + )}` + ); + } + + const func = service[method]; + + if (!isFunction(func)) { + throw new Error( + `Method "${method}" must be a function. ${getConnectorErrorMsg(actionId, connector)}` + ); + } + + if (action.schema) { + try { + action.schema.validate(subActionParams); + } catch (reqValidationError) { + throw new Error(`Request validation failed (${reqValidationError})`); + } + } + + const data = await func.call(service, subActionParams); + return { status: 'ok', data: data ?? {}, actionId }; + }; +}; diff --git a/x-pack/plugins/actions/server/sub_action_framework/index.ts b/x-pack/plugins/actions/server/sub_action_framework/index.ts new file mode 100644 index 0000000000000..02eb281fa6e1b --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/index.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 { PublicMethodsOf } from '@kbn/utility-types'; +import { Logger } from '@kbn/core/server'; +import { ActionsConfigurationUtilities } from '../actions_config'; + +import { ActionTypeRegistry } from '../action_type_registry'; +import { register } from './register'; +import { SubActionConnectorType } from './types'; +import { ActionTypeConfig, ActionTypeSecrets } from '../types'; + +export const createSubActionConnectorFramework = ({ + actionsConfigUtils: configurationUtilities, + actionTypeRegistry, + logger, +}: { + actionTypeRegistry: PublicMethodsOf; + logger: Logger; + actionsConfigUtils: ActionsConfigurationUtilities; +}) => { + return { + registerConnector: ( + connector: SubActionConnectorType + ) => { + register({ actionTypeRegistry, logger, connector, configurationUtilities }); + }, + }; +}; diff --git a/x-pack/plugins/actions/server/sub_action_framework/mocks.ts b/x-pack/plugins/actions/server/sub_action_framework/mocks.ts new file mode 100644 index 0000000000000..274662bb7a35f --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/mocks.ts @@ -0,0 +1,194 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +/* eslint-disable max-classes-per-file */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { AxiosError } from 'axios'; +import { SubActionConnector } from './sub_action_connector'; +import { CaseConnector } from './case'; +import { ExternalServiceIncidentResponse, ServiceParams } from './types'; + +export const TestConfigSchema = schema.object({ url: schema.string() }); +export const TestSecretsSchema = schema.object({ + username: schema.string(), + password: schema.string(), +}); +export type TestConfig = TypeOf; +export type TestSecrets = TypeOf; + +interface ErrorSchema { + errorMessage: string; + errorCode: number; +} + +export class TestSubActionConnector extends SubActionConnector { + constructor(params: ServiceParams) { + super(params); + this.registerSubAction({ + name: 'testUrl', + method: 'testUrl', + schema: schema.object({ url: schema.string() }), + }); + + this.registerSubAction({ + name: 'testData', + method: 'testData', + schema: null, + }); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } + + public async testUrl({ url, data = {} }: { url: string; data?: Record | null }) { + const res = await this.request({ + url, + data, + headers: { 'X-Test-Header': 'test' }, + responseSchema: schema.object({ status: schema.string() }), + }); + + return res; + } + + public async testData({ data }: { data: Record }) { + const res = await this.request({ + url: 'https://example.com', + data: this.removeNullOrUndefinedFields(data), + headers: { 'X-Test-Header': 'test' }, + responseSchema: schema.object({ status: schema.string() }), + }); + + return res; + } +} + +export class TestNoSubActions extends SubActionConnector { + protected getResponseErrorMessage(error: AxiosError) { + return `Error`; + } +} + +export class TestExecutor extends SubActionConnector { + public notAFunction: string = 'notAFunction'; + + constructor(params: ServiceParams) { + super(params); + this.registerSubAction({ + name: 'testUrl', + method: 'not-exist', + schema: schema.object({}), + }); + + this.registerSubAction({ + name: 'notAFunction', + method: 'notAFunction', + schema: schema.object({}), + }); + + this.registerSubAction({ + name: 'echo', + method: 'echo', + schema: schema.object({ id: schema.string() }), + }); + + this.registerSubAction({ + name: 'noSchema', + method: 'noSchema', + schema: null, + }); + + this.registerSubAction({ + name: 'noData', + method: 'noData', + schema: null, + }); + + this.registerSubAction({ + name: 'noAsync', + method: 'noAsync', + schema: null, + }); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Error`; + } + + public async echo({ id }: { id: string }) { + return Promise.resolve({ id }); + } + + public async noSchema({ id }: { id: string }) { + return { id }; + } + + public async noData() {} + + public noAsync() {} +} + +export class TestCaseConnector extends CaseConnector { + constructor(params: ServiceParams) { + super(params); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } + + public async createIncident(incident: { + category: string; + }): Promise { + return { + id: 'create-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async addComment({ + incidentId, + comment, + }: { + incidentId: string; + comment: string; + }): Promise { + return { + id: 'add-comment', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async updateIncident({ + incidentId, + incident, + }: { + incidentId: string; + incident: { category: string }; + }): Promise { + return { + id: 'update-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async getIncident({ id }: { id: string }): Promise { + return { + id: 'get-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } +} diff --git a/x-pack/plugins/actions/server/sub_action_framework/register.test.ts b/x-pack/plugins/actions/server/sub_action_framework/register.test.ts new file mode 100644 index 0000000000000..85d630736a3b1 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/register.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 { loggingSystemMock } from '@kbn/core/server/mocks'; +import { actionsConfigMock } from '../actions_config.mock'; +import { actionTypeRegistryMock } from '../action_type_registry.mock'; +import { + TestSecretsSchema, + TestConfigSchema, + TestConfig, + TestSecrets, + TestSubActionConnector, +} from './mocks'; +import { register } from './register'; + +describe('Registration', () => { + const connector = { + id: '.test', + name: 'Test', + minimumLicenseRequired: 'basic' as const, + schema: { + config: TestConfigSchema, + secrets: TestSecretsSchema, + }, + Service: TestSubActionConnector, + }; + + const actionTypeRegistry = actionTypeRegistryMock.create(); + const mockedActionsConfig = actionsConfigMock.create(); + const logger = loggingSystemMock.createLogger(); + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + it('registers the connector correctly', async () => { + register({ + actionTypeRegistry, + connector, + configurationUtilities: mockedActionsConfig, + logger, + }); + + expect(actionTypeRegistry.register).toHaveBeenCalledTimes(1); + expect(actionTypeRegistry.register).toHaveBeenCalledWith({ + id: connector.id, + name: connector.name, + minimumLicenseRequired: connector.minimumLicenseRequired, + validate: expect.anything(), + executor: expect.anything(), + }); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/register.ts b/x-pack/plugins/actions/server/sub_action_framework/register.ts new file mode 100644 index 0000000000000..ff9cf50e514cd --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/register.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PublicMethodsOf } from '@kbn/utility-types'; +import { Logger } from '@kbn/core/server'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { ActionTypeRegistry } from '../action_type_registry'; +import { SubActionConnector } from './sub_action_connector'; +import { CaseConnector } from './case'; +import { ActionTypeConfig, ActionTypeSecrets } from '../types'; +import { buildExecutor } from './executor'; +import { ExecutorParams, SubActionConnectorType, IService } from './types'; +import { buildValidators } from './validators'; + +const validateService = (Service: IService) => { + if ( + !(Service.prototype instanceof CaseConnector) && + !(Service.prototype instanceof SubActionConnector) + ) { + throw new Error( + 'Service must be extend one of the abstract classes: SubActionConnector or CaseConnector' + ); + } +}; + +export const register = ({ + actionTypeRegistry, + connector, + logger, + configurationUtilities, +}: { + configurationUtilities: ActionsConfigurationUtilities; + actionTypeRegistry: PublicMethodsOf; + connector: SubActionConnectorType; + logger: Logger; +}) => { + validateService(connector.Service); + + const validators = buildValidators({ connector, configurationUtilities }); + const executor = buildExecutor({ + connector, + logger, + configurationUtilities, + }); + + actionTypeRegistry.register({ + id: connector.id, + name: connector.name, + minimumLicenseRequired: connector.minimumLicenseRequired, + validate: validators, + executor, + }); +}; diff --git a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.test.ts b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.test.ts new file mode 100644 index 0000000000000..957d8875547c2 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.test.ts @@ -0,0 +1,343 @@ +/* + * Copyright 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 { Agent as HttpsAgent } from 'https'; +import HttpProxyAgent from 'http-proxy-agent'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { MockedLogger } from '@kbn/logging-mocks'; +import { actionsConfigMock } from '../actions_config.mock'; +import { actionsMock } from '../mocks'; +import { TestSubActionConnector } from './mocks'; +import { getCustomAgents } from '../builtin_action_types/lib/get_custom_agents'; +import { ActionsConfigurationUtilities } from '../actions_config'; + +jest.mock('axios'); +const axiosMock = axios as jest.Mocked; + +const createAxiosError = (): AxiosError => { + const error = new Error() as AxiosError; + error.isAxiosError = true; + error.config = { method: 'get', url: 'https://example.com' }; + error.response = { + data: { errorMessage: 'An error occurred', errorCode: 500 }, + } as AxiosResponse; + + return error; +}; + +describe('SubActionConnector', () => { + const axiosInstanceMock = jest.fn(); + let logger: MockedLogger; + let services: ReturnType; + let mockedActionsConfig: jest.Mocked; + let service: TestSubActionConnector; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + + axiosInstanceMock.mockReturnValue({ data: { status: 'ok' } }); + axiosMock.create.mockImplementation(() => { + return axiosInstanceMock as unknown as AxiosInstance; + }); + + logger = loggingSystemMock.createLogger(); + services = actionsMock.createServices(); + mockedActionsConfig = actionsConfigMock.create(); + + mockedActionsConfig.getResponseSettings.mockReturnValue({ + maxContentLength: 1000000, + timeout: 360000, + }); + + service = new TestSubActionConnector({ + configurationUtilities: mockedActionsConfig, + logger, + connector: { id: 'test-id', type: '.test' }, + config: { url: 'https://example.com' }, + secrets: { username: 'elastic', password: 'changeme' }, + services, + }); + }); + + describe('Sub actions', () => { + it('gets the sub actions correctly', async () => { + const subActions = service.getSubActions(); + expect(subActions.get('testUrl')).toEqual({ + method: 'testUrl', + name: 'testUrl', + schema: expect.anything(), + }); + }); + }); + + describe('URL validation', () => { + it('removes double slashes correctly', async () => { + await service.testUrl({ url: 'https://example.com//api///test-endpoint' }); + expect(axiosInstanceMock.mock.calls[0][0]).toBe('https://example.com/api/test-endpoint'); + }); + + it('removes the ending slash correctly', async () => { + await service.testUrl({ url: 'https://example.com/' }); + expect(axiosInstanceMock.mock.calls[0][0]).toBe('https://example.com'); + }); + + it('throws an error if the url is invalid', async () => { + expect.assertions(1); + await expect(async () => service.testUrl({ url: 'invalid-url' })).rejects.toThrow( + 'URL Error: Invalid URL: invalid-url' + ); + }); + + it('throws an error if the url starts with backslashes', async () => { + expect.assertions(1); + await expect(async () => service.testUrl({ url: '//example.com/foo' })).rejects.toThrow( + 'URL Error: Invalid URL: //example.com/foo' + ); + }); + + it('throws an error if the protocol is not supported', async () => { + expect.assertions(1); + await expect(async () => service.testUrl({ url: 'ftp://example.com' })).rejects.toThrow( + 'URL Error: Invalid protocol' + ); + }); + + it('throws if the host is the URI is not allowed', async () => { + expect.assertions(1); + + mockedActionsConfig.ensureUriAllowed.mockImplementation(() => { + throw new Error('URI is not allowed'); + }); + + await expect(async () => service.testUrl({ url: 'https://example.com' })).rejects.toThrow( + 'error configuring connector action: URI is not allowed' + ); + }); + }); + + describe('Data', () => { + it('sets data to an empty object if the data are null', async () => { + await service.testUrl({ url: 'https://example.com', data: null }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { data } = axiosInstanceMock.mock.calls[0][1]; + expect(data).toEqual({}); + }); + + it('pass data to axios correctly if not null', async () => { + await service.testUrl({ url: 'https://example.com', data: { foo: 'foo' } }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { data } = axiosInstanceMock.mock.calls[0][1]; + expect(data).toEqual({ foo: 'foo' }); + }); + + it('removeNullOrUndefinedFields: removes null values and undefined values correctly', async () => { + await service.testData({ data: { foo: 'foo', bar: null, baz: undefined } }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { data } = axiosInstanceMock.mock.calls[0][1]; + expect(data).toEqual({ foo: 'foo' }); + }); + + it.each([[null], [undefined], [[]], [() => {}], [new Date()]])( + 'removeNullOrUndefinedFields: returns data if it is not an object', + async (dataToTest) => { + // @ts-expect-error + await service.testData({ data: dataToTest }); + + const { data } = axiosInstanceMock.mock.calls[0][1]; + expect(data).toEqual({}); + } + ); + }); + + describe('Fetching', () => { + it('fetch correctly', async () => { + const res = await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + expect(axiosInstanceMock).toBeCalledWith('https://example.com', { + method: 'get', + data: {}, + headers: { + 'Content-Type': 'application/json', + 'X-Test-Header': 'test', + }, + httpAgent: undefined, + httpsAgent: expect.any(HttpsAgent), + proxy: false, + maxContentLength: 1000000, + timeout: 360000, + }); + + expect(logger.debug).toBeCalledWith( + 'Request to external service. Connector Id: test-id. Connector type: .test Method: get. URL: https://example.com' + ); + + expect(res).toEqual({ data: { status: 'ok' } }); + }); + + it('validates the response correctly', async () => { + axiosInstanceMock.mockReturnValue({ data: { invalidField: 'test' } }); + await expect(async () => service.testUrl({ url: 'https://example.com' })).rejects.toThrow( + 'Response validation failed (Error: [status]: expected value of type [string] but got [undefined])' + ); + }); + + it('formats the response error correctly', async () => { + axiosInstanceMock.mockImplementation(() => { + throw createAxiosError(); + }); + + await expect(async () => service.testUrl({ url: 'https://example.com' })).rejects.toThrow( + 'Message: An error occurred. Code: 500' + ); + + expect(logger.debug).toHaveBeenLastCalledWith( + 'Request to external service failed. Connector Id: test-id. Connector type: .test. Method: get. URL: https://example.com' + ); + }); + }); + + describe('Proxy', () => { + it('have been called with proper proxy agent for a valid url', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxySSLSettings: { + verificationMode: 'full', + }, + proxyUrl: 'https://localhost:1212', + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + + const { httpAgent, httpsAgent } = getCustomAgents( + mockedActionsConfig, + logger, + 'https://example.com' + ); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toBeCalledWith('https://example.com', { + method: 'get', + data: {}, + headers: { + 'X-Test-Header': 'test', + 'Content-Type': 'application/json', + }, + httpAgent, + httpsAgent, + proxy: false, + maxContentLength: 1000000, + timeout: 360000, + }); + }); + + it('have been called with proper proxy agent for an invalid url', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxyUrl: ':nope:', + proxySSLSettings: { + verificationMode: 'none', + }, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toBeCalledWith('https://example.com', { + method: 'get', + data: {}, + headers: { + 'X-Test-Header': 'test', + 'Content-Type': 'application/json', + }, + httpAgent: undefined, + httpsAgent: expect.any(HttpsAgent), + proxy: false, + maxContentLength: 1000000, + timeout: 360000, + }); + }); + + it('bypasses with proxyBypassHosts when expected', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxySSLSettings: { + verificationMode: 'full', + }, + proxyUrl: 'https://elastic.proxy.co', + proxyBypassHosts: new Set(['example.com']), + proxyOnlyHosts: undefined, + }); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { httpAgent, httpsAgent } = axiosInstanceMock.mock.calls[0][1]; + expect(httpAgent instanceof HttpProxyAgent).toBe(false); + expect(httpsAgent instanceof HttpsProxyAgent).toBe(false); + }); + + it('does not bypass with proxyBypassHosts when expected', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxySSLSettings: { + verificationMode: 'full', + }, + proxyUrl: 'https://elastic.proxy.co', + proxyBypassHosts: new Set(['not-example.com']), + proxyOnlyHosts: undefined, + }); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { httpAgent, httpsAgent } = axiosInstanceMock.mock.calls[0][1]; + expect(httpAgent instanceof HttpProxyAgent).toBe(true); + expect(httpsAgent instanceof HttpsProxyAgent).toBe(true); + }); + + it('proxies with proxyOnlyHosts when expected', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxySSLSettings: { + verificationMode: 'full', + }, + proxyUrl: 'https://elastic.proxy.co', + proxyBypassHosts: undefined, + proxyOnlyHosts: new Set(['example.com']), + }); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { httpAgent, httpsAgent } = axiosInstanceMock.mock.calls[0][1]; + expect(httpAgent instanceof HttpProxyAgent).toBe(true); + expect(httpsAgent instanceof HttpsProxyAgent).toBe(true); + }); + + it('does not proxy with proxyOnlyHosts when expected', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxySSLSettings: { + verificationMode: 'full', + }, + proxyUrl: 'https://elastic.proxy.co', + proxyBypassHosts: undefined, + proxyOnlyHosts: new Set(['not-example.com']), + }); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { httpAgent, httpsAgent } = axiosInstanceMock.mock.calls[0][1]; + expect(httpAgent instanceof HttpProxyAgent).toBe(false); + expect(httpsAgent instanceof HttpsProxyAgent).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts new file mode 100644 index 0000000000000..4e2be22a6834e --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts @@ -0,0 +1,175 @@ +/* + * Copyright 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 { isPlainObject, isEmpty } from 'lodash'; +import { Type } from '@kbn/config-schema'; +import { Logger } from '@kbn/logging'; +import axios, { + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + Method, + AxiosError, + AxiosRequestHeaders, +} from 'axios'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { getCustomAgents } from '../builtin_action_types/lib/get_custom_agents'; +import { SubAction } from './types'; +import { ServiceParams } from './types'; +import * as i18n from './translations'; + +const isObject = (value: unknown): value is Record => { + return isPlainObject(value); +}; + +const isAxiosError = (error: unknown): error is AxiosError => (error as AxiosError).isAxiosError; + +export abstract class SubActionConnector { + [k: string]: ((params: unknown) => unknown) | unknown; + private axiosInstance: AxiosInstance; + private validProtocols: string[] = ['http:', 'https:']; + private subActions: Map = new Map(); + private configurationUtilities: ActionsConfigurationUtilities; + protected logger: Logger; + protected connector: ServiceParams['connector']; + protected config: Config; + protected secrets: Secrets; + + constructor(params: ServiceParams) { + this.connector = params.connector; + this.logger = params.logger; + this.config = params.config; + this.secrets = params.secrets; + this.configurationUtilities = params.configurationUtilities; + this.axiosInstance = axios.create(); + } + + private normalizeURL(url: string) { + const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + const replaceDoubleSlashesRegex = new RegExp('([^:]/)/+', 'g'); + return urlWithoutTrailingSlash.replace(replaceDoubleSlashesRegex, '$1'); + } + + private normalizeData(data: unknown | undefined | null) { + if (isEmpty(data)) { + return {}; + } + + return data; + } + + private assertURL(url: string) { + try { + const parsedUrl = new URL(url); + + if (!parsedUrl.hostname) { + throw new Error('URL must contain hostname'); + } + + if (!this.validProtocols.includes(parsedUrl.protocol)) { + throw new Error('Invalid protocol'); + } + } catch (error) { + throw new Error(`URL Error: ${error.message}`); + } + } + + private ensureUriAllowed(url: string) { + try { + this.configurationUtilities.ensureUriAllowed(url); + } catch (allowedListError) { + throw new Error(i18n.ALLOWED_HOSTS_ERROR(allowedListError.message)); + } + } + + private getHeaders(headers?: AxiosRequestHeaders) { + return { ...headers, 'Content-Type': 'application/json' }; + } + + private validateResponse(responseSchema: Type, data: unknown) { + try { + responseSchema.validate(data); + } catch (resValidationError) { + throw new Error(`Response validation failed (${resValidationError})`); + } + } + + protected registerSubAction(subAction: SubAction) { + this.subActions.set(subAction.name, subAction); + } + + protected removeNullOrUndefinedFields(data: unknown | undefined) { + if (isObject(data)) { + return Object.fromEntries(Object.entries(data).filter(([_, value]) => value != null)); + } + + return data; + } + + public getSubActions() { + return this.subActions; + } + + protected abstract getResponseErrorMessage(error: AxiosError): string; + + protected async request({ + url, + data, + method = 'get', + responseSchema, + headers, + ...config + }: { + url: string; + responseSchema: Type; + method?: Method; + } & AxiosRequestConfig): Promise> { + try { + this.assertURL(url); + this.ensureUriAllowed(url); + const normalizedURL = this.normalizeURL(url); + + const { httpAgent, httpsAgent } = getCustomAgents( + this.configurationUtilities, + this.logger, + url + ); + const { maxContentLength, timeout } = this.configurationUtilities.getResponseSettings(); + + this.logger.debug( + `Request to external service. Connector Id: ${this.connector.id}. Connector type: ${this.connector.type} Method: ${method}. URL: ${normalizedURL}` + ); + const res = await this.axiosInstance(normalizedURL, { + ...config, + method, + headers: this.getHeaders(headers), + data: this.normalizeData(data), + // use httpAgent and httpsAgent and set axios proxy: false, to be able to handle fail on invalid certs + httpAgent, + httpsAgent, + proxy: false, + maxContentLength, + timeout, + }); + + this.validateResponse(responseSchema, res.data); + + return res; + } catch (error) { + if (isAxiosError(error)) { + this.logger.debug( + `Request to external service failed. Connector Id: ${this.connector.id}. Connector type: ${this.connector.type}. Method: ${error.config.method}. URL: ${error.config.url}` + ); + + const errorMessage = this.getResponseErrorMessage(error); + throw new Error(errorMessage); + } + + throw error; + } + } +} diff --git a/x-pack/plugins/actions/server/sub_action_framework/translations.ts b/x-pack/plugins/actions/server/sub_action_framework/translations.ts new file mode 100644 index 0000000000000..3ffaa230cf23b --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/translations.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 { i18n } from '@kbn/i18n'; + +export const NAME = i18n.translate('xpack.actions.builtin.cases.jiraTitle', { + defaultMessage: 'Jira', +}); + +export const ALLOWED_HOSTS_ERROR = (message: string) => + i18n.translate('xpack.actions.apiAllowedHostsError', { + defaultMessage: 'error configuring connector action: {message}', + values: { + message, + }, + }); diff --git a/x-pack/plugins/actions/server/sub_action_framework/types.ts b/x-pack/plugins/actions/server/sub_action_framework/types.ts new file mode 100644 index 0000000000000..f3080310b1fc0 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/types.ts @@ -0,0 +1,83 @@ +/* + * Copyright 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 { Type } from '@kbn/config-schema'; +import { Logger } from '@kbn/logging'; +import type { LicenseType } from '@kbn/licensing-plugin/common/types'; + +import { ActionsConfigurationUtilities } from '../actions_config'; +import { ActionTypeParams, Services } from '../types'; +import { SubActionConnector } from './sub_action_connector'; + +export interface ServiceParams { + /** + * The type is the connector type id. For example ".servicenow" + * The id is the connector's SavedObject UUID. + */ + connector: { id: string; type: string }; + config: Config; + configurationUtilities: ActionsConfigurationUtilities; + logger: Logger; + secrets: Secrets; + services: Services; +} + +export type IService = new ( + params: ServiceParams +) => SubActionConnector; + +export type IServiceAbstract = abstract new ( + params: ServiceParams +) => SubActionConnector; + +export interface SubActionConnectorType { + id: string; + name: string; + minimumLicenseRequired: LicenseType; + schema: { + config: Type; + secrets: Type; + }; + Service: IService; +} + +export interface ExecutorParams extends ActionTypeParams { + subAction: string; + subActionParams: Record; +} + +export type ExtractFunctionKeys = { + [P in keyof T]-?: T[P] extends Function ? P : never; +}[keyof T]; + +export interface SubAction { + name: string; + method: string; + schema: Type | null; +} + +export interface PushToServiceParams { + externalId: string | null; + comments: Array<{ commentId: string; comment: string }>; + [x: string]: unknown; +} + +export interface ExternalServiceIncidentResponse { + id: string; + title: string; + url: string; + pushedDate: string; +} + +export interface ExternalServiceCommentResponse { + commentId: string; + pushedDate: string; + externalCommentId?: string; +} +export interface PushToServiceResponse extends ExternalServiceIncidentResponse { + comments?: ExternalServiceCommentResponse[]; +} diff --git a/x-pack/plugins/actions/server/sub_action_framework/validators.test.ts b/x-pack/plugins/actions/server/sub_action_framework/validators.test.ts new file mode 100644 index 0000000000000..78c3f042efce6 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/validators.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ActionsConfigurationUtilities } from '../actions_config'; +import { actionsConfigMock } from '../actions_config.mock'; +import { + TestSecretsSchema, + TestConfigSchema, + TestConfig, + TestSecrets, + TestSubActionConnector, +} from './mocks'; +import { IService } from './types'; +import { buildValidators } from './validators'; + +describe('Validators', () => { + let mockedActionsConfig: jest.Mocked; + + const createValidator = (Service: IService) => { + const connector = { + id: '.test', + name: 'Test', + minimumLicenseRequired: 'basic' as const, + schema: { + config: TestConfigSchema, + secrets: TestSecretsSchema, + }, + Service, + }; + + return buildValidators({ configurationUtilities: mockedActionsConfig, connector }); + }; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + + mockedActionsConfig = actionsConfigMock.create(); + }); + + it('should create the config and secrets validators correctly', async () => { + const validator = createValidator(TestSubActionConnector); + const { config, secrets } = validator; + + expect(config).toEqual(TestConfigSchema); + expect(secrets).toEqual(TestSecretsSchema); + }); + + it('should validate the params correctly', async () => { + const validator = createValidator(TestSubActionConnector); + const { params } = validator; + expect(params.validate({ subAction: 'test', subActionParams: {} })); + }); + + it('should allow any field in subActionParams', async () => { + const validator = createValidator(TestSubActionConnector); + const { params } = validator; + expect( + params.validate({ + subAction: 'test', + subActionParams: { + foo: 'foo', + bar: 1, + baz: [{ test: 'hello' }, 1, 'test', false], + isValid: false, + val: null, + }, + }) + ).toEqual({ + subAction: 'test', + subActionParams: { + foo: 'foo', + bar: 1, + baz: [{ test: 'hello' }, 1, 'test', false], + isValid: false, + val: null, + }, + }); + }); + + it.each([ + [undefined], + [null], + [1], + [false], + [{ test: 'hello' }], + [['test']], + [{ test: 'hello' }], + ])('should throw if the subAction is %p', async (subAction) => { + const validator = createValidator(TestSubActionConnector); + const { params } = validator; + expect(() => params.validate({ subAction, subActionParams: {} })).toThrow(); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/validators.ts b/x-pack/plugins/actions/server/sub_action_framework/validators.ts new file mode 100644 index 0000000000000..2c272a7d858d6 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/validators.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { ActionTypeConfig, ActionTypeSecrets } from '../types'; +import { SubActionConnectorType } from './types'; + +export const buildValidators = < + Config extends ActionTypeConfig, + Secrets extends ActionTypeSecrets +>({ + connector, + configurationUtilities, +}: { + configurationUtilities: ActionsConfigurationUtilities; + connector: SubActionConnectorType; +}) => { + return { + config: connector.schema.config, + secrets: connector.schema.secrets, + params: schema.object({ + subAction: schema.string(), + /** + * With this validation we enforce the subActionParams to be an object. + * Each sub action has different parameters and they are validated inside the executor + * (x-pack/plugins/actions/server/sub_action_framework/executor.ts). For that reason, + * we allow all unknowns at this level of validation as they are not known at this + * time of execution. + */ + subActionParams: schema.object({}, { unknowns: 'allow' }), + }), + }; +}; diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index ffdf0c09ad216..d1bf39b575ab5 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -43,6 +43,8 @@ const enabledActionTypes = [ '.slack', '.webhook', '.xmatters', + '.test-sub-action-connector', + '.test-sub-action-connector-without-sub-actions', 'test.authorization', 'test.failing', 'test.index-record', diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/action_types.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/action_types.ts index c83a1c543b5a7..fb7d65990d34f 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/action_types.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/action_types.ts @@ -9,6 +9,10 @@ import { CoreSetup } from '@kbn/core/server'; import { schema, TypeOf } from '@kbn/config-schema'; import { ActionType } from '@kbn/actions-plugin/server'; import { FixtureStartDeps, FixtureSetupDeps } from './plugin'; +import { + getTestSubActionConnector, + getTestSubActionConnectorWithoutSubActions, +} from './sub_action_connector'; export function defineActionTypes( core: CoreSetup, @@ -23,6 +27,7 @@ export function defineActionTypes( return { status: 'ok', actionId: '' }; }, }; + const throwActionType: ActionType = { id: 'test.throw', name: 'Test: Throw', @@ -31,6 +36,7 @@ export function defineActionTypes( throw new Error('this action is intended to fail'); }, }; + const cappedActionType: ActionType = { id: 'test.capped', name: 'Test: Capped', @@ -39,6 +45,7 @@ export function defineActionTypes( return { status: 'ok', actionId: '' }; }, }; + actions.registerType(noopActionType); actions.registerType(throwActionType); actions.registerType(cappedActionType); @@ -49,6 +56,11 @@ export function defineActionTypes( actions.registerType(getNoAttemptsRateLimitedActionType()); actions.registerType(getAuthorizationActionType(core)); actions.registerType(getExcludedActionType()); + + /** Sub action framework */ + + actions.registerSubActionConnectorType(getTestSubActionConnector(actions)); + actions.registerSubActionConnectorType(getTestSubActionConnectorWithoutSubActions(actions)); } function getIndexRecordActionType() { diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts new file mode 100644 index 0000000000000..39e8a704cc978 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// eslint-disable-next-line max-classes-per-file +import { AxiosError } from 'axios'; +import type { ServiceParams } from '@kbn/actions-plugin/server'; +import { PluginSetupContract as ActionsPluginSetup } from '@kbn/actions-plugin/server/plugin'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { SubActionConnectorType } from '@kbn/actions-plugin/server/sub_action_framework/types'; + +const TestConfigSchema = schema.object({ url: schema.string() }); +const TestSecretsSchema = schema.object({ + username: schema.string(), + password: schema.string(), +}); + +type TestConfig = TypeOf; +type TestSecrets = TypeOf; + +interface ErrorSchema { + errorMessage: string; + errorCode: number; +} + +export const getTestSubActionConnector = ( + actions: ActionsPluginSetup +): SubActionConnectorType => { + const SubActionConnector = actions.getSubActionConnectorClass(); + + class TestSubActionConnector extends SubActionConnector { + constructor(params: ServiceParams) { + super(params); + this.registerSubAction({ + name: 'subActionWithParams', + method: 'subActionWithParams', + schema: schema.object({ id: schema.string() }), + }); + + this.registerSubAction({ + name: 'subActionWithoutParams', + method: 'subActionWithoutParams', + schema: null, + }); + + this.registerSubAction({ + name: 'notExist', + method: 'notExist', + schema: schema.object({}), + }); + + this.registerSubAction({ + name: 'notAFunction', + method: 'notAFunction', + schema: schema.object({}), + }); + + this.registerSubAction({ + name: 'noData', + method: 'noData', + schema: null, + }); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } + + public async subActionWithParams({ id }: { id: string }) { + return { id }; + } + + public async subActionWithoutParams() { + return { id: 'test' }; + } + + public async noData() {} + } + return { + id: '.test-sub-action-connector', + name: 'Test: Sub action connector', + minimumLicenseRequired: 'platinum' as const, + schema: { config: TestConfigSchema, secrets: TestSecretsSchema }, + Service: TestSubActionConnector, + }; +}; + +export const getTestSubActionConnectorWithoutSubActions = ( + actions: ActionsPluginSetup +): SubActionConnectorType => { + const SubActionConnector = actions.getSubActionConnectorClass(); + + class TestNoSubActions extends SubActionConnector { + protected getResponseErrorMessage(error: AxiosError) { + return `Error`; + } + } + + return { + id: '.test-sub-action-connector-without-sub-actions', + name: 'Test: Sub action connector', + minimumLicenseRequired: 'platinum' as const, + schema: { config: TestConfigSchema, secrets: TestSecretsSchema }, + Service: TestNoSubActions, + }; +}; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts index 9c1b6a4fd8299..8175445b4f1c0 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts @@ -41,5 +41,10 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./connector_types')); loadTestFile(require.resolve('./update')); + + /** + * Sub action framework + */ + loadTestFile(require.resolve('./sub_action_framework')); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/sub_action_framework/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/sub_action_framework/index.ts new file mode 100644 index 0000000000000..350361d58a395 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/sub_action_framework/index.ts @@ -0,0 +1,318 @@ +/* + * Copyright 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 SuperTest from 'supertest'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getUrlPrefix, ObjectRemover } from '../../../../../common/lib'; + +/** + * The sub action connector is defined here + * x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts + */ +const createSubActionConnector = async ({ + supertest, + config, + secrets, + connectorTypeId = '.test-sub-action-connector', + expectedHttpCode = 200, +}: { + supertest: SuperTest.SuperTest; + config?: Record; + secrets?: Record; + connectorTypeId?: string; + expectedHttpCode?: number; +}) => { + const response = await supertest + .post(`${getUrlPrefix('default')}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My sub connector', + connector_type_id: connectorTypeId, + config: { + url: 'https://example.com', + ...config, + }, + secrets: { + username: 'elastic', + password: 'changeme', + ...secrets, + }, + }) + .expect(expectedHttpCode); + + return response; +}; + +const executeSubAction = async ({ + supertest, + connectorId, + subAction, + subActionParams, + expectedHttpCode = 200, +}: { + supertest: SuperTest.SuperTest; + connectorId: string; + subAction: string; + subActionParams: Record; + expectedHttpCode?: number; +}) => { + const response = await supertest + .post(`${getUrlPrefix('default')}/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction, + subActionParams, + }, + }) + .expect(expectedHttpCode); + + return response; +}; + +// eslint-disable-next-line import/no-default-export +export default function createActionTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('Sub action framework', () => { + const objectRemover = new ObjectRemover(supertest); + after(() => objectRemover.removeAll()); + + describe('Create', () => { + it('creates the sub action connector correctly', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + expect(res.body).to.eql({ + id: res.body.id, + is_preconfigured: false, + is_deprecated: false, + is_missing_secrets: false, + name: 'My sub connector', + connector_type_id: '.test-sub-action-connector', + config: { + url: 'https://example.com', + }, + }); + }); + }); + + describe('Schema validation', () => { + it('passes the config schema to the actions framework and validates correctly', async () => { + const res = await createSubActionConnector({ + supertest, + config: { foo: 'foo' }, + expectedHttpCode: 400, + }); + + expect(res.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'error validating action type config: [foo]: definition for this key is missing', + }); + }); + + it('passes the secrets schema to the actions framework and validates correctly', async () => { + const res = await createSubActionConnector({ + supertest, + secrets: { foo: 'foo' }, + expectedHttpCode: 400, + }); + + expect(res.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [foo]: definition for this key is missing', + }); + }); + }); + + describe('Sub actions', () => { + it('executes a sub action with parameters correctly', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'subActionWithParams', + subActionParams: { id: 'test-id' }, + }); + + expect(execRes.body).to.eql({ + status: 'ok', + data: { id: 'test-id' }, + connector_id: res.body.id, + }); + }); + + it('validates the subParams correctly', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'subActionWithParams', + subActionParams: { foo: 'foo' }, + }); + + expect(execRes.body).to.eql({ + status: 'error', + message: 'an error occurred while running the action', + retry: false, + connector_id: res.body.id, + service_message: + 'Request validation failed (Error: [id]: expected value of type [string] but got [undefined])', + }); + }); + + it('validates correctly if the subActionParams is not an object', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + for (const subActionParams of ['foo', 1, true, null, ['bar']]) { + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'subActionWithParams', + // @ts-expect-error + subActionParams, + }); + + const { message, ...resWithoutMessage } = execRes.body; + expect(resWithoutMessage).to.eql({ + status: 'error', + retry: false, + connector_id: res.body.id, + }); + } + }); + + it('should execute correctly without schema validation', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'subActionWithoutParams', + subActionParams: {}, + }); + + expect(execRes.body).to.eql({ + status: 'ok', + data: { id: 'test' }, + connector_id: res.body.id, + }); + }); + + it('should return an empty object if the func returns undefined', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'noData', + subActionParams: {}, + }); + + expect(execRes.body).to.eql({ + status: 'ok', + data: {}, + connector_id: res.body.id, + }); + }); + + it('should return an error if sub action is not registered', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'notRegistered', + subActionParams: { foo: 'foo' }, + }); + + expect(execRes.body).to.eql({ + status: 'error', + message: 'an error occurred while running the action', + retry: false, + connector_id: res.body.id, + service_message: `Sub action \"notRegistered\" is not registered. Connector id: ${res.body.id}. Connector name: Test: Sub action connector. Connector type: .test-sub-action-connector`, + }); + }); + + it('should return an error if the registered method is not a function', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'notAFunction', + subActionParams: { foo: 'foo' }, + }); + + expect(execRes.body).to.eql({ + status: 'error', + message: 'an error occurred while running the action', + retry: false, + connector_id: res.body.id, + service_message: `Method \"notAFunction\" does not exists in service. Sub action: \"notAFunction\". Connector id: ${res.body.id}. Connector name: Test: Sub action connector. Connector type: .test-sub-action-connector`, + }); + }); + + it('should return an error if the registered method does not exists', async () => { + const res = await createSubActionConnector({ supertest }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'notExist', + subActionParams: { foo: 'foo' }, + }); + + expect(execRes.body).to.eql({ + status: 'error', + message: 'an error occurred while running the action', + retry: false, + connector_id: res.body.id, + service_message: `Method \"notExist\" does not exists in service. Sub action: \"notExist\". Connector id: ${res.body.id}. Connector name: Test: Sub action connector. Connector type: .test-sub-action-connector`, + }); + }); + + it('should return an error if there are no sub actions registered', async () => { + const res = await createSubActionConnector({ + supertest, + connectorTypeId: '.test-sub-action-connector-without-sub-actions', + }); + objectRemover.add('default', res.body.id, 'action', 'actions'); + + const execRes = await executeSubAction({ + supertest, + connectorId: res.body.id as string, + subAction: 'notRegistered', + subActionParams: { foo: 'foo' }, + }); + + expect(execRes.body).to.eql({ + status: 'error', + message: 'an error occurred while running the action', + retry: false, + connector_id: res.body.id, + service_message: 'You should register at least one subAction for your connector type', + }); + }); + }); + }); +} From 4f99212c87d3af1a9e257a27102dda2047b2967c Mon Sep 17 00:00:00 2001 From: Milton Hultgren Date: Thu, 19 May 2022 10:58:14 +0100 Subject: [PATCH 15/37] [Metrics UI] Fix reporting of missing metrics in Infra metrics tables (#132329) * [Metrics UI] Fix null metrics reporting in infra tables (#130642) * Fix sorting after null check was fixed * Center loading spinner in container * Fix lazy evaluation risk Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../container/container_metrics_table.tsx | 6 +- .../container/use_container_metrics_table.ts | 93 ++++++++++---- .../host/host_metrics_table.tsx | 6 +- .../host/use_host_metrics_table.ts | 113 ++++++++++++++---- .../pod/pod_metrics_table.tsx | 6 +- .../pod/use_pod_metrics_table.ts | 93 ++++++++++---- .../hooks/use_infrastructure_node_metrics.ts | 54 ++++++--- 7 files changed, 285 insertions(+), 86 deletions(-) diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx index 02c7d0501cdef..b7ba7e17915e4 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx @@ -68,7 +68,11 @@ export const ContainerMetricsTable = (props: ContainerMetricsTableProps) => { ); if (isLoading) { - return ; + return ( + + + + ); } return ( diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts index 23c95c665aa91..fe570a80b6615 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts @@ -83,33 +83,77 @@ export function useContainerMetricsTable({ function seriesToContainerNodeMetricsRow(series: MetricsExplorerSeries): ContainerNodeMetricsRow { if (series.rows.length === 0) { - return { - name: series.id, - uptime: null, - averageCpuUsagePercent: null, - averageMemoryUsageMegabytes: null, - }; + return rowWithoutMetrics(series.id); } - let uptime: number = 0; - let averageCpuUsagePercent: number = 0; - let averageMemoryUsageMegabytes: number = 0; - series.rows.forEach((row) => { - const metricValues = unpackMetrics(row); - uptime += metricValues.uptime ?? 0; - averageCpuUsagePercent += metricValues.averageCpuUsagePercent ?? 0; - averageMemoryUsageMegabytes += metricValues.averageMemoryUsageMegabytes ?? 0; + return { + name: series.id, + ...calculateMetricAverages(series.rows), + }; +} + +function rowWithoutMetrics(name: string) { + return { + name, + uptime: null, + averageCpuUsagePercent: null, + averageMemoryUsageMegabytes: null, + }; +} + +function calculateMetricAverages(rows: MetricsExplorerRow[]) { + const { uptimeValues, averageCpuUsagePercentValues, averageMemoryUsageMegabytesValues } = + collectMetricValues(rows); + + let uptime = null; + if (uptimeValues.length !== 0) { + uptime = averageOfValues(uptimeValues); + } + + let averageCpuUsagePercent = null; + if (averageCpuUsagePercentValues.length !== 0) { + averageCpuUsagePercent = averageOfValues(averageCpuUsagePercentValues); + } + + let averageMemoryUsageMegabytes = null; + if (averageMemoryUsageMegabytesValues.length !== 0) { + const averageInBytes = averageOfValues(averageMemoryUsageMegabytesValues); + const bytesPerMegabyte = 1000000; + averageMemoryUsageMegabytes = Math.floor(averageInBytes / bytesPerMegabyte); + } + + return { + uptime, + averageCpuUsagePercent, + averageMemoryUsageMegabytes, + }; +} + +function collectMetricValues(rows: MetricsExplorerRow[]) { + const uptimeValues: number[] = []; + const averageCpuUsagePercentValues: number[] = []; + const averageMemoryUsageMegabytesValues: number[] = []; + + rows.forEach((row) => { + const { uptime, averageCpuUsagePercent, averageMemoryUsageMegabytes } = unpackMetrics(row); + + if (uptime !== null) { + uptimeValues.push(uptime); + } + + if (averageCpuUsagePercent !== null) { + averageCpuUsagePercentValues.push(averageCpuUsagePercent); + } + + if (averageMemoryUsageMegabytes !== null) { + averageMemoryUsageMegabytesValues.push(averageMemoryUsageMegabytes); + } }); - const bucketCount = series.rows.length; - const bytesPerMegabyte = 1000000; return { - name: series.id, - uptime: uptime / bucketCount, - averageCpuUsagePercent: averageCpuUsagePercent / bucketCount, - averageMemoryUsageMegabytes: Math.floor( - averageMemoryUsageMegabytes / bucketCount / bytesPerMegabyte - ), + uptimeValues, + averageCpuUsagePercentValues, + averageMemoryUsageMegabytesValues, }; } @@ -124,3 +168,8 @@ function unpackMetrics(row: MetricsExplorerRow): Omit acc + value, 0); + return sum / values.length; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx index d878fc091722b..8df9c973e5a17 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx @@ -68,7 +68,11 @@ export const HostMetricsTable = (props: HostMetricsTableProps) => { ); if (isLoading) { - return ; + return ( + + + + ); } return ( diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts index dddd5ad03c7b0..f82463e97a303 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts @@ -80,35 +80,95 @@ export function useHostMetricsTable({ timerange, filterClauseDsl }: UseNodeMetri function seriesToHostNodeMetricsRow(series: MetricsExplorerSeries): HostNodeMetricsRow { if (series.rows.length === 0) { - return { - name: series.id, - cpuCount: null, - averageCpuUsagePercent: null, - totalMemoryMegabytes: null, - averageMemoryUsagePercent: null, - }; + return rowWithoutMetrics(series.id); } - let cpuCount = 0; - let averageCpuUsagePercent = 0; - let totalMemoryMegabytes = 0; - let averageMemoryUsagePercent = 0; - series.rows.forEach((row) => { - const metricValues = unpackMetrics(row); - cpuCount += metricValues.cpuCount ?? 0; - averageCpuUsagePercent += metricValues.averageCpuUsagePercent ?? 0; - totalMemoryMegabytes += metricValues.totalMemoryMegabytes ?? 0; - averageMemoryUsagePercent += metricValues.averageMemoryUsagePercent ?? 0; + return { + name: series.id, + ...calculateMetricAverages(series.rows), + }; +} + +function rowWithoutMetrics(name: string) { + return { + name, + cpuCount: null, + averageCpuUsagePercent: null, + totalMemoryMegabytes: null, + averageMemoryUsagePercent: null, + }; +} + +function calculateMetricAverages(rows: MetricsExplorerRow[]) { + const { + cpuCountValues, + averageCpuUsagePercentValues, + totalMemoryMegabytesValues, + averageMemoryUsagePercentValues, + } = collectMetricValues(rows); + + let cpuCount = null; + if (cpuCountValues.length !== 0) { + cpuCount = averageOfValues(cpuCountValues); + } + + let averageCpuUsagePercent = null; + if (averageCpuUsagePercentValues.length !== 0) { + averageCpuUsagePercent = averageOfValues(averageCpuUsagePercentValues); + } + + let totalMemoryMegabytes = null; + if (totalMemoryMegabytesValues.length !== 0) { + const averageInBytes = averageOfValues(totalMemoryMegabytesValues); + const bytesPerMegabyte = 1000000; + totalMemoryMegabytes = Math.floor(averageInBytes / bytesPerMegabyte); + } + + let averageMemoryUsagePercent = null; + if (averageMemoryUsagePercentValues.length !== 0) { + averageMemoryUsagePercent = averageOfValues(averageMemoryUsagePercentValues); + } + + return { + cpuCount, + averageCpuUsagePercent, + totalMemoryMegabytes, + averageMemoryUsagePercent, + }; +} + +function collectMetricValues(rows: MetricsExplorerRow[]) { + const cpuCountValues: number[] = []; + const averageCpuUsagePercentValues: number[] = []; + const totalMemoryMegabytesValues: number[] = []; + const averageMemoryUsagePercentValues: number[] = []; + + rows.forEach((row) => { + const { cpuCount, averageCpuUsagePercent, totalMemoryMegabytes, averageMemoryUsagePercent } = + unpackMetrics(row); + + if (cpuCount !== null) { + cpuCountValues.push(cpuCount); + } + + if (averageCpuUsagePercent !== null) { + averageCpuUsagePercentValues.push(averageCpuUsagePercent); + } + + if (totalMemoryMegabytes !== null) { + totalMemoryMegabytesValues.push(totalMemoryMegabytes); + } + + if (averageMemoryUsagePercent !== null) { + averageMemoryUsagePercentValues.push(averageMemoryUsagePercent); + } }); - const bucketCount = series.rows.length; - const bytesPerMegabyte = 1000000; return { - name: series.id, - cpuCount: cpuCount / bucketCount, - averageCpuUsagePercent: averageCpuUsagePercent / bucketCount, - totalMemoryMegabytes: Math.floor(totalMemoryMegabytes / bucketCount / bytesPerMegabyte), - averageMemoryUsagePercent: averageMemoryUsagePercent / bucketCount, + cpuCountValues, + averageCpuUsagePercentValues, + totalMemoryMegabytesValues, + averageMemoryUsagePercentValues, }; } @@ -120,3 +180,8 @@ function unpackMetrics(row: MetricsExplorerRow): Omit acc + value, 0); + return sum / values.length; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx index 3739d6b468292..fa6d4b899f157 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx @@ -66,7 +66,11 @@ export const PodMetricsTable = (props: PodMetricsTableProps) => { }; if (isLoading) { - return ; + return ( + + + + ); } return ( diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts index 004ab2ab3ffff..e070d1ca9100c 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts @@ -80,33 +80,77 @@ export function usePodMetricsTable({ timerange, filterClauseDsl }: UseNodeMetric function seriesToPodNodeMetricsRow(series: MetricsExplorerSeries): PodNodeMetricsRow { if (series.rows.length === 0) { - return { - name: series.id, - uptime: null, - averageCpuUsagePercent: null, - averageMemoryUsageMegabytes: null, - }; + return rowWithoutMetrics(series.id); } - let uptime: number = 0; - let averageCpuUsagePercent: number = 0; - let averageMemoryUsagePercent: number = 0; - series.rows.forEach((row) => { - const metricValues = unpackMetrics(row); - uptime += metricValues.uptime ?? 0; - averageCpuUsagePercent += metricValues.averageCpuUsagePercent ?? 0; - averageMemoryUsagePercent += metricValues.averageMemoryUsageMegabytes ?? 0; + return { + name: series.id, + ...calculateMetricAverages(series.rows), + }; +} + +function rowWithoutMetrics(name: string) { + return { + name, + uptime: null, + averageCpuUsagePercent: null, + averageMemoryUsageMegabytes: null, + }; +} + +function calculateMetricAverages(rows: MetricsExplorerRow[]) { + const { uptimeValues, averageCpuUsagePercentValues, averageMemoryUsageMegabytesValues } = + collectMetricValues(rows); + + let uptime = null; + if (uptimeValues.length !== 0) { + uptime = averageOfValues(uptimeValues); + } + + let averageCpuUsagePercent = null; + if (averageCpuUsagePercentValues.length !== 0) { + averageCpuUsagePercent = averageOfValues(averageCpuUsagePercentValues); + } + + let averageMemoryUsageMegabytes = null; + if (averageMemoryUsageMegabytesValues.length !== 0) { + const averageInBytes = averageOfValues(averageMemoryUsageMegabytesValues); + const bytesPerMegabyte = 1000000; + averageMemoryUsageMegabytes = Math.floor(averageInBytes / bytesPerMegabyte); + } + + return { + uptime, + averageCpuUsagePercent, + averageMemoryUsageMegabytes, + }; +} + +function collectMetricValues(rows: MetricsExplorerRow[]) { + const uptimeValues: number[] = []; + const averageCpuUsagePercentValues: number[] = []; + const averageMemoryUsageMegabytesValues: number[] = []; + + rows.forEach((row) => { + const { uptime, averageCpuUsagePercent, averageMemoryUsageMegabytes } = unpackMetrics(row); + + if (uptime !== null) { + uptimeValues.push(uptime); + } + + if (averageCpuUsagePercent !== null) { + averageCpuUsagePercentValues.push(averageCpuUsagePercent); + } + + if (averageMemoryUsageMegabytes !== null) { + averageMemoryUsageMegabytesValues.push(averageMemoryUsageMegabytes); + } }); - const bucketCount = series.rows.length; - const bytesPerMegabyte = 1000000; return { - name: series.id, - uptime: uptime / bucketCount, - averageCpuUsagePercent: averageCpuUsagePercent / bucketCount, - averageMemoryUsageMegabytes: Math.floor( - averageMemoryUsagePercent / bucketCount / bytesPerMegabyte - ), + uptimeValues, + averageCpuUsagePercentValues, + averageMemoryUsageMegabytesValues, }; } @@ -121,3 +165,8 @@ function unpackMetrics(row: MetricsExplorerRow): Omit | null, }; } + +function averageOfValues(values: number[]) { + const sum = values.reduce((acc, value) => acc + value, 0); + return sum / values.length; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts index e165ee4d6ac48..374685a374f24 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts @@ -153,22 +153,46 @@ function makeSortNodes(sortState: SortState) { const nodeAValue = nodeA[sortState.field]; const nodeBValue = nodeB[sortState.field]; - if (typeof nodeAValue === 'string' && typeof nodeBValue === 'string') { - if (sortState.direction === 'asc') { - return nodeAValue.localeCompare(nodeBValue); - } else { - return nodeBValue.localeCompare(nodeAValue); - } + if (sortState.direction === 'asc') { + return sortAscending(nodeAValue, nodeBValue); } - if (typeof nodeAValue === 'number' && typeof nodeBValue === 'number') { - if (sortState.direction === 'asc') { - return nodeAValue - nodeBValue; - } else { - return nodeBValue - nodeAValue; - } - } - - return 0; + return sortDescending(nodeAValue, nodeBValue); }; } + +function sortAscending(nodeAValue: unknown, nodeBValue: unknown) { + if (nodeAValue === null) { + return -1; + } else if (nodeBValue === null) { + return 1; + } + + if (typeof nodeAValue === 'string' && typeof nodeBValue === 'string') { + return nodeAValue.localeCompare(nodeBValue); + } + + if (typeof nodeAValue === 'number' && typeof nodeBValue === 'number') { + return nodeAValue - nodeBValue; + } + + return 0; +} + +function sortDescending(nodeAValue: unknown, nodeBValue: unknown) { + if (nodeAValue === null) { + return 1; + } else if (nodeBValue === null) { + return -1; + } + + if (typeof nodeAValue === 'string' && typeof nodeBValue === 'string') { + return nodeBValue.localeCompare(nodeAValue); + } + + if (typeof nodeAValue === 'number' && typeof nodeBValue === 'number') { + return nodeBValue - nodeAValue; + } + + return 0; +} From ae0c68346a064361f73cc366115e4cfe2352fa11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Thu, 19 May 2022 12:03:10 +0200 Subject: [PATCH 16/37] Bump @storybook@6.4.22 (#129787) --- package.json | 52 +- packages/kbn-pm/dist/index.js | 156 +- packages/kbn-storybook/src/index.ts | 9 +- .../kbn-storybook/src/lib/default_config.ts | 6 +- packages/kbn-storybook/templates/index.ejs | 10 +- renovate.json | 8 + .../public/__stories__/shared/arg_types.ts | 6 +- .../replacement_card.component.tsx | 1 + .../discover/.storybook/discover.webpack.ts | 4 +- .../waterfall/accordion_waterfall.tsx | 12 +- .../analyze_data_button.stories.tsx | 8 +- .../context/breadcrumbs/use_breadcrumb.ts | 16 +- .../simple_template.stories.storyshot | 92 +- .../simple_template.stories.storyshot | 132 +- .../simple_template.stories.storyshot | 250 ++- .../canvas/storybook/canvas_webpack.ts | 3 +- .../fleet/.storybook/context/index.tsx | 4 +- .../test_utils/use_global_storybook_theme.tsx | 12 +- .../pages/overview/overview.stories.tsx | 6 +- .../event_details/table/field_value_cell.tsx | 5 +- yarn.lock | 1443 ++++++++--------- 21 files changed, 1078 insertions(+), 1157 deletions(-) diff --git a/package.json b/package.json index 2d3009b7b7099..7e4e2ea78175a 100644 --- a/package.json +++ b/package.json @@ -219,7 +219,7 @@ "@types/jsonwebtoken": "^8.5.6", "@types/mapbox__vector-tile": "1.3.0", "@types/moment-duration-format": "^2.2.3", - "@types/react-is": "^16.7.1", + "@types/react-is": "^16.7.2", "@types/rrule": "^2.2.9", "JSONStream": "1.3.5", "abort-controller": "^3.0.0", @@ -358,16 +358,16 @@ "rbush": "^3.0.1", "re-resizable": "^6.1.1", "re2": "1.17.4", - "react": "^16.12.0", + "react": "^16.14.0", "react-ace": "^7.0.5", "react-beautiful-dnd": "^13.1.0", "react-color": "^2.13.8", - "react-dom": "^16.12.0", + "react-dom": "^16.14.0", "react-dropzone": "^4.2.9", "react-fast-compare": "^2.0.4", "react-grid-layout": "^0.16.2", "react-intl": "^2.8.0", - "react-is": "^16.8.0", + "react-is": "^16.13.1", "react-markdown": "^4.3.1", "react-moment-proptypes": "^1.7.0", "react-monaco-editor": "^0.41.2", @@ -527,25 +527,26 @@ "@microsoft/api-extractor": "7.18.19", "@octokit/rest": "^16.35.0", "@percy/agent": "^0.28.6", - "@storybook/addon-a11y": "^6.3.12", - "@storybook/addon-actions": "^6.3.12", - "@storybook/addon-docs": "^6.3.12", - "@storybook/addon-essentials": "^6.3.12", - "@storybook/addon-knobs": "^6.3.1", - "@storybook/addon-storyshots": "^6.3.12", - "@storybook/addons": "^6.3.12", - "@storybook/api": "^6.3.12", - "@storybook/components": "^6.3.12", - "@storybook/core": "^6.3.12", - "@storybook/core-common": "^6.3.12", - "@storybook/core-events": "^6.3.12", - "@storybook/node-logger": "^6.3.12", - "@storybook/react": "^6.3.12", - "@storybook/testing-react": "^0.0.22", - "@storybook/theming": "^6.3.12", + "@storybook/addon-a11y": "^6.4.22", + "@storybook/addon-actions": "^6.4.22", + "@storybook/addon-controls": "^6.4.22", + "@storybook/addon-docs": "^6.4.22", + "@storybook/addon-essentials": "^6.4.22", + "@storybook/addon-knobs": "^6.4.0", + "@storybook/addon-storyshots": "^6.4.22", + "@storybook/addons": "^6.4.22", + "@storybook/api": "^6.4.22", + "@storybook/components": "^6.4.22", + "@storybook/core": "^6.4.22", + "@storybook/core-common": "^6.4.22", + "@storybook/core-events": "^6.4.22", + "@storybook/node-logger": "^6.4.22", + "@storybook/react": "^6.4.22", + "@storybook/testing-react": "^1.2.4", + "@storybook/theming": "^6.4.22", "@testing-library/dom": "^8.12.0", "@testing-library/jest-dom": "^5.16.3", - "@testing-library/react": "^12.1.4", + "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^13.5.0", "@types/apidoc": "^0.22.3", @@ -707,6 +708,7 @@ "@types/lz-string": "^1.3.34", "@types/markdown-it": "^12.2.3", "@types/md5": "^2.2.0", + "@types/micromatch": "^4.0.2", "@types/mime": "^2.0.1", "@types/mime-types": "^2.1.0", "@types/minimatch": "^2.0.29", @@ -734,10 +736,9 @@ "@types/pretty-ms": "^5.0.0", "@types/prop-types": "^15.7.3", "@types/rbush": "^3.0.0", - "@types/reach__router": "^1.2.6", - "@types/react": "^16.9.36", + "@types/react": "^16.14.25", "@types/react-beautiful-dnd": "^13.0.0", - "@types/react-dom": "^16.9.8", + "@types/react-dom": "^16.9.15", "@types/react-grid-layout": "^0.16.7", "@types/react-intl": "^2.3.15", "@types/react-redux": "^7.1.9", @@ -818,6 +819,7 @@ "cpy": "^8.1.1", "css-loader": "^3.4.2", "cssnano": "^4.1.11", + "csstype": "^3.0.2", "cypress": "^9.6.1", "cypress-axe": "^0.14.0", "cypress-file-upload": "^5.0.8", @@ -924,7 +926,7 @@ "prettier": "^2.6.2", "pretty-format": "^27.5.1", "q": "^1.5.1", - "react-test-renderer": "^16.12.0", + "react-test-renderer": "^16.14.0", "read-pkg": "^5.2.0", "regenerate": "^1.4.0", "resolve": "^1.22.0", diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 5045611c2ac2c..5699df6aa3666 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -45125,82 +45125,6 @@ exports.wrapOutput = (input, state = {}, options = {}) => { }; -/***/ }), - -/***/ "../../node_modules/pify/index.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -const processFn = (fn, options) => function (...args) { - const P = options.promiseModule; - - return new P((resolve, reject) => { - if (options.multiArgs) { - args.push((...result) => { - if (options.errorFirst) { - if (result[0]) { - reject(result); - } else { - result.shift(); - resolve(result); - } - } else { - resolve(result); - } - }); - } else if (options.errorFirst) { - args.push((error, result) => { - if (error) { - reject(error); - } else { - resolve(result); - } - }); - } else { - args.push(resolve); - } - - fn.apply(this, args); - }); -}; - -module.exports = (input, options) => { - options = Object.assign({ - exclude: [/.+(Sync|Stream)$/], - errorFirst: true, - promiseModule: Promise - }, options); - - const objType = typeof input; - if (!(input !== null && (objType === 'object' || objType === 'function'))) { - throw new TypeError(`Expected \`input\` to be a \`Function\` or \`Object\`, got \`${input === null ? 'null' : objType}\``); - } - - const filter = key => { - const match = pattern => typeof pattern === 'string' ? key === pattern : pattern.test(key); - return options.include ? options.include.some(match) : !options.exclude.some(match); - }; - - let ret; - if (objType === 'function') { - ret = function (...args) { - return options.excludeMain ? input(...args) : processFn(input, options).apply(this, args); - }; - } else { - ret = Object.create(Object.getPrototypeOf(input)); - } - - for (const key in input) { // eslint-disable-line guard-for-in - const property = input[key]; - ret[key] = typeof property === 'function' && filter(key) ? processFn(property, options) : property; - } - - return ret; -}; - - /***/ }), /***/ "../../node_modules/pump/index.js": @@ -59599,7 +59523,7 @@ const fs = __webpack_require__("../../node_modules/graceful-fs/graceful-fs.js"); const writeFileAtomic = __webpack_require__("../../node_modules/write-json-file/node_modules/write-file-atomic/index.js"); const sortKeys = __webpack_require__("../../node_modules/sort-keys/index.js"); const makeDir = __webpack_require__("../../node_modules/write-json-file/node_modules/make-dir/index.js"); -const pify = __webpack_require__("../../node_modules/pify/index.js"); +const pify = __webpack_require__("../../node_modules/write-json-file/node_modules/pify/index.js"); const detectIndent = __webpack_require__("../../node_modules/write-json-file/node_modules/detect-indent/index.js"); const init = (fn, filePath, data, options) => { @@ -59810,7 +59734,7 @@ module.exports = str => { const fs = __webpack_require__("fs"); const path = __webpack_require__("path"); -const pify = __webpack_require__("../../node_modules/pify/index.js"); +const pify = __webpack_require__("../../node_modules/write-json-file/node_modules/pify/index.js"); const semver = __webpack_require__("../../node_modules/write-json-file/node_modules/semver/semver.js"); const defaults = { @@ -59948,6 +59872,82 @@ module.exports.sync = (input, options) => { }; +/***/ }), + +/***/ "../../node_modules/write-json-file/node_modules/pify/index.js": +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +const processFn = (fn, options) => function (...args) { + const P = options.promiseModule; + + return new P((resolve, reject) => { + if (options.multiArgs) { + args.push((...result) => { + if (options.errorFirst) { + if (result[0]) { + reject(result); + } else { + result.shift(); + resolve(result); + } + } else { + resolve(result); + } + }); + } else if (options.errorFirst) { + args.push((error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + } else { + args.push(resolve); + } + + fn.apply(this, args); + }); +}; + +module.exports = (input, options) => { + options = Object.assign({ + exclude: [/.+(Sync|Stream)$/], + errorFirst: true, + promiseModule: Promise + }, options); + + const objType = typeof input; + if (!(input !== null && (objType === 'object' || objType === 'function'))) { + throw new TypeError(`Expected \`input\` to be a \`Function\` or \`Object\`, got \`${input === null ? 'null' : objType}\``); + } + + const filter = key => { + const match = pattern => typeof pattern === 'string' ? key === pattern : pattern.test(key); + return options.include ? options.include.some(match) : !options.exclude.some(match); + }; + + let ret; + if (objType === 'function') { + ret = function (...args) { + return options.excludeMain ? input(...args) : processFn(input, options).apply(this, args); + }; + } else { + ret = Object.create(Object.getPrototypeOf(input)); + } + + for (const key in input) { // eslint-disable-line guard-for-in + const property = input[key]; + ret[key] = typeof property === 'function' && filter(key) ? processFn(property, options) : property; + } + + return ret; +}; + + /***/ }), /***/ "../../node_modules/write-json-file/node_modules/semver/semver.js": diff --git a/packages/kbn-storybook/src/index.ts b/packages/kbn-storybook/src/index.ts index b3258be91ed82..f986e35d1b4ed 100644 --- a/packages/kbn-storybook/src/index.ts +++ b/packages/kbn-storybook/src/index.ts @@ -6,6 +6,13 @@ * Side Public License, v 1. */ -export { defaultConfig, defaultConfigWebFinal, mergeWebpackFinal } from './lib/default_config'; +import { + defaultConfig, + defaultConfigWebFinal, + mergeWebpackFinal, + StorybookConfig, +} from './lib/default_config'; +export { defaultConfig, defaultConfigWebFinal, mergeWebpackFinal }; +export type { StorybookConfig }; export { runStorybookCli } from './lib/run_storybook_cli'; export { default as WebpackConfig } from './webpack.config'; diff --git a/packages/kbn-storybook/src/lib/default_config.ts b/packages/kbn-storybook/src/lib/default_config.ts index 0f0b8070ff8b0..a2712d3d6f24e 100644 --- a/packages/kbn-storybook/src/lib/default_config.ts +++ b/packages/kbn-storybook/src/lib/default_config.ts @@ -7,12 +7,14 @@ */ import * as path from 'path'; -import { StorybookConfig } from '@storybook/core-common'; +import type { StorybookConfig } from '@storybook/core-common'; import { Configuration } from 'webpack'; import webpackMerge from 'webpack-merge'; import { REPO_ROOT } from './constants'; import { default as WebpackConfig } from '../webpack.config'; +export type { StorybookConfig }; + const toPath = (_path: string) => path.join(REPO_ROOT, _path); // This ignore pattern excludes all of node_modules EXCEPT for `@kbn`. This allows for @@ -81,7 +83,7 @@ export const defaultConfig: StorybookConfig = { // an issue with storybook typescript setup see this issue for more details // https://github.com/storybookjs/storybook/issues/9610 -export const defaultConfigWebFinal = { +export const defaultConfigWebFinal: StorybookConfig = { ...defaultConfig, webpackFinal: (config: Configuration) => { return WebpackConfig({ config }); diff --git a/packages/kbn-storybook/templates/index.ejs b/packages/kbn-storybook/templates/index.ejs index 53dc0f5e55750..73367d44cd393 100644 --- a/packages/kbn-storybook/templates/index.ejs +++ b/packages/kbn-storybook/templates/index.ejs @@ -6,10 +6,10 @@ - <%= options.title || 'Storybook'%> + <%= htmlWebpackPlugin.options.title || 'Storybook'%> - <% if (files.favicon) { %> - + <% if (htmlWebpackPlugin.files.favicon) { %> + <% } %> @@ -26,7 +26,7 @@ <% if (typeof headHtmlSnippet !== 'undefined') { %> <%= headHtmlSnippet %> <% } %> <% - files.css.forEach(file => { %> + htmlWebpackPlugin.files.css.forEach(file => { %> <% }); %> @@ -58,7 +58,7 @@ <% } %> - <% files.js.forEach(file => { %> + <% htmlWebpackPlugin.files.js.forEach(file => { %> <% }); %> diff --git a/renovate.json b/renovate.json index 4b9418311ced7..3d24e88d638b0 100644 --- a/renovate.json +++ b/renovate.json @@ -157,6 +157,14 @@ "matchBaseBranches": ["main"], "labels": ["Team:Operations", "release_note:skip"], "enabled": true + }, + { + "groupName": "@storybook", + "reviewers": ["team:kibana-operations"], + "matchBaseBranches": ["main"], + "matchPackagePatterns": ["^@storybook"], + "labels": ["Team:Operations", "release_note:skip"], + "enabled": true } ] } diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/arg_types.ts b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/arg_types.ts index 1a18c905548d4..7b1b83429ef7b 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/arg_types.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/arg_types.ts @@ -54,14 +54,14 @@ export const argTypes: ArgTypes = { palette: { name: `${visConfigName}.palette`, description: 'Palette', - type: { name: 'palette', required: false }, + type: { name: 'other', required: true, value: 'string' }, table: { type: { summary: 'object' } }, control: { type: 'object' }, }, labels: { name: `${visConfigName}.labels`, description: 'Labels configuration', - type: { name: 'object', required: false }, + type: { name: 'other', required: false, value: 'string' }, table: { type: { summary: 'object', @@ -81,7 +81,7 @@ export const argTypes: ArgTypes = { dimensions: { name: `${visConfigName}.dimensions`, description: 'dimensions configuration', - type: { name: 'object', required: false }, + type: { name: 'other', required: false, value: 'string' }, table: { type: { summary: 'object', diff --git a/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.component.tsx b/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.component.tsx index 9b5e1248d1938..8115872749c3e 100644 --- a/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.component.tsx +++ b/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.component.tsx @@ -5,6 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +/** @jsxRuntime classic */ /** @jsx jsx */ import { css, jsx } from '@emotion/react'; diff --git a/src/plugins/discover/.storybook/discover.webpack.ts b/src/plugins/discover/.storybook/discover.webpack.ts index 7b978a4e7110e..c548162f7730c 100644 --- a/src/plugins/discover/.storybook/discover.webpack.ts +++ b/src/plugins/discover/.storybook/discover.webpack.ts @@ -5,9 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { defaultConfig } from '@kbn/storybook'; +import { defaultConfig, StorybookConfig } from '@kbn/storybook'; -export const discoverStorybookConfig = { +export const discoverStorybookConfig: StorybookConfig = { ...defaultConfig, stories: ['../**/*.stories.tsx'], addons: [...(defaultConfig.addons || []), './addon/target/register'], diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/accordion_waterfall.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/accordion_waterfall.tsx index 695ebfd9a8976..804a27481422e 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/accordion_waterfall.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/accordion_waterfall.tsx @@ -13,7 +13,7 @@ import { EuiIcon, EuiText, } from '@elastic/eui'; -import React, { Dispatch, SetStateAction, useState } from 'react'; +import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; import { Margins } from '../../../../../shared/charts/timeline'; import { @@ -76,8 +76,6 @@ const StyledAccordion = euiStyled(EuiAccordion).withConfig({ `; export function AccordionWaterfall(props: AccordionWaterfallProps) { - const [isOpen, setIsOpen] = useState(props.isOpen); - const { item, level, @@ -89,8 +87,12 @@ export function AccordionWaterfall(props: AccordionWaterfallProps) { onClickWaterfallItem, } = props; - const nextLevel = level + 1; - setMaxLevel(nextLevel); + const [isOpen, setIsOpen] = useState(props.isOpen); + const [nextLevel] = useState(level + 1); + + useEffect(() => { + setMaxLevel(nextLevel); + }, [nextLevel, setMaxLevel]); const children = waterfall.childrenByParentId[item.id] || []; const errorCount = waterfall.getErrorCount(item.id); diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/analyze_data_button.stories.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/analyze_data_button.stories.tsx index 9245302539efb..2708c46b52960 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/analyze_data_button.stories.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/analyze_data_button.stories.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { Story, StoryContext } from '@storybook/react'; -import React, { ComponentType } from 'react'; +import type { Story, DecoratorFn } from '@storybook/react'; +import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { CoreStart } from '@kbn/core/public'; import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; @@ -26,7 +26,7 @@ export default { title: 'routing/templates/ApmServiceTemplate/AnalyzeDataButton', component: AnalyzeDataButton, decorators: [ - (StoryComponent: ComponentType, { args }: StoryContext) => { + (StoryComponent, { args }) => { const { agentName, canShowDashboard, environment, serviceName } = args; const KibanaContext = createKibanaReactContext({ @@ -61,7 +61,7 @@ export default { ); }, - ], + ] as DecoratorFn[], }; export const Example: Story = () => { diff --git a/x-pack/plugins/apm/public/context/breadcrumbs/use_breadcrumb.ts b/x-pack/plugins/apm/public/context/breadcrumbs/use_breadcrumb.ts index dfc33c0f10ffc..980c7986d098a 100644 --- a/x-pack/plugins/apm/public/context/breadcrumbs/use_breadcrumb.ts +++ b/x-pack/plugins/apm/public/context/breadcrumbs/use_breadcrumb.ts @@ -21,17 +21,17 @@ export function useBreadcrumb(breadcrumb: Breadcrumb | Breadcrumb[]) { const matchedRoute = useRef(match?.route); - if (matchedRoute.current && matchedRoute.current !== match?.route) { - api.unset(matchedRoute.current); - } + useEffect(() => { + if (matchedRoute.current && matchedRoute.current !== match?.route) { + api.unset(matchedRoute.current); + } - matchedRoute.current = match?.route; + matchedRoute.current = match?.route; - if (matchedRoute.current) { - api.set(matchedRoute.current, castArray(breadcrumb)); - } + if (matchedRoute.current) { + api.set(matchedRoute.current, castArray(breadcrumb)); + } - useEffect(() => { return () => { if (matchedRoute.current) { api.unset(matchedRoute.current); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/simple_template.stories.storyshot b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/simple_template.stories.storyshot index 118f300ccab09..0b9358714e71c 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/simple_template.stories.storyshot +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/simple_template.stories.storyshot @@ -11,38 +11,28 @@ exports[`Storyshots arguments/AxisConfig simple 1`] = ` } >
-
- -
+ className="euiSwitch__thumb" + /> + + +
`; @@ -58,38 +48,28 @@ exports[`Storyshots arguments/AxisConfig/components simple template 1`] = ` } >
-
- -
+ className="euiSwitch__thumb" + /> + + +
`; diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__stories__/__snapshots__/simple_template.stories.storyshot b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__stories__/__snapshots__/simple_template.stories.storyshot index af099aefbc0e5..10a5c634da162 100644 --- a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__stories__/__snapshots__/simple_template.stories.storyshot +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__stories__/__snapshots__/simple_template.stories.storyshot @@ -13,55 +13,45 @@ exports[`Storyshots arguments/ContainerStyle simple 1`] = `
-
- -
+ } + /> +
+
@@ -81,55 +71,45 @@ exports[`Storyshots arguments/ContainerStyle/components simple template 1`] = `
-
- -
+ } + /> +
+
diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__stories__/__snapshots__/simple_template.stories.storyshot b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__stories__/__snapshots__/simple_template.stories.storyshot index f5298c1d1a908..f444266239314 100644 --- a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__stories__/__snapshots__/simple_template.stories.storyshot +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__stories__/__snapshots__/simple_template.stories.storyshot @@ -11,42 +11,32 @@ exports[`Storyshots arguments/SeriesStyle simple 1`] = ` } >
-
- Color -
+ Color
+
+
-
- -
+ Auto +
@@ -64,42 +54,32 @@ exports[`Storyshots arguments/SeriesStyle/components simple: defaults 1`] = ` } >
-
- Color -
+ Color
+
+
-
- -
+ Auto +
@@ -117,42 +97,32 @@ exports[`Storyshots arguments/SeriesStyle/components simple: no labels 1`] = ` } >
-
- Color -
+ Color
+
+
-
- -
+ Auto +
@@ -170,62 +140,52 @@ exports[`Storyshots arguments/SeriesStyle/components simple: no series 1`] = ` } >
-
- Color -
+ Color
+
+
-
- -
+ Auto +
-
+
+ - - Info - + Info -
+
@@ -242,42 +202,32 @@ exports[`Storyshots arguments/SeriesStyle/components simple: with series 1`] = ` } >
-
- Color -
+ Color
+
+
-
- -
+ Auto +
diff --git a/x-pack/plugins/canvas/storybook/canvas_webpack.ts b/x-pack/plugins/canvas/storybook/canvas_webpack.ts index db59af20440e2..e8ce5ff03b812 100644 --- a/x-pack/plugins/canvas/storybook/canvas_webpack.ts +++ b/x-pack/plugins/canvas/storybook/canvas_webpack.ts @@ -7,6 +7,7 @@ import { resolve } from 'path'; import { defaultConfig, mergeWebpackFinal } from '@kbn/storybook'; +import type { StorybookConfig } from '@kbn/storybook'; import { KIBANA_ROOT } from './constants'; export const canvasWebpack = { @@ -61,7 +62,7 @@ export const canvasWebpack = { }, }; -export const canvasStorybookConfig = { +export const canvasStorybookConfig: StorybookConfig = { ...defaultConfig, addons: [...(defaultConfig.addons || []), './addon/target/register'], ...mergeWebpackFinal(canvasWebpack), diff --git a/x-pack/plugins/fleet/.storybook/context/index.tsx b/x-pack/plugins/fleet/.storybook/context/index.tsx index 15ee77506cc0e..2877f265f8c1c 100644 --- a/x-pack/plugins/fleet/.storybook/context/index.tsx +++ b/x-pack/plugins/fleet/.storybook/context/index.tsx @@ -8,7 +8,7 @@ import React, { useEffect, useMemo, useCallback } from 'react'; import { EMPTY } from 'rxjs'; -import type { StoryContext } from '@storybook/react'; +import type { DecoratorFn } from '@storybook/react'; import { createBrowserHistory } from 'history'; import { I18nProvider } from '@kbn/i18n-react'; @@ -40,7 +40,7 @@ import { getExecutionContext } from './execution_context'; // mock later, (or, ideally, Fleet starts to use a service abstraction). // // Expect this to grow as components that are given Stories need access to mocked services. -export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({ +export const StorybookContext: React.FC<{ storyContext?: Parameters[1] }> = ({ storyContext, children: storyChildren, }) => { diff --git a/x-pack/plugins/infra/public/test_utils/use_global_storybook_theme.tsx b/x-pack/plugins/infra/public/test_utils/use_global_storybook_theme.tsx index 7d32cb6360fdf..4d1feb4617dcf 100644 --- a/x-pack/plugins/infra/public/test_utils/use_global_storybook_theme.tsx +++ b/x-pack/plugins/infra/public/test_utils/use_global_storybook_theme.tsx @@ -5,13 +5,15 @@ * 2.0. */ -import type { StoryContext } from '@storybook/addons'; +import type { DecoratorFn } from '@storybook/react'; import React, { useEffect, useMemo, useState } from 'react'; import { BehaviorSubject } from 'rxjs'; import type { CoreTheme } from '@kbn/core/public'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +type StoryContext = Parameters[1]; + export const useGlobalStorybookTheme = ({ globals: { euiTheme } }: StoryContext) => { const theme = useMemo(() => euiThemeFromId(euiTheme), [euiTheme]); const [theme$] = useState(() => new BehaviorSubject(theme)); @@ -38,11 +40,9 @@ export const GlobalStorybookThemeProviders: React.FC<{ storyContext: StoryContex ); }; -export const decorateWithGlobalStorybookThemeProviders = < - StoryFnReactReturnType extends React.ReactNode ->( - wrappedStory: () => StoryFnReactReturnType, - storyContext: StoryContext +export const decorateWithGlobalStorybookThemeProviders: DecoratorFn = ( + wrappedStory, + storyContext ) => ( {wrappedStory()} diff --git a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx index 95d263168f82e..097d0d0845dca 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -8,7 +8,7 @@ import { makeDecorator } from '@storybook/addons'; import { storiesOf } from '@storybook/react'; import { AppMountParameters, CoreStart } from '@kbn/core/public'; -import React from 'react'; +import React, { ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { UI_SETTINGS } from '@kbn/data-plugin/public'; import { createKibanaReactContext, KibanaPageTemplate } from '@kbn/kibana-react-plugin/public'; @@ -37,7 +37,7 @@ const sampleAPMIndices = { transaction: 'apm-*' } as ApmIndicesConfig; const withCore = makeDecorator({ name: 'withCore', parameterName: 'core', - wrapper: (storyFn, context, { options: { theme, ...options } }) => { + wrapper: (storyFn, context) => { unregisterAll(); const KibanaReactContext = createKibanaReactContext({ application: { @@ -93,7 +93,7 @@ const withCore = makeDecorator({ kibanaFeatures: [], }} > - {storyFn(context)} + {storyFn(context) as ReactNode} diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx index 2be7b4071f15a..8c9bc4830b6d8 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx @@ -5,9 +5,8 @@ * 2.0. */ -import React from 'react'; +import React, { CSSProperties } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import { CSSObject } from 'styled-components'; import { BrowserField } from '../../../containers/source'; import { OverflowField } from '../../tables/helpers'; import { FormattedFieldValue } from '../../../../timelines/components/timeline/body/renderers/formatted_field'; @@ -22,7 +21,7 @@ export interface FieldValueCellProps { getLinkValue?: (field: string) => string | null; isDraggable?: boolean; linkValue?: string | null | undefined; - style?: CSSObject | undefined; + style?: CSSProperties | undefined; values: string[] | null | undefined; } diff --git a/yarn.lock b/yarn.lock index 30f73d40cd149..ec5afced2df22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -63,13 +63,6 @@ "@nicolo-ribaudo/chokidar-2" "2.1.8-no-fsevents.3" chokidar "^3.4.0" -"@babel/code-frame@7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" - integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== - dependencies: - "@babel/highlight" "^7.10.4" - "@babel/code-frame@7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" @@ -4048,16 +4041,19 @@ which "^2.0.1" winston "^3.0.0" -"@pmmmwh/react-refresh-webpack-plugin@^0.4.3": - version "0.4.3" - resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.4.3.tgz#1eec460596d200c0236bf195b078a5d1df89b766" - integrity sha512-br5Qwvh8D2OQqSXpd1g/xqXKnK0r+Jz6qVKBbWmpUcrbGOxUrf39V5oZ1876084CGn18uMdR5uvPqBv9UqtBjQ== +"@pmmmwh/react-refresh-webpack-plugin@^0.5.1": + version "0.5.5" + resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.5.tgz#e77aac783bd079f548daa0a7f080ab5b5a9741ca" + integrity sha512-RbG7h6TuP6nFFYKJwbcToA1rjC1FyPg25NR2noAZ0vKI+la01KTSRPkuVPE+U88jXv7javx2JHglUcL1MHcshQ== dependencies: - ansi-html "^0.0.7" + ansi-html-community "^0.0.8" + common-path-prefix "^3.0.0" + core-js-pure "^3.8.1" error-stack-parser "^2.0.6" - html-entities "^1.2.1" - native-url "^0.2.6" - schema-utils "^2.6.5" + find-up "^5.0.0" + html-entities "^2.1.0" + loader-utils "^2.0.0" + schema-utils "^3.0.0" source-map "^0.7.3" "@polka/url@^1.0.0-next.20": @@ -4130,16 +4126,6 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA= -"@reach/router@^1.3.4": - version "1.3.4" - resolved "https://registry.yarnpkg.com/@reach/router/-/router-1.3.4.tgz#d2574b19370a70c80480ed91f3da840136d10f8c" - integrity sha512-+mtn9wjlB9NN2CNnnC/BRYtwdKBfSyyasPYraNAyvaV1occr/5NnB4CVzjEZipNHwYebQwcndGUmpFzxAUoqSA== - dependencies: - create-react-context "0.3.0" - invariant "^2.2.3" - prop-types "^15.6.1" - react-lifecycles-compat "^3.0.4" - "@redux-saga/core@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@redux-saga/core/-/core-1.1.3.tgz#3085097b57a4ea8db5528d58673f20ce0950f6a4" @@ -4311,62 +4297,64 @@ "@types/node" ">=8.9.0" axios "^0.21.1" -"@storybook/addon-a11y@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-a11y/-/addon-a11y-6.3.12.tgz#2f930fc84fc275a4ed43a716fc09cc12caf4e110" - integrity sha512-q1NdRHFJV6sLEEJw0hatCc5ZIthELqM/AWdrEWDyhcJNyiq7Tq4nKqQBMTQSYwHiUAmxVgw7i4oa1vM2M51/3g== - dependencies: - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/channels" "6.3.12" - "@storybook/client-api" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/theming" "6.3.12" +"@storybook/addon-a11y@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-a11y/-/addon-a11y-6.4.22.tgz#df75f1a82c83973c165984e8b0944ceed64c30e9" + integrity sha512-y125LDx5VR6JmiHB6/0RHWudwhe9QcFXqoAqGqWIj4zRv0kb9AyDPDtWvtDOSImCDXIPRmd8P05xTOnYH0ET3w== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/channels" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/theming" "6.4.22" axe-core "^4.2.0" core-js "^3.8.2" global "^4.4.0" - lodash "^4.17.20" + lodash "^4.17.21" react-sizeme "^3.0.1" regenerator-runtime "^0.13.7" ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/addon-actions@6.3.12", "@storybook/addon-actions@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-6.3.12.tgz#69eb5f8f780f1b00456051da6290d4b959ba24a0" - integrity sha512-mzuN4Ano4eyicwycM2PueGzzUCAEzt9/6vyptWEIVJu0sjK0J9KtBRlqFi1xGQxmCfimDR/n/vWBBkc7fp2uJA== - dependencies: - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/client-api" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/theming" "6.3.12" +"@storybook/addon-actions@6.4.22", "@storybook/addon-actions@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-6.4.22.tgz#ec1b4332e76a8021dc0a1375dfd71a0760457588" + integrity sha512-t2w3iLXFul+R/1ekYxIEzUOZZmvEa7EzUAVAuCHP4i6x0jBnTTZ7sAIUVRaxVREPguH5IqI/2OklYhKanty2Yw== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/theming" "6.4.22" core-js "^3.8.2" fast-deep-equal "^3.1.3" global "^4.4.0" - lodash "^4.17.20" + lodash "^4.17.21" polished "^4.0.5" prop-types "^15.7.2" react-inspector "^5.1.0" regenerator-runtime "^0.13.7" + telejson "^5.3.2" ts-dedent "^2.0.0" util-deprecate "^1.0.2" uuid-browser "^3.1.0" -"@storybook/addon-backgrounds@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-backgrounds/-/addon-backgrounds-6.3.12.tgz#5feecd461f48178aa976ba2694418e9ea1d621b3" - integrity sha512-51cHBx0HV7K/oRofJ/1pE05qti6sciIo8m4iPred1OezXIrJ/ckzP+gApdaUdzgcLAr6/MXQWLk0sJuImClQ6w== - dependencies: - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/theming" "6.3.12" +"@storybook/addon-backgrounds@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-backgrounds/-/addon-backgrounds-6.4.22.tgz#5d9dbff051eefc1ca6e6c7973c01d17fbef4c2f5" + integrity sha512-xQIV1SsjjRXP7P5tUoGKv+pul1EY8lsV7iBXQb5eGbp4AffBj3qoYBSZbX4uiazl21o0MQiQoeIhhaPVaFIIGg== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/theming" "6.4.22" core-js "^3.8.2" global "^4.4.0" memoizerific "^1.11.3" @@ -4374,24 +4362,28 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/addon-controls@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-controls/-/addon-controls-6.3.12.tgz#dbb732c62cf06fb7ccaf87d6ab11c876d14456fc" - integrity sha512-WO/PbygE4sDg3BbstJ49q0uM3Xu5Nw4lnHR5N4hXSvRAulZt1d1nhphRTHjfX+CW+uBcfzkq9bksm6nKuwmOyw== - dependencies: - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/client-api" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/node-logger" "6.3.12" - "@storybook/theming" "6.3.12" +"@storybook/addon-controls@6.4.22", "@storybook/addon-controls@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-controls/-/addon-controls-6.4.22.tgz#42c7f426eb7ba6d335e8e14369d6d13401878665" + integrity sha512-f/M/W+7UTEUnr/L6scBMvksq+ZA8GTfh3bomE5FtWyOyaFppq9k8daKAvdYNlzXAOrUUsoZVJDgpb20Z2VBiSQ== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-common" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/node-logger" "6.4.22" + "@storybook/store" "6.4.22" + "@storybook/theming" "6.4.22" core-js "^3.8.2" + lodash "^4.17.21" ts-dedent "^2.0.0" -"@storybook/addon-docs@6.3.12", "@storybook/addon-docs@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-6.3.12.tgz#2ec73b4f231d9f190d5c89295bc47bea6a95c6d1" - integrity sha512-iUrqJBMTOn2PgN8AWNQkfxfIPkh8pEg27t8UndMgfOpeGK/VWGw2UEifnA82flvntcilT4McxmVbRHkeBY9K5A== +"@storybook/addon-docs@6.4.22", "@storybook/addon-docs@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-6.4.22.tgz#19f22ede8ae31291069af7ab5abbc23fa269012b" + integrity sha512-9j+i+W+BGHJuRe4jUrqk6ubCzP4fc1xgFS2o8pakRiZgPn5kUQPdkticmsyh1XeEJifwhqjKJvkEDrcsleytDA== dependencies: "@babel/core" "^7.12.10" "@babel/generator" "^7.12.11" @@ -4402,20 +4394,21 @@ "@mdx-js/loader" "^1.6.22" "@mdx-js/mdx" "^1.6.22" "@mdx-js/react" "^1.6.22" - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/builder-webpack4" "6.3.12" - "@storybook/client-api" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/core" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/csf" "0.0.1" - "@storybook/csf-tools" "6.3.12" - "@storybook/node-logger" "6.3.12" - "@storybook/postinstall" "6.3.12" - "@storybook/source-loader" "6.3.12" - "@storybook/theming" "6.3.12" + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/builder-webpack4" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/csf-tools" "6.4.22" + "@storybook/node-logger" "6.4.22" + "@storybook/postinstall" "6.4.22" + "@storybook/preview-web" "6.4.22" + "@storybook/source-loader" "6.4.22" + "@storybook/store" "6.4.22" + "@storybook/theming" "6.4.22" acorn "^7.4.1" acorn-jsx "^5.3.1" acorn-walk "^7.2.0" @@ -4427,41 +4420,42 @@ html-tags "^3.1.0" js-string-escape "^1.0.1" loader-utils "^2.0.0" - lodash "^4.17.20" + lodash "^4.17.21" + nanoid "^3.1.23" p-limit "^3.1.0" - prettier "~2.2.1" + prettier ">=2.2.1 <=2.3.0" prop-types "^15.7.2" - react-element-to-jsx-string "^14.3.2" + react-element-to-jsx-string "^14.3.4" regenerator-runtime "^0.13.7" remark-external-links "^8.0.0" remark-slug "^6.0.0" ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/addon-essentials@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-essentials/-/addon-essentials-6.3.12.tgz#445cc4bc2eb9168a9e5de1fdfb5ef3b92974e74b" - integrity sha512-PK0pPE0xkq00kcbBcFwu/5JGHQTu4GvLIHfwwlEGx6GWNQ05l6Q+1Z4nE7xJGv2PSseSx3CKcjn8qykNLe6O6g== - dependencies: - "@storybook/addon-actions" "6.3.12" - "@storybook/addon-backgrounds" "6.3.12" - "@storybook/addon-controls" "6.3.12" - "@storybook/addon-docs" "6.3.12" - "@storybook/addon-measure" "^2.0.0" - "@storybook/addon-toolbars" "6.3.12" - "@storybook/addon-viewport" "6.3.12" - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/node-logger" "6.3.12" +"@storybook/addon-essentials@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-essentials/-/addon-essentials-6.4.22.tgz#6981c89e8b315cda7ce93b9bf74e98ca80aec00a" + integrity sha512-GTv291fqvWq2wzm7MruBvCGuWaCUiuf7Ca3kzbQ/WqWtve7Y/1PDsqRNQLGZrQxkXU0clXCqY1XtkTrtA3WGFQ== + dependencies: + "@storybook/addon-actions" "6.4.22" + "@storybook/addon-backgrounds" "6.4.22" + "@storybook/addon-controls" "6.4.22" + "@storybook/addon-docs" "6.4.22" + "@storybook/addon-measure" "6.4.22" + "@storybook/addon-outline" "6.4.22" + "@storybook/addon-toolbars" "6.4.22" + "@storybook/addon-viewport" "6.4.22" + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/node-logger" "6.4.22" core-js "^3.8.2" regenerator-runtime "^0.13.7" - storybook-addon-outline "^1.4.1" ts-dedent "^2.0.0" -"@storybook/addon-knobs@^6.3.1": - version "6.3.1" - resolved "https://registry.yarnpkg.com/@storybook/addon-knobs/-/addon-knobs-6.3.1.tgz#2115c6f0d5759e4fe73d5f25710f4a94ebd6f0db" - integrity sha512-2GGGnQSPXXUhHHYv4IW6pkyQlCPYXKYiyGzfhV7Zhs95M2Ban08OA6KLmliMptWCt7U9tqTO8dB5u0C2cWmCTw== +"@storybook/addon-knobs@^6.4.0": + version "6.4.0" + resolved "https://registry.yarnpkg.com/@storybook/addon-knobs/-/addon-knobs-6.4.0.tgz#fa5943ef21826cdc2e20ded74edfdf5a6dc71dcf" + integrity sha512-DiH1/5e2AFHoHrncl1qLu18ZHPHzRMMPvOLFz8AWvvmc+VCqTdIaE+tdxKr3e8rYylKllibgvDOzrLjfTNjF+Q== dependencies: copy-to-clipboard "^3.3.1" core-js "^3.8.2" @@ -4475,25 +4469,52 @@ react-lifecycles-compat "^3.0.4" react-select "^3.2.0" -"@storybook/addon-measure@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@storybook/addon-measure/-/addon-measure-2.0.0.tgz#c40bbe91bacd3f795963dc1ee6ff86be87deeda9" - integrity sha512-ZhdT++cX+L9LwjhGYggvYUUVQH/MGn2rwbrAwCMzA/f2QTFvkjxzX8nDgMxIhaLCDC+gHIxfJG2wrWN0jkBr3g== +"@storybook/addon-measure@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-measure/-/addon-measure-6.4.22.tgz#5e2daac4184a4870b6b38ff71536109b7811a12a" + integrity sha512-CjDXoCNIXxNfXfgyJXPc0McjCcwN1scVNtHa9Ckr+zMjiQ8pPHY7wDZCQsG69KTqcWHiVfxKilI82456bcHYhQ== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + core-js "^3.8.2" + global "^4.4.0" + +"@storybook/addon-outline@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-outline/-/addon-outline-6.4.22.tgz#7a2776344785f7deab83338fbefbefd5e6cfc8cf" + integrity sha512-VIMEzvBBRbNnupGU7NV0ahpFFb6nKVRGYWGREjtABdFn2fdKr1YicOHFe/3U7hRGjb5gd+VazSvyUvhaKX9T7Q== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + core-js "^3.8.2" + global "^4.4.0" + regenerator-runtime "^0.13.7" + ts-dedent "^2.0.0" -"@storybook/addon-storyshots@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-storyshots/-/addon-storyshots-6.3.12.tgz#542bba23a6ad65a4a0b77427169f177e24f5c5f1" - integrity sha512-plpy/q3pPpXtK9DyofE0trTeCZIyU0Z+baybbxltsM/tKFuQxbHSxTwgluq/7LOMkaRPgbddGyHForHoRLjsWg== +"@storybook/addon-storyshots@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-storyshots/-/addon-storyshots-6.4.22.tgz#a2e4053eb36394667dfeabfe0de4d0e91cc4ad40" + integrity sha512-9u+uigHH4khxHB18z1TOau+RKpLo/8tdhvKVqgjy6pr3FSsgp+JyoI+ubDtgWAWFHQ0Zhh5MBWNDmPOo5pwBdA== dependencies: "@jest/transform" "^26.6.2" - "@storybook/addons" "6.3.12" - "@storybook/client-api" "6.3.12" - "@storybook/core" "6.3.12" - "@storybook/core-common" "6.3.12" + "@storybook/addons" "6.4.22" + "@storybook/babel-plugin-require-context-hook" "1.0.1" + "@storybook/client-api" "6.4.22" + "@storybook/core" "6.4.22" + "@storybook/core-client" "6.4.22" + "@storybook/core-common" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" "@types/glob" "^7.1.3" "@types/jest" "^26.0.16" "@types/jest-specific-snapshot" "^0.5.3" - babel-plugin-require-context-hook "^1.0.0" core-js "^3.8.2" glob "^7.1.6" global "^4.4.0" @@ -4505,81 +4526,84 @@ regenerator-runtime "^0.13.7" ts-dedent "^2.0.0" -"@storybook/addon-toolbars@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-toolbars/-/addon-toolbars-6.3.12.tgz#bc0d420b3476c891c42f7b0ab3b457e9e5ef7ca5" - integrity sha512-8GvP6zmAfLPRnYRARSaIwLkQClLIRbflRh4HZoFk6IMjQLXZb4NL3JS5OLFKG+HRMMU2UQzfoSDqjI7k7ptyRw== +"@storybook/addon-toolbars@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-toolbars/-/addon-toolbars-6.4.22.tgz#858a4e5939987c188c96ed374ebeea88bdd9e8de" + integrity sha512-FFyj6XDYpBBjcUu6Eyng7R805LUbVclEfydZjNiByAoDVyCde9Hb4sngFxn/T4fKAfBz/32HKVXd5iq4AHYtLg== dependencies: - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/client-api" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/theming" "6.3.12" + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/theming" "6.4.22" core-js "^3.8.2" regenerator-runtime "^0.13.7" -"@storybook/addon-viewport@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addon-viewport/-/addon-viewport-6.3.12.tgz#2fd61e60644fb07185a662f75b3e9dad8ad14f01" - integrity sha512-TRjyfm85xouOPmXxeLdEIzXLfJZZ1ePQ7p/5yphDGBHdxMU4m4qiZr8wYpUaxHsRu/UB3dKfaOyGT+ivogbnbw== - dependencies: - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/theming" "6.3.12" +"@storybook/addon-viewport@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addon-viewport/-/addon-viewport-6.4.22.tgz#381a2fc4764fe0851889994a5ba36c3121300c11" + integrity sha512-6jk0z49LemeTblez5u2bYXYr6U+xIdLbywe3G283+PZCBbEDE6eNYy2d2HDL+LbCLbezJBLYPHPalElphjJIcw== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/theming" "6.4.22" core-js "^3.8.2" global "^4.4.0" memoizerific "^1.11.3" prop-types "^15.7.2" regenerator-runtime "^0.13.7" -"@storybook/addons@6.3.12", "@storybook/addons@^6.3.0", "@storybook/addons@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-6.3.12.tgz#8773dcc113c5086dfff722388b7b65580e43b65b" - integrity sha512-UgoMyr7Qr0FS3ezt8u6hMEcHgyynQS9ucr5mAwZky3wpXRPFyUTmMto9r4BBUdqyUvTUj/LRKIcmLBfj+/l0Fg== - dependencies: - "@storybook/api" "6.3.12" - "@storybook/channels" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/router" "6.3.12" - "@storybook/theming" "6.3.12" +"@storybook/addons@6.4.22", "@storybook/addons@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-6.4.22.tgz#e165407ca132c2182de2d466b7ff7c5644b6ad7b" + integrity sha512-P/R+Jsxh7pawKLYo8MtE3QU/ilRFKbtCewV/T1o5U/gm8v7hKQdFz3YdRMAra4QuCY8bQIp7MKd2HrB5aH5a1A== + dependencies: + "@storybook/api" "6.4.22" + "@storybook/channels" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/router" "6.4.22" + "@storybook/theming" "6.4.22" + "@types/webpack-env" "^1.16.0" core-js "^3.8.2" global "^4.4.0" regenerator-runtime "^0.13.7" -"@storybook/api@6.3.12", "@storybook/api@^6.3.0", "@storybook/api@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/api/-/api-6.3.12.tgz#2845c20464d5348d676d09665e8ab527825ed7b5" - integrity sha512-LScRXUeCWEW/OP+jiooNMQICVdusv7azTmULxtm72fhkXFRiQs2CdRNTiqNg46JLLC9z95f1W+pGK66X6HiiQA== - dependencies: - "@reach/router" "^1.3.4" - "@storybook/channels" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/csf" "0.0.1" - "@storybook/router" "6.3.12" +"@storybook/api@6.4.22", "@storybook/api@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/api/-/api-6.4.22.tgz#d63f7ad3ffdd74af01ae35099bff4c39702cf793" + integrity sha512-lAVI3o2hKupYHXFTt+1nqFct942up5dHH6YD7SZZJGyW21dwKC3HK1IzCsTawq3fZAKkgWFgmOO649hKk60yKg== + dependencies: + "@storybook/channels" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/router" "6.4.22" "@storybook/semver" "^7.3.2" - "@storybook/theming" "6.3.12" - "@types/reach__router" "^1.3.7" + "@storybook/theming" "6.4.22" core-js "^3.8.2" fast-deep-equal "^3.1.3" global "^4.4.0" - lodash "^4.17.20" + lodash "^4.17.21" memoizerific "^1.11.3" - qs "^6.10.0" regenerator-runtime "^0.13.7" store2 "^2.12.0" telejson "^5.3.2" ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/builder-webpack4@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/builder-webpack4/-/builder-webpack4-6.3.12.tgz#288d541e2801892721c975259476022da695dbfe" - integrity sha512-Dlm5Fc1svqpFDnVPZdAaEBiM/IDZHMV3RfEGbUTY/ZC0q8b/Ug1czzp/w0aTIjOFRuBDcG6IcplikaqHL8CJLg== +"@storybook/babel-plugin-require-context-hook@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@storybook/babel-plugin-require-context-hook/-/babel-plugin-require-context-hook-1.0.1.tgz#0a4ec9816f6c7296ebc97dd8de3d2b7ae76f2e26" + integrity sha512-WM4vjgSVi8epvGiYfru7BtC3f0tGwNs7QK3Uc4xQn4t5hHQvISnCqbNrHdDYmNW56Do+bBztE8SwP6NGUvd7ww== + +"@storybook/builder-webpack4@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/builder-webpack4/-/builder-webpack4-6.4.22.tgz#d3384b146e97a2b3a6357c6eb8279ff0f1c7f8f5" + integrity sha512-A+GgGtKGnBneRFSFkDarUIgUTI8pYFdLmUVKEAGdh2hL+vLXAz9A46sEY7C8LQ85XWa8TKy3OTDxqR4+4iWj3A== dependencies: "@babel/core" "^7.12.10" "@babel/plugin-proposal-class-properties" "^7.12.1" @@ -4602,34 +4626,34 @@ "@babel/preset-env" "^7.12.11" "@babel/preset-react" "^7.12.10" "@babel/preset-typescript" "^7.12.7" - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/channel-postmessage" "6.3.12" - "@storybook/channels" "6.3.12" - "@storybook/client-api" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/core-common" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/node-logger" "6.3.12" - "@storybook/router" "6.3.12" + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/channel-postmessage" "6.4.22" + "@storybook/channels" "6.4.22" + "@storybook/client-api" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-common" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/node-logger" "6.4.22" + "@storybook/preview-web" "6.4.22" + "@storybook/router" "6.4.22" "@storybook/semver" "^7.3.2" - "@storybook/theming" "6.3.12" - "@storybook/ui" "6.3.12" + "@storybook/store" "6.4.22" + "@storybook/theming" "6.4.22" + "@storybook/ui" "6.4.22" "@types/node" "^14.0.10" "@types/webpack" "^4.41.26" autoprefixer "^9.8.6" - babel-loader "^8.2.2" + babel-loader "^8.0.0" babel-plugin-macros "^2.8.0" babel-plugin-polyfill-corejs3 "^0.1.0" case-sensitive-paths-webpack-plugin "^2.3.0" core-js "^3.8.2" css-loader "^3.6.0" - dotenv-webpack "^1.8.0" file-loader "^6.2.0" find-up "^5.0.0" fork-ts-checker-webpack-plugin "^4.1.6" - fs-extra "^9.0.1" glob "^7.1.6" glob-promise "^3.4.0" global "^4.4.0" @@ -4639,7 +4663,6 @@ postcss-flexbugs-fixes "^4.2.1" postcss-loader "^4.2.0" raw-loader "^4.0.2" - react-dev-utils "^11.0.3" stable "^0.1.8" style-loader "^1.3.0" terser-webpack-plugin "^4.2.3" @@ -4649,72 +4672,85 @@ webpack "4" webpack-dev-middleware "^3.7.3" webpack-filter-warnings-plugin "^1.2.1" - webpack-hot-middleware "^2.25.0" + webpack-hot-middleware "^2.25.1" webpack-virtual-modules "^0.2.2" -"@storybook/channel-postmessage@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/channel-postmessage/-/channel-postmessage-6.3.12.tgz#3ff9412ac0f445e3b8b44dd414e783a5a47ff7c1" - integrity sha512-Ou/2Ga3JRTZ/4sSv7ikMgUgLTeZMsXXWLXuscz4oaYhmOqAU9CrJw0G1NitwBgK/+qC83lEFSLujHkWcoQDOKg== +"@storybook/channel-postmessage@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/channel-postmessage/-/channel-postmessage-6.4.22.tgz#8be0be1ea1e667a49fb0f09cdfdeeb4a45829637" + integrity sha512-gt+0VZLszt2XZyQMh8E94TqjHZ8ZFXZ+Lv/Mmzl0Yogsc2H+6VzTTQO4sv0IIx6xLbpgG72g5cr8VHsxW5kuDQ== dependencies: - "@storybook/channels" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/core-events" "6.3.12" + "@storybook/channels" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/core-events" "6.4.22" core-js "^3.8.2" global "^4.4.0" qs "^6.10.0" telejson "^5.3.2" -"@storybook/channels@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-6.3.12.tgz#aa0d793895a8b211f0ad3459c61c1bcafd0093c7" - integrity sha512-l4sA+g1PdUV8YCbgs47fIKREdEQAKNdQIZw0b7BfTvY9t0x5yfBywgQhYON/lIeiNGz2OlIuD+VUtqYfCtNSyw== +"@storybook/channel-websocket@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/channel-websocket/-/channel-websocket-6.4.22.tgz#d541f69125873123c453757e2b879a75a9266c65" + integrity sha512-Bm/FcZ4Su4SAK5DmhyKKfHkr7HiHBui6PNutmFkASJInrL9wBduBfN8YQYaV7ztr8ezoHqnYRx8sj28jpwa6NA== + dependencies: + "@storybook/channels" "6.4.22" + "@storybook/client-logger" "6.4.22" + core-js "^3.8.2" + global "^4.4.0" + telejson "^5.3.2" + +"@storybook/channels@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-6.4.22.tgz#710f732763d63f063f615898ab1afbe74e309596" + integrity sha512-cfR74tu7MLah1A8Rru5sak71I+kH2e/sY6gkpVmlvBj4hEmdZp4Puj9PTeaKcMXh9DgIDPNA5mb8yvQH6VcyxQ== dependencies: core-js "^3.8.2" ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/client-api@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/client-api/-/client-api-6.3.12.tgz#a0c6d72a871d1cb02b4b98675472839061e39b5b" - integrity sha512-xnW+lKKK2T774z+rOr9Wopt1aYTStfb86PSs9p3Fpnc2Btcftln+C3NtiHZl8Ccqft8Mz/chLGgewRui6tNI8g== - dependencies: - "@storybook/addons" "6.3.12" - "@storybook/channel-postmessage" "6.3.12" - "@storybook/channels" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/csf" "0.0.1" +"@storybook/client-api@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/client-api/-/client-api-6.4.22.tgz#df14f85e7900b94354c26c584bab53a67c47eae9" + integrity sha512-sO6HJNtrrdit7dNXQcZMdlmmZG1k6TswH3gAyP/DoYajycrTwSJ6ovkarzkO+0QcJ+etgra4TEdTIXiGHBMe/A== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/channel-postmessage" "6.4.22" + "@storybook/channels" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/store" "6.4.22" "@types/qs" "^6.9.5" "@types/webpack-env" "^1.16.0" core-js "^3.8.2" + fast-deep-equal "^3.1.3" global "^4.4.0" - lodash "^4.17.20" + lodash "^4.17.21" memoizerific "^1.11.3" qs "^6.10.0" regenerator-runtime "^0.13.7" - stable "^0.1.8" store2 "^2.12.0" + synchronous-promise "^2.0.15" ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/client-logger@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-6.3.12.tgz#6585c98923b49fcb25dbceeeb96ef2a83e28e0f4" - integrity sha512-zNDsamZvHnuqLznDdP9dUeGgQ9TyFh4ray3t1VGO7ZqWVZ2xtVCCXjDvMnOXI2ifMpX5UsrOvshIPeE9fMBmiQ== +"@storybook/client-logger@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-6.4.22.tgz#51abedb7d3c9bc21921aeb153ac8a19abc625cd6" + integrity sha512-LXhxh/lcDsdGnK8kimqfhu3C0+D2ylCSPPQNbU0IsLRmTfbpQYMdyl0XBjPdHiRVwlL7Gkw5OMjYemQgJ02zlw== dependencies: core-js "^3.8.2" global "^4.4.0" -"@storybook/components@6.3.12", "@storybook/components@^6.3.0", "@storybook/components@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/components/-/components-6.3.12.tgz#0c7967c60354c84afa20dfab4753105e49b1927d" - integrity sha512-kdQt8toUjynYAxDLrJzuG7YSNL6as1wJoyzNUaCfG06YPhvIAlKo7le9tS2mThVFN5e9nbKrW3N1V1sp6ypZXQ== +"@storybook/components@6.4.22", "@storybook/components@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/components/-/components-6.4.22.tgz#4d425280240702883225b6a1f1abde7dc1a0e945" + integrity sha512-dCbXIJF9orMvH72VtAfCQsYbe57OP7fAADtR6YTwfCw9Sm1jFuZr8JbblQ1HcrXEoJG21nOyad3Hm5EYVb/sBw== dependencies: "@popperjs/core" "^2.6.0" - "@storybook/client-logger" "6.3.12" - "@storybook/csf" "0.0.1" - "@storybook/theming" "6.3.12" + "@storybook/client-logger" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/theming" "6.4.22" "@types/color-convert" "^2.0.0" "@types/overlayscrollbars" "^1.12.0" "@types/react-syntax-highlighter" "11.0.5" @@ -4722,7 +4758,7 @@ core-js "^3.8.2" fast-deep-equal "^3.1.3" global "^4.4.0" - lodash "^4.17.20" + lodash "^4.17.21" markdown-to-jsx "^7.1.3" memoizerific "^1.11.3" overlayscrollbars "^1.13.1" @@ -4736,33 +4772,36 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/core-client@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/core-client/-/core-client-6.3.12.tgz#fd01bfbc69c331f4451973a4e7597624dc3737e5" - integrity sha512-8Smd9BgZHJpAdevLKQYinwtjSyCZAuBMoetP4P5hnn53mWl0NFbrHFaAdT+yNchDLZQUbf7Y18VmIqEH+RCR5w== - dependencies: - "@storybook/addons" "6.3.12" - "@storybook/channel-postmessage" "6.3.12" - "@storybook/client-api" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/csf" "0.0.1" - "@storybook/ui" "6.3.12" +"@storybook/core-client@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/core-client/-/core-client-6.4.22.tgz#9079eda8a9c8e6ba24b84962a749b1c99668cb2a" + integrity sha512-uHg4yfCBeM6eASSVxStWRVTZrAnb4FT6X6v/xDqr4uXCpCttZLlBzrSDwPBLNNLtCa7ntRicHM8eGKIOD5lMYQ== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/channel-postmessage" "6.4.22" + "@storybook/channel-websocket" "6.4.22" + "@storybook/client-api" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/preview-web" "6.4.22" + "@storybook/store" "6.4.22" + "@storybook/ui" "6.4.22" airbnb-js-shims "^2.2.1" ansi-to-html "^0.6.11" core-js "^3.8.2" global "^4.4.0" - lodash "^4.17.20" + lodash "^4.17.21" qs "^6.10.0" regenerator-runtime "^0.13.7" ts-dedent "^2.0.0" unfetch "^4.2.0" util-deprecate "^1.0.2" -"@storybook/core-common@6.3.12", "@storybook/core-common@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/core-common/-/core-common-6.3.12.tgz#95ce953d7efda44394b159322d6a2280c202f21c" - integrity sha512-xlHs2QXELq/moB4MuXjYOczaxU64BIseHsnFBLyboJYN6Yso3qihW5RB7cuJlGohkjb4JwY74dvfT4Ww66rkBA== +"@storybook/core-common@6.4.22", "@storybook/core-common@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/core-common/-/core-common-6.4.22.tgz#b00fa3c0625e074222a50be3196cb8052dd7f3bf" + integrity sha512-PD3N/FJXPNRHeQS2zdgzYFtqPLdi3MLwAicbnw+U3SokcsspfsAuyYHZOYZgwO8IAEKy6iCc7TpBdiSJZ/vAKQ== dependencies: "@babel/core" "^7.12.10" "@babel/plugin-proposal-class-properties" "^7.12.1" @@ -4785,13 +4824,11 @@ "@babel/preset-react" "^7.12.10" "@babel/preset-typescript" "^7.12.7" "@babel/register" "^7.12.1" - "@storybook/node-logger" "6.3.12" + "@storybook/node-logger" "6.4.22" "@storybook/semver" "^7.3.2" - "@types/glob-base" "^0.3.0" - "@types/micromatch" "^4.0.1" "@types/node" "^14.0.10" "@types/pretty-hrtime" "^1.0.0" - babel-loader "^8.2.2" + babel-loader "^8.0.0" babel-plugin-macros "^3.0.1" babel-plugin-polyfill-corejs3 "^0.1.0" chalk "^4.1.0" @@ -4800,79 +4837,91 @@ file-system-cache "^1.0.5" find-up "^5.0.0" fork-ts-checker-webpack-plugin "^6.0.4" + fs-extra "^9.0.1" glob "^7.1.6" - glob-base "^0.3.0" + handlebars "^4.7.7" interpret "^2.2.0" json5 "^2.1.3" lazy-universal-dotenv "^3.0.1" - micromatch "^4.0.2" + picomatch "^2.3.0" pkg-dir "^5.0.0" pretty-hrtime "^1.0.3" resolve-from "^5.0.0" + slash "^3.0.0" + telejson "^5.3.2" ts-dedent "^2.0.0" util-deprecate "^1.0.2" webpack "4" -"@storybook/core-events@6.3.12", "@storybook/core-events@^6.3.0", "@storybook/core-events@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-6.3.12.tgz#73f6271d485ef2576234e578bb07705b92805290" - integrity sha512-SXfD7xUUMazaeFkB92qOTUV8Y/RghE4SkEYe5slAdjeocSaH7Nz2WV0rqNEgChg0AQc+JUI66no8L9g0+lw4Gw== +"@storybook/core-events@6.4.22", "@storybook/core-events@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-6.4.22.tgz#c09b0571951affd4254028b8958a4d8652700989" + integrity sha512-5GYY5+1gd58Gxjqex27RVaX6qbfIQmJxcbzbNpXGNSqwqAuIIepcV1rdCVm6I4C3Yb7/AQ3cN5dVbf33QxRIwA== dependencies: core-js "^3.8.2" -"@storybook/core-server@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/core-server/-/core-server-6.3.12.tgz#d906f823b263d78a4b087be98810b74191d263cd" - integrity sha512-T/Mdyi1FVkUycdyOnhXvoo3d9nYXLQFkmaJkltxBFLzAePAJUSgAsPL9odNC3+p8Nr2/UDsDzvu/Ow0IF0mzLQ== +"@storybook/core-server@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/core-server/-/core-server-6.4.22.tgz#254409ec2ba49a78b23f5e4a4c0faea5a570a32b" + integrity sha512-wFh3e2fa0un1d4+BJP+nd3FVWUO7uHTqv3OGBfOmzQMKp4NU1zaBNdSQG7Hz6mw0fYPBPZgBjPfsJRwIYLLZyw== dependencies: "@discoveryjs/json-ext" "^0.5.3" - "@storybook/builder-webpack4" "6.3.12" - "@storybook/core-client" "6.3.12" - "@storybook/core-common" "6.3.12" - "@storybook/csf-tools" "6.3.12" - "@storybook/manager-webpack4" "6.3.12" - "@storybook/node-logger" "6.3.12" + "@storybook/builder-webpack4" "6.4.22" + "@storybook/core-client" "6.4.22" + "@storybook/core-common" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/csf-tools" "6.4.22" + "@storybook/manager-webpack4" "6.4.22" + "@storybook/node-logger" "6.4.22" "@storybook/semver" "^7.3.2" + "@storybook/store" "6.4.22" "@types/node" "^14.0.10" "@types/node-fetch" "^2.5.7" "@types/pretty-hrtime" "^1.0.0" "@types/webpack" "^4.41.26" better-opn "^2.1.1" - boxen "^4.2.0" + boxen "^5.1.2" chalk "^4.1.0" - cli-table3 "0.6.0" + cli-table3 "^0.6.1" commander "^6.2.1" compression "^1.7.4" core-js "^3.8.2" - cpy "^8.1.1" + cpy "^8.1.2" detect-port "^1.3.0" express "^4.17.1" file-system-cache "^1.0.5" fs-extra "^9.0.1" globby "^11.0.2" ip "^1.1.5" + lodash "^4.17.21" node-fetch "^2.6.1" pretty-hrtime "^1.0.3" prompts "^2.4.0" regenerator-runtime "^0.13.7" serve-favicon "^2.5.0" + slash "^3.0.0" + telejson "^5.3.3" ts-dedent "^2.0.0" util-deprecate "^1.0.2" + watchpack "^2.2.0" webpack "4" + ws "^8.2.3" -"@storybook/core@6.3.12", "@storybook/core@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/core/-/core-6.3.12.tgz#eb945f7ed5c9039493318bcd2bb5a3a897b91cfd" - integrity sha512-FJm2ns8wk85hXWKslLWiUWRWwS9KWRq7jlkN6M9p57ghFseSGr4W71Orcoab4P3M7jI97l5yqBfppbscinE74g== +"@storybook/core@6.4.22", "@storybook/core@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/core/-/core-6.4.22.tgz#cf14280d7831b41d5dea78f76b414bdfde5918f0" + integrity sha512-KZYJt7GM5NgKFXbPRZZZPEONZ5u/tE/cRbMdkn/zWN3He8+VP+65/tz8hbriI/6m91AWVWkBKrODSkeq59NgRA== dependencies: - "@storybook/core-client" "6.3.12" - "@storybook/core-server" "6.3.12" + "@storybook/core-client" "6.4.22" + "@storybook/core-server" "6.4.22" -"@storybook/csf-tools@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/csf-tools/-/csf-tools-6.3.12.tgz#d979c6a79d1e9d6c8b5a5e8834d07fcf5b793844" - integrity sha512-wNrX+99ajAXxLo0iRwrqw65MLvCV6SFC0XoPLYrtBvyKr+hXOOnzIhO2f5BNEii8velpC2gl2gcLKeacpVYLqA== +"@storybook/csf-tools@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/csf-tools/-/csf-tools-6.4.22.tgz#f6d64bcea1b36114555972acae66a1dbe9e34b5c" + integrity sha512-LMu8MZAiQspJAtMBLU2zitsIkqQv7jOwX7ih5JrXlyaDticH7l2j6Q+1mCZNWUOiMTizj0ivulmUsSaYbpToSw== dependencies: + "@babel/core" "^7.12.10" "@babel/generator" "^7.12.11" "@babel/parser" "^7.12.11" "@babel/plugin-transform-react-jsx" "^7.12.12" @@ -4880,43 +4929,44 @@ "@babel/traverse" "^7.12.11" "@babel/types" "^7.12.11" "@mdx-js/mdx" "^1.6.22" - "@storybook/csf" "^0.0.1" + "@storybook/csf" "0.0.2--canary.87bc651.0" core-js "^3.8.2" fs-extra "^9.0.1" + global "^4.4.0" js-string-escape "^1.0.1" - lodash "^4.17.20" - prettier "~2.2.1" + lodash "^4.17.21" + prettier ">=2.2.1 <=2.3.0" regenerator-runtime "^0.13.7" + ts-dedent "^2.0.0" -"@storybook/csf@0.0.1", "@storybook/csf@^0.0.1": - version "0.0.1" - resolved "https://registry.yarnpkg.com/@storybook/csf/-/csf-0.0.1.tgz#95901507dc02f0bc6f9ac8ee1983e2fc5bb98ce6" - integrity sha512-USTLkZze5gkel8MYCujSRBVIrUQ3YPBrLOx7GNk/0wttvVtlzWXAq9eLbQ4p/NicGxP+3T7KPEMVV//g+yubpw== +"@storybook/csf@0.0.2--canary.87bc651.0": + version "0.0.2--canary.87bc651.0" + resolved "https://registry.yarnpkg.com/@storybook/csf/-/csf-0.0.2--canary.87bc651.0.tgz#c7b99b3a344117ef67b10137b6477a3d2750cf44" + integrity sha512-ajk1Uxa+rBpFQHKrCcTmJyQBXZ5slfwHVEaKlkuFaW77it8RgbPJp/ccna3sgoi8oZ7FkkOyvv1Ve4SmwFqRqw== dependencies: lodash "^4.17.15" -"@storybook/manager-webpack4@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/manager-webpack4/-/manager-webpack4-6.3.12.tgz#1c10a60b0acec3f9136dd8b7f22a25469d8b91e5" - integrity sha512-OkPYNrHXg2yZfKmEfTokP6iKx4OLTr0gdI5yehi/bLEuQCSHeruxBc70Dxm1GBk1Mrf821wD9WqMXNDjY5Qtug== +"@storybook/manager-webpack4@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/manager-webpack4/-/manager-webpack4-6.4.22.tgz#eabd674beee901c7f755d9b679e9f969cbab636d" + integrity sha512-nzhDMJYg0vXdcG0ctwE6YFZBX71+5NYaTGkxg3xT7gbgnP1YFXn9gVODvgq3tPb3gcRapjyOIxUa20rV+r8edA== dependencies: "@babel/core" "^7.12.10" "@babel/plugin-transform-template-literals" "^7.12.1" "@babel/preset-react" "^7.12.10" - "@storybook/addons" "6.3.12" - "@storybook/core-client" "6.3.12" - "@storybook/core-common" "6.3.12" - "@storybook/node-logger" "6.3.12" - "@storybook/theming" "6.3.12" - "@storybook/ui" "6.3.12" + "@storybook/addons" "6.4.22" + "@storybook/core-client" "6.4.22" + "@storybook/core-common" "6.4.22" + "@storybook/node-logger" "6.4.22" + "@storybook/theming" "6.4.22" + "@storybook/ui" "6.4.22" "@types/node" "^14.0.10" "@types/webpack" "^4.41.26" - babel-loader "^8.2.2" + babel-loader "^8.0.0" case-sensitive-paths-webpack-plugin "^2.3.0" chalk "^4.1.0" core-js "^3.8.2" css-loader "^3.6.0" - dotenv-webpack "^1.8.0" express "^4.17.1" file-loader "^6.2.0" file-system-cache "^1.0.5" @@ -4938,24 +4988,46 @@ webpack-dev-middleware "^3.7.3" webpack-virtual-modules "^0.2.2" -"@storybook/node-logger@6.3.12", "@storybook/node-logger@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/node-logger/-/node-logger-6.3.12.tgz#a67cfbe266d2692f317914ef583721627498df19" - integrity sha512-iktOem/Ls2+dsZY9PhPeC6T1QhX/y7OInP88neLsqEPEbB2UXca3Ydv7OZBhBVbvN25W45b05MRzbtNUxYLNRw== +"@storybook/node-logger@6.4.22", "@storybook/node-logger@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/node-logger/-/node-logger-6.4.22.tgz#c4ec00f8714505f44eda7671bc88bb44abf7ae59" + integrity sha512-sUXYFqPxiqM7gGH7gBXvO89YEO42nA4gBicJKZjj9e+W4QQLrftjF9l+mAw2K0mVE10Bn7r4pfs5oEZ0aruyyA== dependencies: "@types/npmlog" "^4.1.2" chalk "^4.1.0" core-js "^3.8.2" - npmlog "^4.1.2" + npmlog "^5.0.1" pretty-hrtime "^1.0.3" -"@storybook/postinstall@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/postinstall/-/postinstall-6.3.12.tgz#ed98caff76d8c1a1733ec630565ef4162b274614" - integrity sha512-HkZ+abtZ3W6JbGPS6K7OSnNXbwaTwNNd5R02kRs4gV9B29XsBPDtFT6vIwzM3tmVQC7ihL5a8ceWp2OvzaNOuw== +"@storybook/postinstall@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/postinstall/-/postinstall-6.4.22.tgz#592c7406f197fd25a5644c3db7a87d9b5da77e85" + integrity sha512-LdIvA+l70Mp5FSkawOC16uKocefc+MZLYRHqjTjgr7anubdi6y7W4n9A7/Yw4IstZHoknfL88qDj/uK5N+Ahzw== dependencies: core-js "^3.8.2" +"@storybook/preview-web@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/preview-web/-/preview-web-6.4.22.tgz#58bfc6492503ff4265b50f42a27ea8b0bfcf738a" + integrity sha512-sWS+sgvwSvcNY83hDtWUUL75O2l2LY/GTAS0Zp2dh3WkObhtuJ/UehftzPZlZmmv7PCwhb4Q3+tZDKzMlFxnKQ== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/channel-postmessage" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/store" "6.4.22" + ansi-to-html "^0.6.11" + core-js "^3.8.2" + global "^4.4.0" + lodash "^4.17.21" + qs "^6.10.0" + regenerator-runtime "^0.13.7" + synchronous-promise "^2.0.15" + ts-dedent "^2.0.0" + unfetch "^4.2.0" + util-deprecate "^1.0.2" + "@storybook/react-docgen-typescript-plugin@1.0.2-canary.253f8c1.0": version "1.0.2-canary.253f8c1.0" resolved "https://registry.yarnpkg.com/@storybook/react-docgen-typescript-plugin/-/react-docgen-typescript-plugin-1.0.2-canary.253f8c1.0.tgz#f2da40e6aae4aa586c2fb284a4a1744602c3c7fa" @@ -4969,49 +5041,51 @@ react-docgen-typescript "^2.0.0" tslib "^2.0.0" -"@storybook/react@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/react/-/react-6.3.12.tgz#2e172cbfc06f656d2890743dcf49741a10fa1629" - integrity sha512-c1Y/3/eNzye+ZRwQ3BXJux6pUMVt3lhv1/M9Qagl9JItP3jDSj5Ed3JHCgwEqpprP8mvNNXwEJ8+M7vEQyDuHg== +"@storybook/react@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/react/-/react-6.4.22.tgz#5940e5492bc87268555b47f12aff4be4b67eae54" + integrity sha512-5BFxtiguOcePS5Ty/UoH7C6odmvBYIZutfiy4R3Ua6FYmtxac5vP9r5KjCz1IzZKT8mCf4X+PuK1YvDrPPROgQ== dependencies: "@babel/preset-flow" "^7.12.1" "@babel/preset-react" "^7.12.10" - "@pmmmwh/react-refresh-webpack-plugin" "^0.4.3" - "@storybook/addons" "6.3.12" - "@storybook/core" "6.3.12" - "@storybook/core-common" "6.3.12" - "@storybook/node-logger" "6.3.12" + "@pmmmwh/react-refresh-webpack-plugin" "^0.5.1" + "@storybook/addons" "6.4.22" + "@storybook/core" "6.4.22" + "@storybook/core-common" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/node-logger" "6.4.22" "@storybook/react-docgen-typescript-plugin" "1.0.2-canary.253f8c1.0" "@storybook/semver" "^7.3.2" + "@storybook/store" "6.4.22" "@types/webpack-env" "^1.16.0" babel-plugin-add-react-displayname "^0.0.5" babel-plugin-named-asset-import "^0.3.1" babel-plugin-react-docgen "^4.2.1" core-js "^3.8.2" global "^4.4.0" - lodash "^4.17.20" + lodash "^4.17.21" prop-types "^15.7.2" - react-dev-utils "^11.0.3" - react-refresh "^0.8.3" + react-refresh "^0.11.0" read-pkg-up "^7.0.1" regenerator-runtime "^0.13.7" ts-dedent "^2.0.0" webpack "4" -"@storybook/router@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/router/-/router-6.3.12.tgz#0d572ec795f588ca886f39cb9b27b94ff3683f84" - integrity sha512-G/pNGCnrJRetCwyEZulHPT+YOcqEj/vkPVDTUfii2qgqukup6K0cjwgd7IukAURnAnnzTi1gmgFuEKUi8GE/KA== +"@storybook/router@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/router/-/router-6.4.22.tgz#e3cc5cd8595668a367e971efb9695bbc122ed95e" + integrity sha512-zeuE8ZgFhNerQX8sICQYNYL65QEi3okyzw7ynF58Ud6nRw4fMxSOHcj2T+nZCIU5ufozRL4QWD/Rg9P2s/HtLw== dependencies: - "@reach/router" "^1.3.4" - "@storybook/client-logger" "6.3.12" - "@types/reach__router" "^1.3.7" + "@storybook/client-logger" "6.4.22" core-js "^3.8.2" fast-deep-equal "^3.1.3" global "^4.4.0" - lodash "^4.17.20" + history "5.0.0" + lodash "^4.17.21" memoizerific "^1.11.3" qs "^6.10.0" + react-router "^6.0.0" + react-router-dom "^6.0.0" ts-dedent "^2.0.0" "@storybook/semver@^7.3.2": @@ -5022,36 +5096,59 @@ core-js "^3.6.5" find-up "^4.1.0" -"@storybook/source-loader@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/source-loader/-/source-loader-6.3.12.tgz#86e72824c04ad0eaa89b807857bd845db97e57bd" - integrity sha512-Lfe0LOJGqAJYkZsCL8fhuQOeFSCgv8xwQCt4dkcBd0Rw5zT2xv0IXDOiIOXGaWBMDtrJUZt/qOXPEPlL81Oaqg== +"@storybook/source-loader@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/source-loader/-/source-loader-6.4.22.tgz#c931b81cf1bd63f79b51bfa9311de7f5a04a7b77" + integrity sha512-O4RxqPgRyOgAhssS6q1Rtc8LiOvPBpC1EqhCYWRV3K+D2EjFarfQMpjgPj18hC+QzpUSfzoBZYqsMECewEuLNw== dependencies: - "@storybook/addons" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/csf" "0.0.1" + "@storybook/addons" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" core-js "^3.8.2" estraverse "^5.2.0" global "^4.4.0" loader-utils "^2.0.0" - lodash "^4.17.20" - prettier "~2.2.1" + lodash "^4.17.21" + prettier ">=2.2.1 <=2.3.0" regenerator-runtime "^0.13.7" -"@storybook/testing-react@^0.0.22": - version "0.0.22" - resolved "https://registry.yarnpkg.com/@storybook/testing-react/-/testing-react-0.0.22.tgz#65d3defefbac0183eded0dafb601241d8f135c66" - integrity sha512-XBJpH1cROXkwwKwD89kIcyhyMPEN5zfSyOUanrN+/Tx4nB5IwzVc/Om+7mtSFvh4UTSNOk5G42Y12KE/HbH7VA== +"@storybook/store@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/store/-/store-6.4.22.tgz#f291fbe3639f14d25f875cac86abb209a97d4e2a" + integrity sha512-lrmcZtYJLc2emO+1l6AG4Txm9445K6Pyv9cGAuhOJ9Kks0aYe0YtvMkZVVry0RNNAIv6Ypz72zyKc/QK+tZLAQ== + dependencies: + "@storybook/addons" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/csf" "0.0.2--canary.87bc651.0" + core-js "^3.8.2" + fast-deep-equal "^3.1.3" + global "^4.4.0" + lodash "^4.17.21" + memoizerific "^1.11.3" + regenerator-runtime "^0.13.7" + slash "^3.0.0" + stable "^0.1.8" + synchronous-promise "^2.0.15" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + +"@storybook/testing-react@^1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@storybook/testing-react/-/testing-react-1.2.4.tgz#2cc8bf6685e358e8c570a9d823dacecb5995ef37" + integrity sha512-qkyXpE66zp0iyfhdiMV2jxNF32SWXW8vOo+rqLHg29Vg/ssor+G7o+wgWkGP9PaID6pTMstzotVSp/mUa3oN3w== + dependencies: + "@storybook/csf" "0.0.2--canary.87bc651.0" -"@storybook/theming@6.3.12", "@storybook/theming@^6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-6.3.12.tgz#5bddf9bd90a60709b5ab238ecdb7d9055dd7862e" - integrity sha512-wOJdTEa/VFyFB2UyoqyYGaZdym6EN7RALuQOAMT6zHA282FBmKw8nL5DETHEbctpnHdcrMC/391teK4nNSrdOA== +"@storybook/theming@6.4.22", "@storybook/theming@^6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-6.4.22.tgz#19097eec0366447ddd0d6917b0e0f81d0ec5e51e" + integrity sha512-NVMKH/jxSPtnMTO4VCN1k47uztq+u9fWv4GSnzq/eezxdGg9ceGL4/lCrNGoNajht9xbrsZ4QvsJ/V2sVGM8wA== dependencies: "@emotion/core" "^10.1.1" "@emotion/is-prop-valid" "^0.8.6" "@emotion/styled" "^10.0.27" - "@storybook/client-logger" "6.3.12" + "@storybook/client-logger" "6.4.22" core-js "^3.8.2" deep-object-diff "^1.1.0" emotion-theming "^10.0.27" @@ -5061,22 +5158,21 @@ resolve-from "^5.0.0" ts-dedent "^2.0.0" -"@storybook/ui@6.3.12": - version "6.3.12" - resolved "https://registry.yarnpkg.com/@storybook/ui/-/ui-6.3.12.tgz#349e1a4c58c4fd18ea65b2ab56269a7c3a164ee7" - integrity sha512-PC2yEz4JMfarq7rUFbeA3hCA+31p5es7YPEtxLRvRwIZhtL0P4zQUfHpotb3KgWdoAIfZesAuoIQwMPQmEFYrw== +"@storybook/ui@6.4.22": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@storybook/ui/-/ui-6.4.22.tgz#49badd7994465d78d984ca4c42533c1c22201c46" + integrity sha512-UVjMoyVsqPr+mkS1L7m30O/xrdIEgZ5SCWsvqhmyMUok3F3tRB+6M+OA5Yy+cIVfvObpA7MhxirUT1elCGXsWQ== dependencies: "@emotion/core" "^10.1.1" - "@storybook/addons" "6.3.12" - "@storybook/api" "6.3.12" - "@storybook/channels" "6.3.12" - "@storybook/client-logger" "6.3.12" - "@storybook/components" "6.3.12" - "@storybook/core-events" "6.3.12" - "@storybook/router" "6.3.12" + "@storybook/addons" "6.4.22" + "@storybook/api" "6.4.22" + "@storybook/channels" "6.4.22" + "@storybook/client-logger" "6.4.22" + "@storybook/components" "6.4.22" + "@storybook/core-events" "6.4.22" + "@storybook/router" "6.4.22" "@storybook/semver" "^7.3.2" - "@storybook/theming" "6.3.12" - "@types/markdown-to-jsx" "^6.11.3" + "@storybook/theming" "6.4.22" copy-to-clipboard "^3.3.1" core-js "^3.8.2" core-js-pure "^3.8.2" @@ -5084,8 +5180,8 @@ emotion-theming "^10.0.27" fuse.js "^3.6.1" global "^4.4.0" - lodash "^4.17.20" - markdown-to-jsx "^6.11.4" + lodash "^4.17.21" + markdown-to-jsx "^7.1.3" memoizerific "^1.11.3" polished "^4.0.5" qs "^6.10.0" @@ -5170,14 +5266,14 @@ "@types/react-test-renderer" ">=16.9.0" react-error-boundary "^3.1.0" -"@testing-library/react@^12.1.4": - version "12.1.4" - resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.4.tgz#09674b117e550af713db3f4ec4c0942aa8bbf2c0" - integrity sha512-jiPKOm7vyUw311Hn/HlNQ9P8/lHNtArAx0PisXyFixDDvfl8DbD6EUdbshK5eqauvBSvzZd19itqQ9j3nferJA== +"@testing-library/react@^12.1.5": + version "12.1.5" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" + integrity sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg== dependencies: "@babel/runtime" "^7.12.5" "@testing-library/dom" "^8.0.0" - "@types/react-dom" "*" + "@types/react-dom" "<18.0.0" "@testing-library/user-event@^13.5.0": version "13.5.0" @@ -5737,11 +5833,6 @@ resolved "https://registry.yarnpkg.com/@types/getos/-/getos-3.0.0.tgz#582c758e99e9d634f31f471faf7ce59cf1c39a71" integrity sha512-g5O9kykBPMaK5USwU+zM5AyXaztqbvHjSQ7HaBjqgO3f5lKGChkRhLP58Z/Nrr4RBGNNPrBcJkWZwnmbmi9YjQ== -"@types/glob-base@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@types/glob-base/-/glob-base-0.3.0.tgz#a581d688347e10e50dd7c17d6f2880a10354319d" - integrity sha1-pYHWiDR+EOUN18F9byiAoQNUMZ0= - "@types/glob-stream@*": version "6.1.0" resolved "https://registry.yarnpkg.com/@types/glob-stream/-/glob-stream-6.1.0.tgz#7ede8a33e59140534f8d8adfb8ac9edfb31897bc" @@ -6477,13 +6568,6 @@ "@types/linkify-it" "*" "@types/mdurl" "*" -"@types/markdown-to-jsx@^6.11.3": - version "6.11.3" - resolved "https://registry.yarnpkg.com/@types/markdown-to-jsx/-/markdown-to-jsx-6.11.3.tgz#cdd1619308fecbc8be7e6a26f3751260249b020e" - integrity sha512-30nFYpceM/ZEvhGiqWjm5quLUxNeld0HCzJEXMZZDpq53FPkS85mTwkWtCXzCqq8s5JYLgM5W392a02xn8Bdaw== - dependencies: - "@types/react" "*" - "@types/md5@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@types/md5/-/md5-2.2.0.tgz#cd82e16b95973f94bb03dee40c5b6be4a7fb7fb4" @@ -6503,10 +6587,10 @@ resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-1.0.2.tgz#e2ce9d83a613bacf284c7be7d491945e39e1f8e9" integrity sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA== -"@types/micromatch@^4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@types/micromatch/-/micromatch-4.0.1.tgz#9381449dd659fc3823fd2a4190ceacc985083bc7" - integrity sha512-my6fLBvpY70KattTNzYOK6KU1oR1+UCz9ug/JbcF5UrEmeCt9P7DV2t7L8+t18mMPINqGQCE4O8PLOPbI84gxw== +"@types/micromatch@^4.0.2": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/micromatch/-/micromatch-4.0.2.tgz#ce29c8b166a73bf980a5727b1e4a4d099965151d" + integrity sha512-oqXqVb0ci19GtH0vOA/U2TmHTcRY9kuZl4mqUxe0QmJAlIW13kzhuK5pi1i9+ngav8FjpSb9FVS/GE00GLX1VA== dependencies: "@types/braces" "*" @@ -6788,13 +6872,6 @@ resolved "https://registry.yarnpkg.com/@types/rbush/-/rbush-3.0.0.tgz#b6887d99b159e87ae23cd14eceff34f139842aa6" integrity sha512-W3ue/GYWXBOpkRm0VSoifrP3HV0Ni47aVJWvXyWMcbtpBy/l/K/smBRiJ+fI8f7shXRjZBiux+iJzYbh7VmcZg== -"@types/reach__router@^1.2.6", "@types/reach__router@^1.3.7": - version "1.3.7" - resolved "https://registry.yarnpkg.com/@types/reach__router/-/reach__router-1.3.7.tgz#de8ab374259ae7f7499fc1373b9697a5f3cd6428" - integrity sha512-cyBEb8Ef3SJNH5NYEIDGPoMMmYUxROatuxbICusVRQIqZUB85UCt6R2Ok60tKS/TABJsJYaHyNTW3kqbpxlMjg== - dependencies: - "@types/react" "*" - "@types/react-beautiful-dnd@^13.0.0": version "13.0.0" resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz#e60d3d965312fcf1516894af92dc3e9249587db4" @@ -6809,12 +6886,12 @@ dependencies: "@types/react" "*" -"@types/react-dom@*", "@types/react-dom@>=16.9.0", "@types/react-dom@^16.9.8": - version "16.9.8" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.8.tgz#fe4c1e11dfc67155733dfa6aa65108b4971cb423" - integrity sha512-ykkPQ+5nFknnlU6lDd947WbQ6TE3NNzbQAkInC2EKY1qeYdTKp7onFusmYZb+ityzx2YviqT6BXSu+LyWWJwcA== +"@types/react-dom@<18.0.0", "@types/react-dom@>=16.9.0", "@types/react-dom@^16.9.15": + version "16.9.15" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.15.tgz#7bf41f2b2b86915ff9c0de475cb111d904df12c6" + integrity sha512-PjWhZj54ACucQX2hDmnHyqHz+N2On5g3Lt5BeNn+wy067qvOokVSQw1nEog1XGfvLYrSl3cyrdebEfjQQNXD3A== dependencies: - "@types/react" "*" + "@types/react" "^16" "@types/react-grid-layout@^0.16.7": version "0.16.7" @@ -6835,7 +6912,7 @@ resolved "https://registry.yarnpkg.com/@types/react-intl/-/react-intl-2.3.17.tgz#e1fc6e46e8af58bdef9531259d509380a8a99e8e" integrity sha512-FGd6J1GQ7zvl1GZ3BBev83B7nfak8dqoR2PZ+l5MoisKMpd4xOLhZJC1ugpmk3Rz5F85t6HbOg9mYqXW97BsNA== -"@types/react-is@^16.7.1": +"@types/react-is@^16.7.2": version "16.7.2" resolved "https://registry.yarnpkg.com/@types/react-is/-/react-is-16.7.2.tgz#8c2862013d00d741be189ceb71da8e8d21e8fa7d" integrity sha512-rdQUu9J+RUz4Vcr768UyTzv+fZGzKBy1/PPhaxTfzAfaHSW4+b0olA6czXLZv7PO7/ktbHu41kcpAG7Z46kvDQ== @@ -6936,13 +7013,14 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16", "@types/react@^16.9.36": - version "16.9.36" - resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.36.tgz#ade589ff51e2a903e34ee4669e05dbfa0c1ce849" - integrity sha512-mGgUb/Rk/vGx4NCvquRuSH0GHBQKb1OqpGS9cT9lFxlTLHZgkksgI60TuIxubmn7JuCb+sENHhQciqa0npm0AQ== +"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16", "@types/react@^16.14.25": + version "16.14.25" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.25.tgz#d003f712c7563fdef5a87327f1892825af375608" + integrity sha512-cXRVHd7vBT5v1is72mmvmsg9stZrbJO04DJqFeh3Yj2tVKO6vmxg5BI+ybI6Ls7ROXRG3aFbZj9x0WA3ZAoDQw== dependencies: "@types/prop-types" "*" - csstype "^2.2.0" + "@types/scheduler" "*" + csstype "^3.0.2" "@types/read-pkg@^4.0.0": version "4.0.0" @@ -7013,6 +7091,11 @@ dependencies: rrule "*" +"@types/scheduler@*": + version "0.16.2" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" + integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== + "@types/seedrandom@>=2.0.0 <4.0.0": version "2.4.28" resolved "https://registry.yarnpkg.com/@types/seedrandom/-/seedrandom-2.4.28.tgz#9ce8fa048c1e8c85cb71d7fe4d704e000226036f" @@ -7755,7 +7838,7 @@ acorn@^8.4.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.5.0.tgz#4512ccb99b3698c752591e9bb4472e38ad43cee2" integrity sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q== -address@1.1.2, address@^1.0.1: +address@^1.0.1: version "1.1.2" resolved "https://registry.yarnpkg.com/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" integrity sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA== @@ -7994,7 +8077,12 @@ ansi-green@^0.1.1: dependencies: ansi-wrap "0.1.0" -ansi-html@0.0.7, ansi-html@^0.0.7: +ansi-html-community@0.0.8, ansi-html-community@^0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/ansi-html-community/-/ansi-html-community-0.0.8.tgz#69fbc4d6ccbe383f9736934ae34c3f8290f1bf41" + integrity sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw== + +ansi-html@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" integrity sha1-gTWEAhliqenm/QOflA0S9WynhZ4= @@ -8211,6 +8299,14 @@ archy@^1.0.0: resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" integrity sha1-+cjBN1fMHde8N5rHeyxipcKGjEA= +are-we-there-yet@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" + integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== + dependencies: + delegates "^1.0.0" + readable-stream "^3.6.0" + are-we-there-yet@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-3.0.0.tgz#ba20bd6b553e31d62fc8c31bd23d22b95734390d" @@ -8762,7 +8858,7 @@ babel-jest@^26.6.3: graceful-fs "^4.2.4" slash "^3.0.0" -babel-loader@^8.2.2: +babel-loader@^8.0.0, babel-loader@^8.2.2: version "8.2.2" resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.2.2.tgz#9363ce84c10c9a40e6c753748e1441b60c8a0b81" integrity sha512-JvTd0/D889PQBtUXJ2PXaKU/pjZDMtHA9V2ecm+eNRmmBCMR09a+fmpGTNwnJtFmFl5Ei7Vy47LjBb+L0wQ99g== @@ -9250,20 +9346,6 @@ bowser@^1.7.3: resolved "https://registry.yarnpkg.com/bowser/-/bowser-1.9.4.tgz#890c58a2813a9d3243704334fa81b96a5c150c9a" integrity sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ== -boxen@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-4.2.0.tgz#e411b62357d6d6d36587c8ac3d5d974daa070e64" - integrity sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ== - dependencies: - ansi-align "^3.0.0" - camelcase "^5.3.1" - chalk "^3.0.0" - cli-boxes "^2.2.0" - string-width "^4.1.0" - term-size "^2.1.0" - type-fest "^0.8.1" - widest-line "^3.1.0" - boxen@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.0.1.tgz#657528bdd3f59a772b8279b831f27ec2c744664b" @@ -9278,6 +9360,20 @@ boxen@^5.0.0: widest-line "^3.1.0" wrap-ansi "^7.0.0" +boxen@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50" + integrity sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ== + dependencies: + ansi-align "^3.0.0" + camelcase "^6.2.0" + chalk "^4.1.0" + cli-boxes "^2.2.1" + string-width "^4.2.2" + type-fest "^0.20.2" + widest-line "^3.1.0" + wrap-ansi "^7.0.0" + brace-expansion@^1.1.7: version "1.1.8" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.8.tgz#c07b211c7c952ec1f8efd51a77ef0d1d3990a292" @@ -9581,16 +9677,6 @@ browserify@^17.0.0: vm-browserify "^1.0.0" xtend "^4.0.0" -browserslist@4.14.2: - version "4.14.2" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.14.2.tgz#1b3cec458a1ba87588cc5e9be62f19b6d48813ce" - integrity sha512-HI4lPveGKUR0x2StIz+2FXfDk9SfVMrxn6PLh1JeGUwcuoDkdKZebWiyLRJ68iIPDpMI4JLVDf7S7XzslgWOhw== - dependencies: - caniuse-lite "^1.0.30001125" - electron-to-chromium "^1.3.564" - escalade "^3.0.2" - node-releases "^1.1.61" - browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.17.5, browserslist@^4.17.6, browserslist@^4.19.1: version "4.19.1" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.19.1.tgz#4ac0435b35ab655896c31d53018b6dd5e9e4c9a3" @@ -9933,7 +10019,7 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001097, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001286: +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001097, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001286: version "1.0.30001335" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001335.tgz" integrity sha512-ddP1Tgm7z2iIxu6QTtbZUv6HJxSaV/PZeSrWFZtbY4JZ69tOeNhBCl3HyRQgeNZKE5AOn1kpV7fhljigy0Ty3w== @@ -9999,15 +10085,6 @@ chai@3.5.0: deep-eql "^0.1.3" type-detect "^1.0.0" -chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - chalk@4.1.0, chalk@^4.0.0, chalk@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" @@ -10035,6 +10112,15 @@ chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" +chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + chalk@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" @@ -10254,11 +10340,6 @@ clean-webpack-plugin@^3.0.0: "@types/webpack" "^4.4.31" del "^4.1.1" -cli-boxes@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.0.tgz#538ecae8f9c6ca508e3c3c95b453fe93cb4c168d" - integrity sha512-gpaBrMAizVEANOpfZp/EEUixTXDyGt7DFzdK5hU+UbWt/J0lB0w20ncZj59Z9a93xHb9u12zF5BS6i9RKbtg4w== - cli-boxes@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" @@ -10283,17 +10364,7 @@ cli-spinners@^2.2.0, cli-spinners@^2.5.0: resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.5.0.tgz#12763e47251bf951cb75c201dfa58ff1bcb2d047" integrity sha512-PC+AmIuK04E6aeSs/pUccSujsTzBhu4HzC2dL+CfJB/Jcc2qTRbEwZQDfIUpt2Xl8BodYBEq8w4fc0kU2I9DjQ== -cli-table3@0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.0.tgz#b7b1bc65ca8e7b5cef9124e13dc2b21e2ce4faee" - integrity sha512-gnB85c3MGC7Nm9I/FkiasNBOKjOiO1RNuXXarQms37q4QMpWdlbBgD/VnOStA2faG1dpXMv31RFApjX1/QdgWQ== - dependencies: - object-assign "^4.1.0" - string-width "^4.2.0" - optionalDependencies: - colors "^1.1.2" - -cli-table3@~0.6.1: +cli-table3@^0.6.1, cli-table3@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.1.tgz#36ce9b7af4847f288d3cdd081fbd09bf7bd237b8" integrity sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA== @@ -10558,7 +10629,7 @@ color-string@^1.5.2, color-string@^1.6.0, color-string@^1.9.0: color-name "^1.0.0" simple-swizzle "^0.2.2" -color-support@^1.1.3: +color-support@^1.1.2, color-support@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== @@ -10592,7 +10663,7 @@ colorette@^1.2.0, colorette@^1.2.1, colorette@^1.2.2: resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== -colors@1.4.0, colors@^1.1.2, colors@^1.3.2: +colors@1.4.0, colors@^1.3.2: version "1.4.0" resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== @@ -10682,6 +10753,11 @@ commander@^9.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-9.0.0.tgz#86d58f24ee98126568936bd1d3574e0308a99a40" integrity sha512-JJfP2saEKbQqvW+FI93OYUB4ByV5cizMpFMiiJI8xDbBvQvSkIk0VvQdn1CZ8mqAO8Loq2h0gYTYtDFUZUeERw== +common-path-prefix@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/common-path-prefix/-/common-path-prefix-3.0.0.tgz#7d007a7e07c58c4b4d5f433131a19141b29f11e0" + integrity sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w== + common-tags@^1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937" @@ -10970,7 +11046,7 @@ core-js-compat@^3.8.1: browserslist "^4.17.6" semver "7.0.0" -core-js-pure@^3.0.0, core-js-pure@^3.8.2: +core-js-pure@^3.0.0, core-js-pure@^3.8.1, core-js-pure@^3.8.2: version "3.19.1" resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.19.1.tgz#edffc1fc7634000a55ba05e95b3f0fe9587a5aa4" integrity sha512-Q0Knr8Es84vtv62ei6/6jXH/7izKmOrtrxH9WJTHLCMAVeU+8TF8z8Nr08CsH4Ot0oJKzBzJJL9SJBYIv7WlfQ== @@ -11070,6 +11146,21 @@ cpy@^8.1.1: p-filter "^2.1.0" p-map "^3.0.0" +cpy@^8.1.2: + version "8.1.2" + resolved "https://registry.yarnpkg.com/cpy/-/cpy-8.1.2.tgz#e339ea54797ad23f8e3919a5cffd37bfc3f25935" + integrity sha512-dmC4mUesv0OYH2kNFEidtf/skUwv4zePmGeepjyyJ0qTo5+8KhA1o99oIAwVVLzQMAeDJml74d6wPPKb6EZUTg== + dependencies: + arrify "^2.0.1" + cp-file "^7.0.0" + globby "^9.2.0" + has-glob "^1.0.0" + junk "^3.1.0" + nested-error-stacks "^2.1.0" + p-all "^2.1.0" + p-filter "^2.1.0" + p-map "^3.0.0" + crc-32@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.0.tgz#cb2db6e29b88508e32d9dd0ec1693e7b41a18208" @@ -11138,7 +11229,7 @@ create-react-class@^15.5.2: loose-envify "^1.3.1" object-assign "^4.1.1" -create-react-context@0.3.0, create-react-context@^0.3.0: +create-react-context@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.3.0.tgz#546dede9dc422def0d3fc2fe03afe0bc0f4f7d8c" integrity sha512-dNldIoSuNSvlTJ7slIKC/ZFGKexBMBrrcc+TTe1NdmROnaASuLPvqpwj9v4XS4uXZ8+YPu0sNmShX2rXI5LNsw== @@ -11163,15 +11254,6 @@ cross-env@^6.0.3: dependencies: cross-spawn "^7.0.0" -cross-spawn@7.0.3, cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - cross-spawn@^6.0.0, cross-spawn@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -11183,6 +11265,15 @@ cross-spawn@^6.0.0, cross-spawn@^6.0.5: shebang-command "^1.2.0" which "^1.2.9" +cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + crypt@~0.0.1: version "0.0.2" resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" @@ -12433,14 +12524,6 @@ detect-node@2.1.0, detect-node@^2.0.4, detect-node@^2.1.0: resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== -detect-port-alt@1.1.6: - version "1.1.6" - resolved "https://registry.yarnpkg.com/detect-port-alt/-/detect-port-alt-1.1.6.tgz#24707deabe932d4a3cf621302027c2b266568275" - integrity sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q== - dependencies: - address "^1.0.1" - debug "^2.6.0" - detect-port@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/detect-port/-/detect-port-1.3.0.tgz#d9c40e9accadd4df5cac6a782aefd014d573d1f1" @@ -12754,35 +12837,16 @@ dot-prop@^5.2.0: dependencies: is-obj "^2.0.0" -dotenv-defaults@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/dotenv-defaults/-/dotenv-defaults-1.0.2.tgz#441cf5f067653fca4bbdce9dd3b803f6f84c585d" - integrity sha512-iXFvHtXl/hZPiFj++1hBg4lbKwGM+t/GlvELDnRtOFdjXyWP7mubkVr+eZGWG62kdsbulXAef6v/j6kiWc/xGA== - dependencies: - dotenv "^6.2.0" - dotenv-expand@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0" integrity sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA== -dotenv-webpack@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/dotenv-webpack/-/dotenv-webpack-1.8.0.tgz#7ca79cef2497dd4079d43e81e0796bc9d0f68a5e" - integrity sha512-o8pq6NLBehtrqA8Jv8jFQNtG9nhRtVqmoD4yWbgUyoU3+9WBlPe+c2EAiaJok9RB28QvrWvdWLZGeTT5aATDMg== - dependencies: - dotenv-defaults "^1.0.2" - dotenv@^16.0.0: version "16.0.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.0.tgz#c619001253be89ebb638d027b609c75c26e47411" integrity sha512-qD9WU0MPM4SWLPJy/r2Be+2WgQj8plChsyrCNQzW/0WjvcJQiKQJ9mH3ZgB3fxbUUxgc/11ZJ0Fi5KiimWGz2Q== -dotenv@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-6.2.0.tgz#941c0410535d942c8becf28d3f357dbd9d476064" - integrity sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w== - dotenv@^8.0.0, dotenv@^8.1.0: version "8.2.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" @@ -12976,7 +13040,7 @@ elasticsearch@^16.4.0: chalk "^1.0.0" lodash "^4.17.10" -electron-to-chromium@^1.3.564, electron-to-chromium@^1.4.17: +electron-to-chromium@^1.4.17: version "1.4.66" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.66.tgz#d7453d363dcd7b06ed1757adcde34d724e27b367" integrity sha512-f1RXFMsvwufWLwYUxTiP7HmjprKXrqEWHiQkjAYa9DJeVIlZk5v8gBGcaV+FhtXLly6C1OTVzQY+2UQrACiLlg== @@ -13403,7 +13467,7 @@ es6-weak-map@^2.0.1, es6-weak-map@^2.0.2: es6-iterator "^2.0.1" es6-symbol "^3.1.1" -escalade@^3.0.2, escalade@^3.1.1: +escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== @@ -13418,11 +13482,6 @@ escape-html@^1.0.3, escape-html@~1.0.3: resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= -escape-string-regexp@2.0.0, escape-string-regexp@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" - integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== - escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" @@ -13433,6 +13492,11 @@ escape-string-regexp@^1.0.0, escape-string-regexp@^1.0.2, escape-string-regexp@^ resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= +escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + escodegen@^1.11.0, escodegen@^1.11.1, escodegen@^1.14.1: version "1.14.3" resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503" @@ -14452,11 +14516,6 @@ filelist@^1.0.1: dependencies: minimatch "^3.0.4" -filesize@6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/filesize/-/filesize-6.1.0.tgz#e81bdaa780e2451d714d71c0d7a4f3238d37ad00" - integrity sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg== - fill-range@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" @@ -14510,14 +14569,6 @@ find-root@^1.1.0: resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng== -find-up@4.1.0, find-up@^4.0.0, find-up@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" - integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== - dependencies: - locate-path "^5.0.0" - path-exists "^4.0.0" - find-up@5.0.0, find-up@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" @@ -14548,6 +14599,14 @@ find-up@^3.0.0: dependencies: locate-path "^3.0.0" +find-up@^4.0.0, find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + findup-sync@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-2.0.0.tgz#9326b1488c22d1a6088650a86901b2d9a90a2cbc" @@ -14705,7 +14764,7 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= -fork-ts-checker-webpack-plugin@4.1.6, fork-ts-checker-webpack-plugin@^4.1.6: +fork-ts-checker-webpack-plugin@^4.1.6: version "4.1.6" resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-4.1.6.tgz#5055c703febcf37fa06405d400c122b905167fc5" integrity sha512-DUxuQaKoqfNne8iikd14SAkh5uw4+8vNifp6gmA73yYNS6ywLIWSLD/n/mBzHQRpW3J7rbATEakmiA8JvkTyZw== @@ -14952,6 +15011,21 @@ fuse.js@^3.6.1: resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.6.1.tgz#7de85fdd6e1b3377c23ce010892656385fd9b10c" integrity sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw== +gauge@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" + integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.2" + console-control-strings "^1.0.0" + has-unicode "^2.0.1" + object-assign "^4.1.1" + signal-exit "^3.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.2" + gauge@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/gauge/-/gauge-4.0.2.tgz#c3777652f542b6ef62797246e8c7caddecb32cc7" @@ -15199,21 +15273,6 @@ glob-all@^3.2.1: glob "^7.1.2" yargs "^15.3.1" -glob-base@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" - integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q= - dependencies: - glob-parent "^2.0.0" - is-glob "^2.0.0" - -glob-parent@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28" - integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg= - dependencies: - is-glob "^2.0.0" - glob-parent@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" @@ -15257,7 +15316,7 @@ glob-to-regexp@^0.3.0: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs= -glob-to-regexp@^0.4.0: +glob-to-regexp@^0.4.0, glob-to-regexp@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== @@ -15316,13 +15375,6 @@ global-dirs@^3.0.0: dependencies: ini "2.0.0" -global-modules@2.0.0, global-modules@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" - integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== - dependencies: - global-prefix "^3.0.0" - global-modules@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea" @@ -15332,6 +15384,13 @@ global-modules@^1.0.0: is-windows "^1.0.1" resolve-dir "^1.0.0" +global-modules@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" + integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== + dependencies: + global-prefix "^3.0.0" + global-prefix@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe" @@ -15401,18 +15460,6 @@ globby@10.0.0: merge2 "^1.2.3" slash "^3.0.0" -globby@11.0.1: - version "11.0.1" - resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.1.tgz#9a2bf107a068f3ffeabc49ad702c79ede8cfd357" - integrity sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ== - dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.1.1" - ignore "^5.1.4" - merge2 "^1.3.0" - slash "^3.0.0" - globby@11.0.4, globby@^11.0.1, globby@^11.0.2, globby@^11.0.3, globby@^11.0.4: version "11.0.4" resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.4.tgz#2cbaff77c2f2a62e71e9b2813a67b97a3a3001a5" @@ -15711,14 +15758,6 @@ gulplog@^1.0.0: dependencies: glogg "^1.0.0" -gzip-size@5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-5.1.1.tgz#cb9bee692f87c0612b232840a873904e4c135274" - integrity sha512-FNHi6mmoHvs1mxZAds4PpdCS6QG8B4C1krxJsMutgxl5t3+GlRTzzI3NEkifXx2pVsOvJdOGSmIgDhQ55FwdPA== - dependencies: - 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" @@ -16042,6 +16081,13 @@ highlight.js@^10.1.1, highlight.js@^10.4.1, highlight.js@~10.4.0: resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.4.1.tgz#d48fbcf4a9971c4361b3f95f302747afe19dbad0" integrity sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg== +history@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/history/-/history-5.0.0.tgz#0cabbb6c4bbf835addb874f8259f6d25101efd08" + integrity sha512-3NyRMKIiFSJmIPdq7FxkNMJkQ7ZEtVblOQ38VtKaA0zZMW1Eo6Q6W8oDKEflr1kNNTItSnk4JMCO1deeSgbLLg== + dependencies: + "@babel/runtime" "^7.7.6" + history@^4.9.0: version "4.9.0" resolved "https://registry.yarnpkg.com/history/-/history-4.9.0.tgz#84587c2068039ead8af769e9d6a6860a14fa1bca" @@ -16054,6 +16100,13 @@ history@^4.9.0: tiny-warning "^1.0.0" value-equal "^0.4.0" +history@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b" + integrity sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ== + dependencies: + "@babel/runtime" "^7.7.6" + hjson@3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/hjson/-/hjson-3.2.1.tgz#20de41dc87fc9a10d1557d0230b0e02afb1b09ac" @@ -16140,11 +16193,16 @@ html-encoding-sniffer@^2.0.1: dependencies: whatwg-encoding "^1.0.5" -html-entities@^1.2.0, html-entities@^1.2.1, html-entities@^1.3.1: +html-entities@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.3.1.tgz#fb9a1a4b5b14c5daba82d3e34c6ae4fe701a0e44" integrity sha512-rhE/4Z3hIhzHAUKbW8jVcCyuT5oJCXXqhN/6mXXVCpzTmvJnoH2HL/bt3EZ6p55jbFJBeAe1ZNpL5BugLujxNA== +html-entities@^2.1.0: + version "2.3.3" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.3.tgz#117d7626bece327fc8baace8868fa6f5ef856e46" + integrity sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA== + html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" @@ -16504,11 +16562,6 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= -immer@8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656" - integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA== - immer@^9.0.1, immer@^9.0.6: version "9.0.6" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.6.tgz#7a96bf2674d06c8143e327cbf73539388ddf1a73" @@ -16790,7 +16843,7 @@ intl@^1.2.5: resolved "https://registry.yarnpkg.com/intl/-/intl-1.2.5.tgz#82244a2190c4e419f8371f5aa34daa3420e2abde" integrity sha1-giRKIZDE5Bn4Nx9ao02qNCDiq94= -invariant@^2.1.0, invariant@^2.1.1, invariant@^2.2.2, invariant@^2.2.3, invariant@^2.2.4: +invariant@^2.1.0, invariant@^2.1.1, invariant@^2.2.2, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== @@ -17041,11 +17094,6 @@ is-extendable@^1.0.1: dependencies: is-plain-object "^2.0.4" -is-extglob@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" - integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA= - is-extglob@^2.1.0, is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -17090,13 +17138,6 @@ is-generator-function@^1.0.7: resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.7.tgz#d2132e529bb0000a7f80794d4bdf5cd5e5813522" integrity sha512-YZc5EwyO4f2kWCax7oegfuSr9mFz1ZvieNYBEjmukLxgXfBUbxAWGVF7GZf0zidYtoBl3WvC07YK0wT76a+Rtw== -is-glob@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" - integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM= - dependencies: - is-extglob "^1.0.0" - is-glob@^3.0.0, is-glob@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" @@ -17323,11 +17364,6 @@ is-resolvable@^1.0.0: resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" integrity sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg== -is-root@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-root/-/is-root-2.1.0.tgz#809e18129cf1129644302a4f8544035d51984a9c" - integrity sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg== - is-set@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.1.tgz#d1604afdab1724986d30091575f54945da7e5f43" @@ -19671,14 +19707,6 @@ markdown-table@^2.0.0: dependencies: repeat-string "^1.0.0" -markdown-to-jsx@^6.11.4: - version "6.11.4" - resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-6.11.4.tgz#b4528b1ab668aef7fe61c1535c27e837819392c5" - integrity sha512-3lRCD5Sh+tfA52iGgfs/XZiw33f7fFX9Bn55aNnVNUd2GzLDkOWyKYYD8Yju2B1Vn+feiEdgJs8T6Tg0xNokPw== - dependencies: - prop-types "^15.6.2" - unquote "^1.1.0" - markdown-to-jsx@^7.1.3: version "7.1.3" resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.1.3.tgz#f00bae66c0abe7dd2d274123f84cb6bd2a2c7c6a" @@ -20538,7 +20566,7 @@ nano-time@1.0.0: dependencies: big-integer "^1.6.16" -nanoid@3.2.0: +nanoid@3.2.0, nanoid@^3.1.23: version "3.2.0" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c" integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA== @@ -20566,13 +20594,6 @@ napi-build-utils@^1.0.1: resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== -native-url@^0.2.6: - version "0.2.6" - resolved "https://registry.yarnpkg.com/native-url/-/native-url-0.2.6.tgz#ca1258f5ace169c716ff44eccbddb674e10399ae" - integrity sha512-k4bDC87WtgrdD362gZz6zoiXQrl40kYlBmpfmSjwRO1VU0V5ccwJTlxuE72F6m3V0vc1xOf6n3UCP9QyerRqmA== - dependencies: - querystring "^0.2.0" - natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -20853,11 +20874,6 @@ node-preload@^0.2.1: dependencies: process-on-spawn "^1.0.0" -node-releases@^1.1.61: - version "1.1.61" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.61.tgz#707b0fca9ce4e11783612ba4a2fcba09047af16e" - integrity sha512-DD5vebQLg8jLCOzwupn954fbIiZht05DAZs0k2u8NStSe6h9XdsuIQL8hSRKYiU8WUQRznmSDrKGbv3ObOmC7g== - node-releases@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.1.tgz#3d1d395f204f1f2f29a54358b9fb678765ad2fc5" @@ -21022,6 +21038,16 @@ npmlog@^4.0.0, npmlog@^4.0.1, npmlog@^4.1.2: gauge "~2.7.3" set-blocking "~2.0.0" +npmlog@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" + integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== + dependencies: + are-we-there-yet "^2.0.0" + console-control-strings "^1.1.0" + gauge "^3.0.0" + set-blocking "^2.0.0" + npmlog@^6.0.0: version "6.0.1" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-6.0.1.tgz#06f1344a174c06e8de9c6c70834cfba2964bba17" @@ -21344,7 +21370,7 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" -open@^7.0.2, open@^7.0.3: +open@^7.0.3: version "7.1.0" resolved "https://registry.yarnpkg.com/open/-/open-7.1.0.tgz#68865f7d3cb238520fa1225a63cf28bcf8368a1c" integrity sha512-lLPI5KgOwEYCDKXf4np7y1PBEkj7HYIyP2DY8mVDRnx0VIIu6bNrRB0R66TuO7Mack6EnTNLm4uvcl1UoklTpA== @@ -22211,13 +22237,6 @@ pkg-dir@^5.0.0: dependencies: find-up "^5.0.0" -pkg-up@3.1.0, pkg-up@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5" - integrity sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA== - dependencies: - find-up "^3.0.0" - pkg-up@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-2.0.0.tgz#c819ac728059a461cab1c3889a2be3c49a004d7f" @@ -22225,6 +22244,13 @@ pkg-up@^2.0.0: dependencies: find-up "^2.1.0" +pkg-up@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5" + integrity sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA== + dependencies: + find-up "^3.0.0" + platform@^1.3.0: version "1.3.5" resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.5.tgz#fb6958c696e07e2918d2eeda0f0bc9448d733444" @@ -22857,16 +22883,16 @@ prettier-linter-helpers@^1.0.0: dependencies: fast-diff "^1.1.2" +"prettier@>=2.2.1 <=2.3.0": + version "2.2.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5" + integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q== + prettier@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.6.2.tgz#e26d71a18a74c3d0f0597f55f01fb6c06c206032" integrity sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew== -prettier@~2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5" - integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q== - pretty-bytes@^5.6.0: version "5.6.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" @@ -23038,7 +23064,7 @@ promise@^7.1.1: dependencies: asap "~2.0.3" -prompts@2.4.0, prompts@^2.0.1: +prompts@^2.0.1: version "2.4.0" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.0.tgz#4aa5de0723a231d1ee9121c40fdf663df73f61d7" integrity sha512-awZAKrk3vN6CroQukBL+R9051a4R3zCZBlJm/HBfrSZ8iTpYix3VX1vU4mveiLpiwmOJT4wokTF9m6HUk4KqWQ== @@ -23547,36 +23573,6 @@ react-colorful@^5.1.2: resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.5.1.tgz#29d9c4e496f2ca784dd2bb5053a3a4340cfaf784" integrity sha512-M1TJH2X3RXEt12sWkpa6hLc/bbYS0H6F4rIqjQZ+RxNBstpY67d9TrFXtqdZwhpmBXcCwEi7stKqFue3ZRkiOg== -react-dev-utils@^11.0.3: - version "11.0.4" - resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-11.0.4.tgz#a7ccb60257a1ca2e0efe7a83e38e6700d17aa37a" - integrity sha512-dx0LvIGHcOPtKbeiSUM4jqpBl3TcY7CDjZdfOIcKeznE7BWr9dg0iPG90G5yfVQ+p/rGNMXdbfStvzQZEVEi4A== - dependencies: - "@babel/code-frame" "7.10.4" - address "1.1.2" - browserslist "4.14.2" - chalk "2.4.2" - cross-spawn "7.0.3" - detect-port-alt "1.1.6" - escape-string-regexp "2.0.0" - filesize "6.1.0" - find-up "4.1.0" - fork-ts-checker-webpack-plugin "4.1.6" - global-modules "2.0.0" - globby "11.0.1" - gzip-size "5.1.1" - immer "8.0.1" - is-root "2.1.0" - loader-utils "2.0.0" - open "^7.0.2" - pkg-up "3.1.0" - prompts "2.4.0" - react-error-overlay "^6.0.9" - recursive-readdir "2.2.2" - shell-quote "1.7.2" - strip-ansi "6.0.0" - text-table "0.2.0" - react-docgen-typescript@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/react-docgen-typescript/-/react-docgen-typescript-2.1.1.tgz#c9f9ccb1fa67e0f4caf3b12f2a07512a201c2dcf" @@ -23596,15 +23592,15 @@ react-docgen@^5.0.0: node-dir "^0.1.10" strip-indent "^3.0.0" -react-dom@^16.12.0: - version "16.12.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.12.0.tgz#0da4b714b8d13c2038c9396b54a92baea633fe11" - integrity sha512-LMxFfAGrcS3kETtQaCkTKjMiifahaMySFDn71fZUNpPHZQEzmk/GiAeIT8JSOrHB23fnuCOMruL2a8NYlw+8Gw== +react-dom@^16.14.0: + version "16.14.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" + integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" prop-types "^15.6.2" - scheduler "^0.18.0" + scheduler "^0.19.1" react-draggable@3.x, "react-draggable@^2.2.6 || ^3.0.3": version "3.0.5" @@ -23639,7 +23635,7 @@ react-dropzone@^4.2.9: attr-accept "^1.1.3" prop-types "^15.5.7" -react-element-to-jsx-string@^14.3.2, react-element-to-jsx-string@^14.3.4: +react-element-to-jsx-string@^14.3.4: version "14.3.4" resolved "https://registry.yarnpkg.com/react-element-to-jsx-string/-/react-element-to-jsx-string-14.3.4.tgz#709125bc72f06800b68f9f4db485f2c7d31218a8" integrity sha512-t4ZwvV6vwNxzujDQ+37bspnLwA4JlgUPWhLjBJWsNIDceAf6ZKUTCjdm08cN6WeZ5pTMKiCJkmAYnpmR4Bm+dg== @@ -23655,11 +23651,6 @@ react-error-boundary@^3.1.0: dependencies: "@babel/runtime" "^7.12.5" -react-error-overlay@^6.0.9: - version "6.0.9" - resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a" - integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew== - react-fast-compare@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" @@ -23751,21 +23742,16 @@ react-intl@^2.8.0: intl-relativeformat "^2.1.0" invariant "^2.1.1" -react-is@17.0.2, react-is@^17.0.2: +react-is@17.0.2, react-is@^17.0.1, react-is@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-is@^16.12.0, react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: +react-is@^16.12.0, react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^17.0.1: - version "17.0.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" - integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== - react-lib-adler32@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/react-lib-adler32/-/react-lib-adler32-1.0.3.tgz#63df1aed274eabcc1c5067077ea281ec30888ba7" @@ -23873,10 +23859,10 @@ react-redux@^7.1.0, react-redux@^7.2.0: prop-types "^15.7.2" react-is "^16.9.0" -react-refresh@^0.8.3: - version "0.8.3" - resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" - integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg== +react-refresh@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046" + integrity sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A== react-remove-scroll-bar@^2.1.0: version "2.1.0" @@ -23949,6 +23935,14 @@ react-router-dom@^5.2.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-router-dom@^6.0.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.3.0.tgz#a0216da813454e521905b5fa55e0e5176123f43d" + integrity sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw== + dependencies: + history "^5.2.0" + react-router "6.3.0" + react-router-redux@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/react-router-redux/-/react-router-redux-4.0.8.tgz#227403596b5151e182377dab835b5d45f0f8054e" @@ -23970,6 +23964,13 @@ react-router@5.2.0, react-router@^5.2.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-router@6.3.0, react-router@^6.0.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" + integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== + dependencies: + history "^5.2.0" + react-select@^2.4.4: version "2.4.4" resolved "https://registry.yarnpkg.com/react-select/-/react-select-2.4.4.tgz#ba72468ef1060c7d46fbb862b0748f96491f1f73" @@ -24057,15 +24058,15 @@ react-syntax-highlighter@^13.5.3, react-syntax-highlighter@^15.3.1: prismjs "^1.22.0" refractor "^3.2.0" -react-test-renderer@^16.0.0-0, react-test-renderer@^16.12.0, "react-test-renderer@^16.8.0 || ^17.0.0": - version "16.12.0" - resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.12.0.tgz#11417ffda579306d4e841a794d32140f3da1b43f" - integrity sha512-Vj/teSqt2oayaWxkbhQ6gKis+t5JrknXfPVo+aIJ8QwYAqMPH77uptOdrlphyxl8eQI/rtkOYg86i/UWkpFu0w== +react-test-renderer@^16.0.0-0, react-test-renderer@^16.14.0, "react-test-renderer@^16.8.0 || ^17.0.0": + version "16.14.0" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.14.0.tgz#e98360087348e260c56d4fe2315e970480c228ae" + integrity sha512-L8yPjqPE5CZO6rKsKXRO/rVPiaCOy0tQQJbC+UjPNlobl5mad59lvPjwFsQHTvL03caVDIVr9x9/OSgDe6I5Eg== dependencies: object-assign "^4.1.1" prop-types "^15.6.2" react-is "^16.8.6" - scheduler "^0.18.0" + scheduler "^0.19.1" react-textarea-autosize@^8.3.0: version "8.3.3" @@ -24182,10 +24183,10 @@ react-window@^1.8.6: "@babel/runtime" "^7.0.0" memoize-one ">=3.1.1 <6" -react@^16.12.0: - version "16.12.0" - resolved "https://registry.yarnpkg.com/react/-/react-16.12.0.tgz#0c0a9c6a142429e3614834d5a778e18aa78a0b83" - integrity sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA== +react@^16.14.0: + version "16.14.0" + resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" + integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -24392,13 +24393,6 @@ recompose@^0.26.0: hoist-non-react-statics "^2.3.1" symbol-observable "^1.0.4" -recursive-readdir@2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.2.2.tgz#9946fb3274e1628de6e36b2f6714953b4845094f" - integrity sha512-nRCcW9Sj7NuZwa2XvH9co8NPeXUBhZP7CRKJtU+cS6PW9FpCIFoI5ib0NT1ZrbNuPoRy0ylyCaUL8Gih4LSyFg== - dependencies: - minimatch "3.0.4" - redent@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" @@ -25460,10 +25454,10 @@ saxes@^5.0.0: dependencies: xmlchars "^2.2.0" -scheduler@^0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.18.0.tgz#5901ad6659bc1d8f3fdaf36eb7a67b0d6746b1c4" - integrity sha512-agTSHR1Nbfi6ulI0kYNK0203joW2Y5W4po4l+v03tOoiJKpTBbxpNhWDvqc/4IcOw+KLmSiQLTasZ4cab2/UWQ== +scheduler@^0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" + integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -25833,7 +25827,7 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -shell-quote@1.7.2, shell-quote@^1.4.2, shell-quote@^1.6.1: +shell-quote@^1.4.2, shell-quote@^1.6.1: version "1.7.2" resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2" integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg== @@ -26543,17 +26537,6 @@ store2@^2.12.0: resolved "https://registry.yarnpkg.com/store2/-/store2-2.12.0.tgz#e1f1b7e1a59b6083b2596a8d067f6ee88fd4d3cf" integrity sha512-7t+/wpKLanLzSnQPX8WAcuLCCeuSHoWdQuh9SB3xD0kNOM38DNf+0Oa+wmvxmYueRzkmh6IcdKFtvTa+ecgPDw== -storybook-addon-outline@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/storybook-addon-outline/-/storybook-addon-outline-1.4.1.tgz#0a1b262b9c65df43fc63308a1fdbd4283c3d9458" - integrity sha512-Qvv9X86CoONbi+kYY78zQcTGmCgFaewYnOVR6WL7aOFJoW7TrLiIc/O4hH5X9PsEPZFqjfXEPUPENWVUQim6yw== - dependencies: - "@storybook/addons" "^6.3.0" - "@storybook/api" "^6.3.0" - "@storybook/components" "^6.3.0" - "@storybook/core-events" "^6.3.0" - ts-dedent "^2.1.1" - stream-browserify@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b" @@ -26730,7 +26713,7 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" -string-width@^4.2.3: +string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -26835,13 +26818,6 @@ strip-ansi@*, strip-ansi@5.2.0, strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi dependencies: ansi-regex "^4.1.0" -strip-ansi@6.0.0, strip-ansi@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" - integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== - dependencies: - ansi-regex "^5.0.0" - strip-ansi@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-0.3.0.tgz#25f48ea22ca79187f3174a4db8759347bb126220" @@ -26863,7 +26839,7 @@ strip-ansi@^4.0.0: dependencies: ansi-regex "^3.0.0" -strip-ansi@^6.0.1: +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -27251,6 +27227,11 @@ symbol.prototype.description@^1.0.0: dependencies: has-symbols "^1.0.0" +synchronous-promise@^2.0.15: + version "2.0.15" + resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.15.tgz#07ca1822b9de0001f5ff73595f3d08c4f720eb8e" + integrity sha512-k8uzYIkIVwmT+TcglpdN50pS2y1BDcUnBPK9iJeGu0Pl1lOI8pD6wtzgw91Pjpe+RxtTncw32tLxs/R0yNL2Mg== + syntax-error@^1.1.1: version "1.4.0" resolved "https://registry.yarnpkg.com/syntax-error/-/syntax-error-1.4.0.tgz#2d9d4ff5c064acb711594a3e3b95054ad51d907c" @@ -27396,7 +27377,7 @@ tcp-port-used@^1.0.1: debug "4.1.0" is2 "2.0.1" -telejson@^5.3.2: +telejson@^5.3.2, telejson@^5.3.3: version "5.3.3" resolved "https://registry.yarnpkg.com/telejson/-/telejson-5.3.3.tgz#fa8ca84543e336576d8734123876a9f02bf41d2e" integrity sha512-PjqkJZpzEggA9TBpVtJi1LVptP7tYtXB6rEubwlHap76AMjzvOdKX41CxyaW7ahhzDU1aftXnMCx5kAPDZTQBA== @@ -27424,11 +27405,6 @@ tempy@^0.3.0: type-fest "^0.3.1" unique-string "^1.0.0" -term-size@^2.1.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.0.tgz#1f16adedfe9bdc18800e1776821734086fcc6753" - integrity sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw== - terminal-link@^2.0.0, terminal-link@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994" @@ -27513,7 +27489,7 @@ text-hex@1.0.x: resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== -text-table@0.2.0, text-table@^0.2.0: +text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= @@ -27908,7 +27884,7 @@ ts-debounce@^3.0.0: resolved "https://registry.yarnpkg.com/ts-debounce/-/ts-debounce-3.0.0.tgz#9beedf59c04de3b5bef8ff28bd6885624df357be" integrity sha512-7jiRWgN4/8IdvCxbIwnwg2W0bbYFBH6BxFqBjMKk442t7+liF2Z1H6AUCcl8e/pD93GjPru+axeiJwFmRww1WQ== -ts-dedent@^2.0.0, ts-dedent@^2.1.1: +ts-dedent@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5" integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ== @@ -28593,7 +28569,7 @@ unpipe@1.0.0, unpipe@~1.0.0: resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= -unquote@^1.1.0, unquote@~1.1.1: +unquote@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/unquote/-/unquote-1.1.1.tgz#8fded7324ec6e88a0ff8b905e7c098cdc086d544" integrity sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ= @@ -29541,6 +29517,14 @@ watchpack@^1.6.0, watchpack@^1.7.4: chokidar "^3.4.1" watchpack-chokidar2 "^2.0.0" +watchpack@^2.2.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25" + integrity sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA== + dependencies: + glob-to-regexp "^0.4.1" + graceful-fs "^4.1.2" + wbuf@^1.1.0, wbuf@^1.7.3: version "1.7.3" resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df" @@ -29682,15 +29666,15 @@ webpack-filter-warnings-plugin@^1.2.1: resolved "https://registry.yarnpkg.com/webpack-filter-warnings-plugin/-/webpack-filter-warnings-plugin-1.2.1.tgz#dc61521cf4f9b4a336fbc89108a75ae1da951cdb" integrity sha512-Ez6ytc9IseDMLPo0qCuNNYzgtUl8NovOqjIq4uAU8LTD4uoa1w1KpZyyzFtLTEMZpkkOkLfL9eN+KGYdk1Qtwg== -webpack-hot-middleware@^2.25.0: - version "2.25.0" - resolved "https://registry.yarnpkg.com/webpack-hot-middleware/-/webpack-hot-middleware-2.25.0.tgz#4528a0a63ec37f8f8ef565cf9e534d57d09fe706" - integrity sha512-xs5dPOrGPCzuRXNi8F6rwhawWvQQkeli5Ro48PRuQh8pYPCPmNnltP9itiUPT4xI8oW+y0m59lyyeQk54s5VgA== +webpack-hot-middleware@^2.25.1: + version "2.25.1" + resolved "https://registry.yarnpkg.com/webpack-hot-middleware/-/webpack-hot-middleware-2.25.1.tgz#581f59edf0781743f4ca4c200fd32c9266c6cf7c" + integrity sha512-Koh0KyU/RPYwel/khxbsDz9ibDivmUbrRuKSSQvW42KSDdO4w23WI3SkHpSUKHE76LrFnnM/L7JCrpBwu8AXYw== dependencies: - ansi-html "0.0.7" - html-entities "^1.2.0" + ansi-html-community "0.0.8" + html-entities "^2.1.0" querystring "^0.2.0" - strip-ansi "^3.0.0" + strip-ansi "^6.0.0" webpack-log@^2.0.0: version "2.0.0" @@ -29877,7 +29861,7 @@ which@^1.2.14, which@^1.2.9, which@^1.3.1: dependencies: isexe "^2.0.0" -wide-align@^1.1.0, wide-align@^1.1.5: +wide-align@^1.1.0, wide-align@^1.1.2, wide-align@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== @@ -30123,6 +30107,11 @@ ws@^7.3.1: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.6.tgz#e59fc509fb15ddfb65487ee9765c5a51dec5fe7b" integrity sha512-6GLgCqo2cy2A2rjCNFlxQS6ZljG/coZfZXclldI8FB/1G3CCI36Zd8xy2HrFVACi8tfk5XrgLQEk+P0Tnz9UcA== +ws@^8.2.3: + version "8.5.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f" + integrity sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg== + 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 5d5603a57237d8fe9cf186916c713b9ddddf039d Mon Sep 17 00:00:00 2001 From: Yash Tewari Date: Thu, 19 May 2022 13:12:35 +0300 Subject: [PATCH 17/37] Add cloudbeat index to agent policy defaults. (#132452) * Add cloudbeat index to agent policy defaults. The associated indices are used by filebeat to send cloudbeat logs and metrics to Kibana. * Commit using elastic.co Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Yash Tewari --- x-pack/plugins/fleet/common/constants/agent_policy.ts | 1 + .../__snapshots__/monitoring_permissions.test.ts.snap | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/x-pack/plugins/fleet/common/constants/agent_policy.ts b/x-pack/plugins/fleet/common/constants/agent_policy.ts index 95078d8ead84f..316c66d2c75d6 100644 --- a/x-pack/plugins/fleet/common/constants/agent_policy.ts +++ b/x-pack/plugins/fleet/common/constants/agent_policy.ts @@ -24,4 +24,5 @@ export const AGENT_POLICY_DEFAULT_MONITORING_DATASETS = [ 'elastic_agent.endpoint_security', 'elastic_agent.auditbeat', 'elastic_agent.heartbeat', + 'elastic_agent.cloudbeat', ]; diff --git a/x-pack/plugins/fleet/server/services/agent_policies/__snapshots__/monitoring_permissions.test.ts.snap b/x-pack/plugins/fleet/server/services/agent_policies/__snapshots__/monitoring_permissions.test.ts.snap index a54d4beb6c041..d46e7a92475ac 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/__snapshots__/monitoring_permissions.test.ts.snap +++ b/x-pack/plugins/fleet/server/services/agent_policies/__snapshots__/monitoring_permissions.test.ts.snap @@ -116,6 +116,7 @@ Object { "logs-elastic_agent.endpoint_security-testnamespace123", "logs-elastic_agent.auditbeat-testnamespace123", "logs-elastic_agent.heartbeat-testnamespace123", + "logs-elastic_agent.cloudbeat-testnamespace123", "metrics-elastic_agent-testnamespace123", "metrics-elastic_agent.elastic_agent-testnamespace123", "metrics-elastic_agent.apm_server-testnamespace123", @@ -127,6 +128,7 @@ Object { "metrics-elastic_agent.endpoint_security-testnamespace123", "metrics-elastic_agent.auditbeat-testnamespace123", "metrics-elastic_agent.heartbeat-testnamespace123", + "metrics-elastic_agent.cloudbeat-testnamespace123", ], "privileges": Array [ "auto_configure", @@ -155,6 +157,7 @@ Object { "logs-elastic_agent.endpoint_security-testnamespace123", "logs-elastic_agent.auditbeat-testnamespace123", "logs-elastic_agent.heartbeat-testnamespace123", + "logs-elastic_agent.cloudbeat-testnamespace123", ], "privileges": Array [ "auto_configure", @@ -183,6 +186,7 @@ Object { "metrics-elastic_agent.endpoint_security-testnamespace123", "metrics-elastic_agent.auditbeat-testnamespace123", "metrics-elastic_agent.heartbeat-testnamespace123", + "metrics-elastic_agent.cloudbeat-testnamespace123", ], "privileges": Array [ "auto_configure", From 895e425c652ef9b683b994fb50eab0ac2feaf55b Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 19 May 2022 07:42:03 -0400 Subject: [PATCH 18/37] Fix flaky user activation/deactivation tests (#132465) --- x-pack/test/functional/apps/security/users.ts | 3 +-- .../functional/page_objects/security_page.ts | 22 +++++++++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/x-pack/test/functional/apps/security/users.ts b/x-pack/test/functional/apps/security/users.ts index 67be1e7ddecce..8448750bf1ccd 100644 --- a/x-pack/test/functional/apps/security/users.ts +++ b/x-pack/test/functional/apps/security/users.ts @@ -202,8 +202,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/118728 - describe.skip('Deactivate/Activate user', () => { + describe('Deactivate/Activate user', () => { it('deactivates user when confirming', async () => { await PageObjects.security.deactivatesUser(optionalUser); const users = keyBy(await PageObjects.security.getElasticsearchUsers(), 'username'); diff --git a/x-pack/test/functional/page_objects/security_page.ts b/x-pack/test/functional/page_objects/security_page.ts index 8731a3a3f5459..508fb7106948a 100644 --- a/x-pack/test/functional/page_objects/security_page.ts +++ b/x-pack/test/functional/page_objects/security_page.ts @@ -37,6 +37,7 @@ export class SecurityPageObject extends FtrService { private readonly common = this.ctx.getPageObject('common'); private readonly header = this.ctx.getPageObject('header'); private readonly monacoEditor = this.ctx.getService('monacoEditor'); + private readonly es = this.ctx.getService('es'); public loginPage = Object.freeze({ login: async (username?: string, password?: string, options: LoginOptions = {}) => { @@ -350,13 +351,6 @@ export class SecurityPageObject extends FtrService { const btn = await this.find.byButtonText(privilege); await btn.click(); - - // const options = await this.find.byCssSelector(`.euiFilterSelectItem`); - // Object.entries(options).forEach(([key, prop]) => { - // console.log({ key, proto: prop.__proto__ }); - // }); - - // await options.click(); } async assignRoleToUser(role: string) { @@ -516,6 +510,13 @@ export class SecurityPageObject extends FtrService { await this.clickUserByUserName(user.username ?? ''); await this.testSubjects.click('editUserDisableUserButton'); await this.testSubjects.click('confirmModalConfirmButton'); + await this.testSubjects.missingOrFail('confirmModalConfirmButton'); + if (user.username) { + await this.retry.waitForWithTimeout('ES to acknowledge deactivation', 15000, async () => { + const userResponse = await this.es.security.getUser({ username: user.username }); + return userResponse[user.username!].enabled === false; + }); + } await this.submitUpdateUserForm(); } @@ -523,6 +524,13 @@ export class SecurityPageObject extends FtrService { await this.clickUserByUserName(user.username ?? ''); await this.testSubjects.click('editUserEnableUserButton'); await this.testSubjects.click('confirmModalConfirmButton'); + await this.testSubjects.missingOrFail('confirmModalConfirmButton'); + if (user.username) { + await this.retry.waitForWithTimeout('ES to acknowledge activation', 15000, async () => { + const userResponse = await this.es.security.getUser({ username: user.username }); + return userResponse[user.username!].enabled === true; + }); + } await this.submitUpdateUserForm(); } From 178773f816613f04484523fdc63a89ca55fa6e5c Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Thu, 19 May 2022 14:15:26 +0200 Subject: [PATCH 19/37] [Cases] Cases alerts table UI enhancements (#132417) --- .../case_view/case_view_page.test.tsx | 3 +- .../components/case_view/case_view_page.tsx | 26 +++++++++---- .../components/case_view_alerts.test.tsx | 14 +++++++ .../case_view/components/case_view_alerts.tsx | 19 +++++++++- .../components/case_view_alerts_empty.tsx | 23 ++++++++++++ .../components/case_view/translations.ts | 7 ++++ .../components/user_actions/comment/alert.tsx | 4 ++ .../user_actions/comment/comment.test.tsx | 7 +++- .../comment/show_alert_table_link.test.tsx | 37 +++++++++++++++++++ .../comment/show_alert_table_link.tsx | 34 +++++++++++++++++ .../components/user_actions/translations.ts | 7 ++++ .../containers/use_get_feature_ids.test.tsx | 26 ++++++++----- .../public/containers/use_get_feature_ids.tsx | 26 ++++++++++--- .../apps/cases/view_case.ts | 26 +++++++++++++ 14 files changed, 232 insertions(+), 27 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/case_view/components/case_view_alerts_empty.tsx create mode 100644 x-pack/plugins/cases/public/components/user_actions/comment/show_alert_table_link.test.tsx create mode 100644 x-pack/plugins/cases/public/components/user_actions/comment/show_alert_table_link.tsx diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx index fd0f7eebe0095..55b78ba23514a 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -585,8 +585,7 @@ describe('CaseViewPage', () => { appMockRender = createAppMockRenderer(); }); - // unskip when alerts tab is activated - it.skip('renders tabs correctly', async () => { + it('renders tabs correctly', async () => { const result = appMockRender.render(); await act(async () => { expect(result.getByTestId('case-view-tab-title-alerts')).toBeTruthy(); diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx index 0c6acee136f5c..98393e3081c7b 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx @@ -5,8 +5,9 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui'; +import { EuiBetaBadge, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import styled from 'styled-components'; import { Case, UpdateKey } from '../../../common/ui'; import { useCaseViewNavigation, useUrlParams } from '../../common/navigation'; import { useGetCaseMetrics } from '../../containers/use_get_case_metrics'; @@ -16,6 +17,7 @@ import { useCasesFeatures } from '../cases_context/use_cases_features'; import { CaseActionBar } from '../case_action_bar'; import { HeaderPage } from '../header_page'; import { EditableTitle } from '../header_page/editable_title'; +import { EXPERIMENTAL_DESC, EXPERIMENTAL_LABEL } from '../header_page/translations'; import { useTimelineContext } from '../timeline_context/use_timeline_context'; import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs'; import { WhitePageWrapperNoBorder } from '../wrappers'; @@ -26,10 +28,9 @@ import { ACTIVITY_TAB, ALERTS_TAB } from './translations'; import { CaseViewPageProps, CASE_VIEW_PAGE_TABS } from './types'; import { useOnUpdateField } from './use_on_update_field'; -// This hardcoded constant is left here intentionally -// as a way to hide a wip functionality -// that will be merge in the 8.3 release. -const ENABLE_ALERTS_TAB = true; +const ExperimentalBadge = styled(EuiBetaBadge)` + margin-left: 5px; +`; export const CaseViewPage = React.memo( ({ @@ -182,11 +183,22 @@ export const CaseViewPage = React.memo( /> ), }, - ...(features.alerts.enabled && ENABLE_ALERTS_TAB + ...(features.alerts.enabled ? [ { id: CASE_VIEW_PAGE_TABS.ALERTS, - name: ALERTS_TAB, + name: ( + <> + {ALERTS_TAB} + + + ), content: , }, ] diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx index 30d4636275674..9649ea013c02d 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx @@ -65,4 +65,18 @@ describe('Case View Page activity tab', () => { ); }); }); + + it('should show an empty prompt when the cases has no alerts', async () => { + const result = appMockRender.render( + + ); + await waitFor(async () => { + expect(result.getByTestId('caseViewAlertsEmpty')).toBeTruthy(); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx index 75da3fd3fe470..b1371b6d733b6 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx @@ -7,10 +7,12 @@ import React, { useMemo } from 'react'; +import { EuiFlexItem, EuiFlexGroup, EuiProgress } from '@elastic/eui'; import { Case } from '../../../../common'; import { useKibana } from '../../../common/lib/kibana'; import { getManualAlertIds, getRegistrationContextFromAlerts } from './helpers'; import { useGetFeatureIds } from '../../../containers/use_get_feature_ids'; +import { CaseViewAlertsEmpty } from './case_view_alerts_empty'; interface CaseViewAlertsProps { caseData: Case; @@ -31,7 +33,8 @@ export const CaseViewAlerts = ({ caseData }: CaseViewAlertsProps) => { [caseData.comments] ); - const alertFeatureIds = useGetFeatureIds(alertRegistrationContexts); + const { isLoading: isLoadingAlertFeatureIds, alertFeatureIds } = + useGetFeatureIds(alertRegistrationContexts); const alertStateProps = { alertsTableConfigurationRegistry: triggersActionsUi.alertsTableConfigurationRegistry, @@ -41,6 +44,18 @@ export const CaseViewAlerts = ({ caseData }: CaseViewAlertsProps) => { query: alertIdsQuery, }; - return <>{triggersActionsUi.getAlertsStateTable(alertStateProps)}; + if (alertIdsQuery.ids.values.length === 0) { + return ; + } + + return isLoadingAlertFeatureIds ? ( + + + + + + ) : ( + triggersActionsUi.getAlertsStateTable(alertStateProps) + ); }; CaseViewAlerts.displayName = 'CaseViewAlerts'; diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts_empty.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts_empty.tsx new file mode 100644 index 0000000000000..ce10dfe6adb62 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts_empty.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiEmptyPrompt } from '@elastic/eui'; +import React from 'react'; +import { ALERTS_EMPTY_DESCRIPTION } from '../translations'; + +export const CaseViewAlertsEmpty = () => { + return ( + {ALERTS_EMPTY_DESCRIPTION}

} + /> + ); +}; +CaseViewAlertsEmpty.displayName = 'CaseViewAlertsEmpty'; diff --git a/x-pack/plugins/cases/public/components/case_view/translations.ts b/x-pack/plugins/cases/public/components/case_view/translations.ts index 94c19165e515b..af418a1ae858d 100644 --- a/x-pack/plugins/cases/public/components/case_view/translations.ts +++ b/x-pack/plugins/cases/public/components/case_view/translations.ts @@ -163,3 +163,10 @@ export const ACTIVITY_TAB = i18n.translate('xpack.cases.caseView.tabs.activity', export const ALERTS_TAB = i18n.translate('xpack.cases.caseView.tabs.alerts', { defaultMessage: 'Alerts', }); + +export const ALERTS_EMPTY_DESCRIPTION = i18n.translate( + 'xpack.cases.caseView.tabs.alerts.emptyDescription', + { + defaultMessage: 'No alerts have been added to this case.', + } +); diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/alert.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/alert.tsx index 939bd458ebdc0..2c9689960322a 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/alert.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/alert.tsx @@ -18,6 +18,7 @@ import { UserActionUsernameWithAvatar } from '../avatar_username'; import { MultipleAlertsCommentEvent, SingleAlertCommentEvent } from './alert_event'; import { UserActionCopyLink } from '../copy_link'; import { UserActionShowAlert } from './show_alert'; +import { ShowAlertTableLink } from './show_alert_table_link'; type BuilderArgs = Pick< UserActionBuilderArgs, @@ -135,6 +136,9 @@ const getMultipleAlertsUserAction = ({ + + + ), }, diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx index f7023d92d5f54..8fbda9dfdec4a 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx @@ -21,10 +21,13 @@ import { import { TestProviders } from '../../../common/mock'; import { createCommentUserActionBuilder } from './comment'; import { getMockBuilderArgs } from '../mock'; +import { useCaseViewParams } from '../../../common/navigation'; jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/navigation/hooks'); +const useCaseViewParamsMock = useCaseViewParams as jest.Mock; + describe('createCommentUserActionBuilder', () => { const builderArgs = getMockBuilderArgs(); @@ -113,7 +116,8 @@ describe('createCommentUserActionBuilder', () => { }); describe('Multiple alerts', () => { - it('renders correctly multiple alerts', async () => { + it('renders correctly multiple alerts with a link to the alerts table', async () => { + useCaseViewParamsMock.mockReturnValue({ detailName: '1234' }); const userAction = getAlertUserAction(); const builder = createCommentUserActionBuilder({ @@ -141,6 +145,7 @@ describe('createCommentUserActionBuilder', () => { expect(screen.getByTestId('multiple-alerts-user-action-alert-action-id')).toHaveTextContent( 'added 2 alerts from Awesome rule' ); + expect(screen.getByTestId('comment-action-show-alerts-1234')); }); }); diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/show_alert_table_link.test.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/show_alert_table_link.test.tsx new file mode 100644 index 0000000000000..51d5c3a2b547c --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/comment/show_alert_table_link.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { createAppMockRenderer } from '../../../common/mock'; +import { useCaseViewNavigation, useCaseViewParams } from '../../../common/navigation'; +import { ShowAlertTableLink } from './show_alert_table_link'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('../../../common/navigation/hooks'); + +const useCaseViewParamsMock = useCaseViewParams as jest.Mock; +const useCaseViewNavigationMock = useCaseViewNavigation as jest.Mock; + +describe('case view alert table link', () => { + it('calls navigateToCaseView with the correct params', () => { + const appMockRenderer = createAppMockRenderer(); + const navigateToCaseView = jest.fn(); + + useCaseViewParamsMock.mockReturnValue({ detailName: 'case-id' }); + useCaseViewNavigationMock.mockReturnValue({ navigateToCaseView }); + + const result = appMockRenderer.render(); + expect(result.getByTestId('comment-action-show-alerts-case-id')).toBeInTheDocument(); + + userEvent.click(result.getByTestId('comment-action-show-alerts-case-id')); + expect(navigateToCaseView).toHaveBeenCalledWith({ + detailName: 'case-id', + tabId: 'alerts', + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/show_alert_table_link.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/show_alert_table_link.tsx new file mode 100644 index 0000000000000..3ec52e83e5dda --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/comment/show_alert_table_link.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { useCaseViewNavigation, useCaseViewParams } from '../../../common/navigation'; +import { CASE_VIEW_PAGE_TABS } from '../../case_view/types'; +import { SHOW_ALERT_TABLE_TOOLTIP } from '../translations'; + +export const ShowAlertTableLink = () => { + const { navigateToCaseView } = useCaseViewNavigation(); + const { detailName } = useCaseViewParams(); + + const handleShowAlertsTable = useCallback(() => { + navigateToCaseView({ detailName, tabId: CASE_VIEW_PAGE_TABS.ALERTS }); + }, [navigateToCaseView, detailName]); + return ( + {SHOW_ALERT_TABLE_TOOLTIP}

}> + +
+ ); +}; + +ShowAlertTableLink.displayName = 'ShowAlertTableLink'; diff --git a/x-pack/plugins/cases/public/components/user_actions/translations.ts b/x-pack/plugins/cases/public/components/user_actions/translations.ts index ad881a2e78c21..b5b5d902d3a4d 100644 --- a/x-pack/plugins/cases/public/components/user_actions/translations.ts +++ b/x-pack/plugins/cases/public/components/user_actions/translations.ts @@ -46,6 +46,13 @@ export const SHOW_ALERT_TOOLTIP = i18n.translate('xpack.cases.caseView.showAlert defaultMessage: 'Show alert details', }); +export const SHOW_ALERT_TABLE_TOOLTIP = i18n.translate( + 'xpack.cases.caseView.showAlertTableTooltip', + { + defaultMessage: 'Show alerts', + } +); + export const UNKNOWN_RULE = i18n.translate('xpack.cases.caseView.unknownRule.label', { defaultMessage: 'Unknown rule', }); diff --git a/x-pack/plugins/cases/public/containers/use_get_feature_ids.test.tsx b/x-pack/plugins/cases/public/containers/use_get_feature_ids.test.tsx index df39cc883d532..b173ea4ad19e0 100644 --- a/x-pack/plugins/cases/public/containers/use_get_feature_ids.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_feature_ids.test.tsx @@ -5,13 +5,12 @@ * 2.0. */ -import type { ValidFeatureId } from '@kbn/rule-data-utils'; import { renderHook, act } from '@testing-library/react-hooks'; -import { waitFor } from '@testing-library/dom'; import React from 'react'; import { TestProviders } from '../common/mock'; import { useGetFeatureIds } from './use_get_feature_ids'; import * as api from './api'; +import { waitFor } from '@testing-library/dom'; jest.mock('./api'); jest.mock('../common/lib/kibana'); @@ -24,17 +23,20 @@ describe('useGetFeaturesIds', () => { it('inits with empty data', async () => { jest.spyOn(api, 'getFeatureIds').mockRejectedValue([]); - const { result } = renderHook(() => useGetFeatureIds(['context1']), { + const { result } = renderHook(() => useGetFeatureIds(['context1']), { wrapper: ({ children }) => {children}, }); + act(() => { - expect(result.current).toEqual([]); + expect(result.current.alertFeatureIds).toEqual([]); + expect(result.current.isLoading).toEqual(true); + expect(result.current.isError).toEqual(false); }); }); - + // it('fetches data and returns it correctly', async () => { const spy = jest.spyOn(api, 'getFeatureIds'); - const { result } = renderHook(() => useGetFeatureIds(['context1']), { + const { result } = renderHook(() => useGetFeatureIds(['context1']), { wrapper: ({ children }) => {children}, }); @@ -45,19 +47,23 @@ describe('useGetFeaturesIds', () => { ); }); - expect(result.current).toEqual(['siem', 'observability']); + expect(result.current.alertFeatureIds).toEqual(['siem', 'observability']); + expect(result.current.isLoading).toEqual(false); + expect(result.current.isError).toEqual(false); }); - it('throws an error correctly', async () => { + it('sets isError to true when an error occurs', async () => { const spy = jest.spyOn(api, 'getFeatureIds'); spy.mockImplementation(() => { throw new Error('Something went wrong'); }); - const { result } = renderHook(() => useGetFeatureIds(['context1']), { + const { result } = renderHook(() => useGetFeatureIds(['context1']), { wrapper: ({ children }) => {children}, }); - expect(result.current).toEqual([]); + expect(result.current.alertFeatureIds).toEqual([]); + expect(result.current.isLoading).toEqual(false); + expect(result.current.isError).toEqual(true); }); }); diff --git a/x-pack/plugins/cases/public/containers/use_get_feature_ids.tsx b/x-pack/plugins/cases/public/containers/use_get_feature_ids.tsx index ca181c0596eec..082e0539792ff 100644 --- a/x-pack/plugins/cases/public/containers/use_get_feature_ids.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_feature_ids.tsx @@ -12,14 +12,27 @@ import * as i18n from './translations'; import { useToasts } from '../common/lib/kibana'; import { getFeatureIds } from './api'; -export const useGetFeatureIds = (alertRegistrationContexts: string[]): ValidFeatureId[] => { - const [alertFeatureIds, setAlertFeatureIds] = useState([]); +const initialStatus = { + isLoading: true, + alertFeatureIds: [] as ValidFeatureId[], + isError: false, +}; + +export const useGetFeatureIds = ( + alertRegistrationContexts: string[] +): { + isLoading: boolean; + isError: boolean; + alertFeatureIds: ValidFeatureId[]; +} => { const toasts = useToasts(); const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); + const [status, setStatus] = useState(initialStatus); const fetchFeatureIds = useCallback( async (registrationContext: string[]) => { + setStatus({ isLoading: true, alertFeatureIds: [], isError: false }); try { isCancelledRef.current = false; abortCtrlRef.current.abort(); @@ -29,10 +42,11 @@ export const useGetFeatureIds = (alertRegistrationContexts: string[]): ValidFeat const response = await getFeatureIds(query, abortCtrlRef.current.signal); if (!isCancelledRef.current) { - setAlertFeatureIds(response); + setStatus({ isLoading: false, alertFeatureIds: response, isError: false }); } } catch (error) { if (!isCancelledRef.current) { + setStatus({ isLoading: false, alertFeatureIds: [], isError: true }); if (error.name !== 'AbortError') { toasts.addError( error.body && error.body.message ? new Error(error.body.message) : error, @@ -52,7 +66,9 @@ export const useGetFeatureIds = (alertRegistrationContexts: string[]): ValidFeat abortCtrlRef.current.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [alertRegistrationContexts]); + }, alertRegistrationContexts); - return alertFeatureIds; + return status; }; + +export type UseGetFeatureIds = typeof useGetFeatureIds; diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts b/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts index 9aaf523de6638..42d5d5074e18d 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts @@ -215,5 +215,31 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await find.byCssSelector('[data-test-subj*="severity-update-action"]'); }); }); + + describe('Tabs', () => { + // create the case to test on + before(async () => { + await cases.navigation.navigateToApp(); + await cases.api.createNthRandomCases(1); + await cases.casesTable.waitForCasesToBeListed(); + await cases.casesTable.goToFirstListedCase(); + await header.waitUntilLoadingHasFinished(); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); + + it('shows the "activity" tab by default', async () => { + await testSubjects.existOrFail('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-content-activity'); + }); + + // there are no alerts in stack management yet + it.skip("shows the 'alerts' tab when clicked", async () => { + await testSubjects.click('case-view-tab-title-alerts'); + await testSubjects.existOrFail('case-view-tab-content-alerts'); + }); + }); }); }; From fa607d1fe582a2fd2b72cdd27be431d5e820954d Mon Sep 17 00:00:00 2001 From: Carlos Crespo Date: Thu, 19 May 2022 14:20:30 +0200 Subject: [PATCH 20/37] [Stack Monitoring] Converts logstash api routes to typescript (#131946) * converts logstash api routes to typescript * fixes register routes function names * fixes cluster and node pipelines routes * fixes types --- .../common/http_api/logstash/index.ts | 14 ++++ .../post_logstash_cluster_pipelines.ts | 25 ++++++ .../http_api/logstash/post_logstash_node.ts | 24 ++++++ .../logstash/post_logstash_node_pipelines.ts | 26 ++++++ .../http_api/logstash/post_logstash_nodes.ts | 22 +++++ .../logstash/post_logstash_overview.ts | 22 +++++ .../logstash/post_logstash_pipeline.ts | 24 ++++++ .../post_logstash_pipeline_cluster_ids.ts | 22 +++++ x-pack/plugins/monitoring/common/types/es.ts | 16 ++-- .../server/lib/details/get_metrics.ts | 6 ++ .../server/lib/logstash/get_cluster_status.ts | 5 +- .../api/v1/logstash/{index.js => index.ts} | 0 ...{metric_set_node.js => metric_set_node.ts} | 7 +- ...set_overview.js => metric_set_overview.ts} | 4 +- .../api/v1/logstash/{node.js => node.ts} | 67 +++++++-------- .../api/v1/logstash/{nodes.js => nodes.ts} | 43 ++++------ .../v1/logstash/{overview.js => overview.ts} | 43 ++++------ .../v1/logstash/{pipeline.js => pipeline.ts} | 70 +++++++--------- ...ipeline_ids.js => cluster_pipeline_ids.ts} | 35 ++++---- .../logstash/pipelines/cluster_pipelines.js | 79 ------------------ .../logstash/pipelines/cluster_pipelines.ts | 69 ++++++++++++++++ .../v1/logstash/pipelines/node_pipelines.js | 82 ------------------- .../v1/logstash/pipelines/node_pipelines.ts | 69 ++++++++++++++++ x-pack/plugins/monitoring/server/types.ts | 22 +++-- 24 files changed, 457 insertions(+), 339 deletions(-) create mode 100644 x-pack/plugins/monitoring/common/http_api/logstash/index.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_cluster_pipelines.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_node.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_node_pipelines.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_nodes.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_overview.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_pipeline.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_pipeline_cluster_ids.ts rename x-pack/plugins/monitoring/server/routes/api/v1/logstash/{index.js => index.ts} (100%) rename x-pack/plugins/monitoring/server/routes/api/v1/logstash/{metric_set_node.js => metric_set_node.ts} (88%) rename x-pack/plugins/monitoring/server/routes/api/v1/logstash/{metric_set_overview.js => metric_set_overview.ts} (73%) rename x-pack/plugins/monitoring/server/routes/api/v1/logstash/{node.js => node.ts} (61%) rename x-pack/plugins/monitoring/server/routes/api/v1/logstash/{nodes.js => nodes.ts} (57%) rename x-pack/plugins/monitoring/server/routes/api/v1/logstash/{overview.js => overview.ts} (66%) rename x-pack/plugins/monitoring/server/routes/api/v1/logstash/{pipeline.js => pipeline.ts} (55%) rename x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/{cluster_pipeline_ids.js => cluster_pipeline_ids.ts} (53%) delete mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.js create mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.ts delete mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.js create mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.ts diff --git a/x-pack/plugins/monitoring/common/http_api/logstash/index.ts b/x-pack/plugins/monitoring/common/http_api/logstash/index.ts new file mode 100644 index 0000000000000..938826a4556bc --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/logstash/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './post_logstash_node'; +export * from './post_logstash_nodes'; +export * from './post_logstash_overview'; +export * from './post_logstash_pipeline'; +export * from './post_logstash_pipeline_cluster_ids'; +export * from './post_logstash_cluster_pipelines'; +export * from './post_logstash_node_pipelines'; diff --git a/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_cluster_pipelines.ts b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_cluster_pipelines.ts new file mode 100644 index 0000000000000..8892e63e365c4 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_cluster_pipelines.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { clusterUuidRT, ccsRT, timeRangeRT, paginationRT, sortingRT } from '../shared'; + +export const postLogstashClusterPipelinesRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, +}); + +export const postLogstashClusterPipelinesRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + sort: sortingRT, + queryText: rt.string, + }), + rt.type({ + timeRange: timeRangeRT, + pagination: paginationRT, + }), +]); diff --git a/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_node.ts b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_node.ts new file mode 100644 index 0000000000000..1d5d538352884 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_node.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { clusterUuidRT, ccsRT, timeRangeRT } from '../shared'; + +export const postLogstashNodeRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, + logstashUuid: rt.string, +}); + +export const postLogstashNodeRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + }), + rt.type({ + timeRange: timeRangeRT, + is_advanced: rt.boolean, + }), +]); diff --git a/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_node_pipelines.ts b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_node_pipelines.ts new file mode 100644 index 0000000000000..ed674f5419d13 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_node_pipelines.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { clusterUuidRT, ccsRT, timeRangeRT, paginationRT, sortingRT } from '../shared'; + +export const postLogstashNodePipelinesRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, + logstashUuid: rt.string, +}); + +export const postLogstashNodePipelinesRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + sort: sortingRT, + queryText: rt.string, + }), + rt.type({ + timeRange: timeRangeRT, + pagination: paginationRT, + }), +]); diff --git a/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_nodes.ts b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_nodes.ts new file mode 100644 index 0000000000000..df4fecf3bec78 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_nodes.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { clusterUuidRT, ccsRT, timeRangeRT } from '../shared'; + +export const postLogstashNodesRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, +}); + +export const postLogstashNodesRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + }), + rt.type({ + timeRange: timeRangeRT, + }), +]); diff --git a/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_overview.ts b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_overview.ts new file mode 100644 index 0000000000000..dcd179b14a9f7 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_overview.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { clusterUuidRT, ccsRT, timeRangeRT } from '../shared'; + +export const postLogstashOverviewRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, +}); + +export const postLogstashOverviewRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + }), + rt.type({ + timeRange: timeRangeRT, + }), +]); diff --git a/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_pipeline.ts b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_pipeline.ts new file mode 100644 index 0000000000000..36cdc044fe090 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_pipeline.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { clusterUuidRT, ccsRT } from '../shared'; + +export const postLogstashPipelineRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, + pipelineId: rt.string, + pipelineHash: rt.union([rt.string, rt.undefined]), +}); + +export const postLogstashPipelineRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + }), + rt.partial({ + detailVertexId: rt.string, + }), +]); diff --git a/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_pipeline_cluster_ids.ts b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_pipeline_cluster_ids.ts new file mode 100644 index 0000000000000..f1450481a1e51 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/logstash/post_logstash_pipeline_cluster_ids.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { clusterUuidRT, ccsRT, timeRangeRT } from '../shared'; + +export const postLogstashPipelineClusterIdsRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, +}); + +export const postLogstashPipelineClusterIdsRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + }), + rt.type({ + timeRange: timeRangeRT, + }), +]); diff --git a/x-pack/plugins/monitoring/common/types/es.ts b/x-pack/plugins/monitoring/common/types/es.ts index f4c4b385d625d..a6b91f22ae563 100644 --- a/x-pack/plugins/monitoring/common/types/es.ts +++ b/x-pack/plugins/monitoring/common/types/es.ts @@ -142,6 +142,14 @@ export interface ElasticsearchIndexStats { }; } +export interface ElasticsearchLogstashStatePipeline { + representation?: { + graph?: { + vertices?: ElasticsearchSourceLogstashPipelineVertex[]; + }; + }; +} + export interface ElasticsearchLegacySource { timestamp: string; cluster_uuid: string; @@ -204,13 +212,7 @@ export interface ElasticsearchLegacySource { expiry_date_in_millis?: number; }; logstash_state?: { - pipeline?: { - representation?: { - graph?: { - vertices?: ElasticsearchSourceLogstashPipelineVertex[]; - }; - }; - }; + pipeline?: ElasticsearchLogstashStatePipeline; }; logstash_stats?: { timestamp?: string; diff --git a/x-pack/plugins/monitoring/server/lib/details/get_metrics.ts b/x-pack/plugins/monitoring/server/lib/details/get_metrics.ts index 475d2c681596e..f7e65efa74737 100644 --- a/x-pack/plugins/monitoring/server/lib/details/get_metrics.ts +++ b/x-pack/plugins/monitoring/server/lib/details/get_metrics.ts @@ -22,6 +22,12 @@ export type SimpleMetricDescriptor = string; export type MetricDescriptor = SimpleMetricDescriptor | NamedMetricDescriptor; +export function isNamedMetricDescriptor( + metricDescriptor: MetricDescriptor +): metricDescriptor is NamedMetricDescriptor { + return (metricDescriptor as NamedMetricDescriptor).name !== undefined; +} + // TODO: Switch to an options object argument here export async function getMetrics( req: LegacyRequest, diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_cluster_status.ts b/x-pack/plugins/monitoring/server/lib/logstash/get_cluster_status.ts index 308a750f6ef02..21d3d91a34470 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_cluster_status.ts +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_cluster_status.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { get } from 'lodash'; import { getLogstashForClusters } from './get_logstash_for_clusters'; import { LegacyRequest } from '../../types'; @@ -20,7 +19,7 @@ import { LegacyRequest } from '../../types'; */ export function getClusterStatus(req: LegacyRequest, { clusterUuid }: { clusterUuid: string }) { const clusters = [{ cluster_uuid: clusterUuid }]; - return getLogstashForClusters(req, clusters).then((clusterStatus) => - get(clusterStatus, '[0].stats') + return getLogstashForClusters(req, clusters).then( + (clusterStatus) => clusterStatus && clusterStatus[0]?.stats ); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.ts similarity index 100% rename from x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.js rename to x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.ts diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_node.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_node.ts similarity index 88% rename from x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_node.js rename to x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_node.ts index 908fab524901b..6137c47ea7141 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_node.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_node.ts @@ -5,7 +5,12 @@ * 2.0. */ -export const metricSets = { +import { MetricDescriptor } from '../../../../lib/details/get_metrics'; + +export const metricSets: { + advanced: MetricDescriptor[]; + overview: MetricDescriptor[]; +} = { advanced: [ { keys: ['logstash_node_cpu_utilization', 'logstash_node_cgroup_quota'], diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_overview.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_overview.ts similarity index 73% rename from x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_overview.js rename to x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_overview.ts index 3e812c1ab9a7a..440112d841d4e 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_overview.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/metric_set_overview.ts @@ -5,7 +5,9 @@ * 2.0. */ -export const metricSet = [ +import { SimpleMetricDescriptor } from '../../../../lib/details/get_metrics'; + +export const metricSet: SimpleMetricDescriptor[] = [ 'logstash_cluster_events_input_rate', 'logstash_cluster_events_output_rate', 'logstash_cluster_events_latency', diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/node.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/node.ts similarity index 61% rename from x-pack/plugins/monitoring/server/routes/api/v1/logstash/node.js rename to x-pack/plugins/monitoring/server/routes/api/v1/logstash/node.ts index cf1551d260e17..7f5fea6dc78e3 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/node.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/node.ts @@ -5,46 +5,33 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; +import { + postLogstashNodeRequestParamsRT, + postLogstashNodeRequestPayloadRT, +} from '../../../../../common/http_api/logstash'; import { getNodeInfo } from '../../../../lib/logstash/get_node_info'; import { handleError } from '../../../../lib/errors'; -import { getMetrics } from '../../../../lib/details/get_metrics'; +import { + getMetrics, + isNamedMetricDescriptor, + NamedMetricDescriptor, +} from '../../../../lib/details/get_metrics'; import { metricSets } from './metric_set_node'; +import { MonitoringCore } from '../../../../types'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; const { advanced: metricSetAdvanced, overview: metricSetOverview } = metricSets; -/* - * Logstash Node route. - */ -export function logstashNodeRoute(server) { - /** - * Logstash Node request. - * - * This will fetch all data required to display a Logstash Node page. - * - * The current details returned are: - * - * - Logstash Node Summary (Status) - * - Metrics - */ +export function logstashNodeRoute(server: MonitoringCore) { + const validateParams = createValidationFunction(postLogstashNodeRequestParamsRT); + const validateBody = createValidationFunction(postLogstashNodeRequestPayloadRT); + server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/node/{logstashUuid}', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - logstashUuid: schema.string(), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - is_advanced: schema.boolean(), - }), - }, + validate: { + params: validateParams, + body: validateBody, }, async handler(req) { const config = server.config; @@ -58,11 +45,17 @@ export function logstashNodeRoute(server) { metricSet = metricSetOverview; // set the cgroup option if needed const showCgroupMetricsLogstash = config.ui.container.logstash.enabled; - const metricCpu = metricSet.find((m) => m.name === 'logstash_node_cpu_metric'); - if (showCgroupMetricsLogstash) { - metricCpu.keys = ['logstash_node_cgroup_quota_as_cpu_utilization']; - } else { - metricCpu.keys = ['logstash_node_cpu_utilization']; + const metricCpu = metricSet.find( + (m): m is NamedMetricDescriptor => + isNamedMetricDescriptor(m) && m.name === 'logstash_node_cpu_metric' + ); + + if (metricCpu) { + if (showCgroupMetricsLogstash) { + metricCpu.keys = ['logstash_node_cgroup_quota_as_cpu_utilization']; + } else { + metricCpu.keys = ['logstash_node_cpu_utilization']; + } } } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/nodes.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/nodes.ts similarity index 57% rename from x-pack/plugins/monitoring/server/routes/api/v1/logstash/nodes.js rename to x-pack/plugins/monitoring/server/routes/api/v1/logstash/nodes.ts index c483a4ac905dd..169165b0893fe 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/nodes.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/nodes.ts @@ -5,41 +5,26 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; import { getClusterStatus } from '../../../../lib/logstash/get_cluster_status'; import { getNodes } from '../../../../lib/logstash/get_nodes'; import { handleError } from '../../../../lib/errors'; +import { MonitoringCore } from '../../../../types'; +import { + postLogstashNodesRequestParamsRT, + postLogstashNodesRequestPayloadRT, +} from '../../../../../common/http_api/logstash/post_logstash_nodes'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; + +export function logstashNodesRoute(server: MonitoringCore) { + const validateParams = createValidationFunction(postLogstashNodesRequestParamsRT); + const validateBody = createValidationFunction(postLogstashNodesRequestPayloadRT); -/* - * Logstash Nodes route. - */ -export function logstashNodesRoute(server) { - /** - * Logstash Nodes request. - * - * This will fetch all data required to display the Logstash Nodes page. - * - * The current details returned are: - * - * - Logstash Cluster Status - * - Nodes list - */ server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/nodes', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - }), - }, + validate: { + params: validateParams, + body: validateBody, }, async handler(req) { const clusterUuid = req.params.clusterUuid; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/overview.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/overview.ts similarity index 66% rename from x-pack/plugins/monitoring/server/routes/api/v1/logstash/overview.js rename to x-pack/plugins/monitoring/server/routes/api/v1/logstash/overview.ts index 797365da6e308..73cff6ad35ac8 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/overview.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/overview.ts @@ -5,42 +5,27 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; +import { + postLogstashOverviewRequestParamsRT, + postLogstashOverviewRequestPayloadRT, +} from '../../../../../common/http_api/logstash/post_logstash_overview'; import { getClusterStatus } from '../../../../lib/logstash/get_cluster_status'; import { getMetrics } from '../../../../lib/details/get_metrics'; import { handleError } from '../../../../lib/errors'; import { metricSet } from './metric_set_overview'; +import { MonitoringCore } from '../../../../types'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; + +export function logstashOverviewRoute(server: MonitoringCore) { + const validateParams = createValidationFunction(postLogstashOverviewRequestParamsRT); + const validateBody = createValidationFunction(postLogstashOverviewRequestPayloadRT); -/* - * Logstash Overview route. - */ -export function logstashOverviewRoute(server) { - /** - * Logstash Overview request. - * - * This will fetch all data required to display the Logstash Overview page. - * - * The current details returned are: - * - * - Logstash Cluster Status - * - Metrics - */ server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - }), - }, + validate: { + params: validateParams, + body: validateBody, }, async handler(req) { const clusterUuid = req.params.clusterUuid; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.ts similarity index 55% rename from x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.js rename to x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.ts index fc06e36fe9132..ba4eb941f7ffe 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.ts @@ -6,53 +6,42 @@ */ import { notFound } from '@hapi/boom'; -import { schema } from '@kbn/config-schema'; import { handleError, PipelineNotFoundError } from '../../../../lib/errors'; +import { + postLogstashPipelineRequestParamsRT, + postLogstashPipelineRequestPayloadRT, +} from '../../../../../common/http_api/logstash/post_logstash_pipeline'; import { getPipelineVersions } from '../../../../lib/logstash/get_pipeline_versions'; import { getPipeline } from '../../../../lib/logstash/get_pipeline'; import { getPipelineVertex } from '../../../../lib/logstash/get_pipeline_vertex'; +import { MonitoringCore, PipelineVersion } from '../../../../types'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; -function getPipelineVersion(versions, pipelineHash) { - return pipelineHash ? versions.find(({ hash }) => hash === pipelineHash) : versions[0]; +function getPipelineVersion(versions: PipelineVersion[], pipelineHash: string | null) { + return pipelineHash + ? versions.find(({ hash }) => hash === pipelineHash) ?? versions[0] + : versions[0]; } -/* - * Logstash Pipeline route. - */ -export function logstashPipelineRoute(server) { - /** - * Logstash Pipeline Viewer request. - * - * This will fetch all data required to display a Logstash Pipeline Viewer page. - * - * The current details returned are: - * - * - Pipeline Metrics - */ +export function logstashPipelineRoute(server: MonitoringCore) { + const validateParams = createValidationFunction(postLogstashPipelineRequestParamsRT); + const validateBody = createValidationFunction(postLogstashPipelineRequestPayloadRT); + server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/pipeline/{pipelineId}/{pipelineHash?}', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - pipelineId: schema.string(), - pipelineHash: schema.maybe(schema.string()), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - detailVertexId: schema.maybe(schema.string()), - }), - }, + validate: { + params: validateParams, + body: validateBody, }, - handler: async (req) => { + async handler(req) { const config = server.config; const clusterUuid = req.params.clusterUuid; const detailVertexId = req.payload.detailVertexId; const pipelineId = req.params.pipelineId; // Optional params default to empty string, set to null to be more explicit. - const pipelineHash = req.params.pipelineHash || null; + const pipelineHash = req.params.pipelineHash ?? null; // Figure out which version of the pipeline we want to show let versions; @@ -67,16 +56,19 @@ export function logstashPipelineRoute(server) { } const version = getPipelineVersion(versions, pipelineHash); - // noinspection ES6MissingAwait - const promises = [getPipeline(req, config, clusterUuid, pipelineId, version)]; - if (detailVertexId) { - promises.push( - getPipelineVertex(req, config, clusterUuid, pipelineId, version, detailVertexId) - ); - } + const callGetPipelineVertexFunc = () => { + if (!detailVertexId) { + return Promise.resolve(undefined); + } + + return getPipelineVertex(req, config, clusterUuid, pipelineId, version, detailVertexId); + }; try { - const [pipeline, vertex] = await Promise.all(promises); + const [pipeline, vertex] = await Promise.all([ + getPipeline(req, config, clusterUuid, pipelineId, version), + callGetPipelineVertexFunc(), + ]); return { versions, pipeline, diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.ts similarity index 53% rename from x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.js rename to x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.ts index ebe3f1a308ff3..fe4d2c2b64ed7 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.ts @@ -5,32 +5,27 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; import { handleError } from '../../../../../lib/errors'; import { getLogstashPipelineIds } from '../../../../../lib/logstash/get_pipeline_ids'; +import { MonitoringCore } from '../../../../../types'; +import { createValidationFunction } from '../../../../../lib/create_route_validation_function'; +import { + postLogstashPipelineClusterIdsRequestParamsRT, + postLogstashPipelineClusterIdsRequestPayloadRT, +} from '../../../../../../common/http_api/logstash'; + +export function logstashClusterPipelineIdsRoute(server: MonitoringCore) { + const validateParams = createValidationFunction(postLogstashPipelineClusterIdsRequestParamsRT); + const validateBody = createValidationFunction(postLogstashPipelineClusterIdsRequestPayloadRT); -/** - * Retrieve pipelines for a cluster - */ -export function logstashClusterPipelineIdsRoute(server) { server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/pipeline_ids', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - }), - }, + validate: { + params: validateParams, + body: validateBody, }, - handler: async (req) => { + async handler(req) { const config = server.config; const clusterUuid = req.params.clusterUuid; const size = config.ui.max_bucket_size; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.js deleted file mode 100644 index 38ba810ca5a23..0000000000000 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.js +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { schema } from '@kbn/config-schema'; -import { getClusterStatus } from '../../../../../lib/logstash/get_cluster_status'; -import { handleError } from '../../../../../lib/errors'; -import { getPaginatedPipelines } from '../../../../../lib/logstash/get_paginated_pipelines'; - -/** - * Retrieve pipelines for a cluster - */ -export function logstashClusterPipelinesRoute(server) { - server.route({ - method: 'POST', - path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/pipelines', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - pagination: schema.object({ - index: schema.number(), - size: schema.number(), - }), - sort: schema.maybe( - schema.object({ - field: schema.string(), - direction: schema.string(), - }) - ), - queryText: schema.string({ defaultValue: '' }), - }), - }, - }, - handler: async (req) => { - const { pagination, sort, queryText } = req.payload; - const clusterUuid = req.params.clusterUuid; - - const throughputMetric = 'logstash_cluster_pipeline_throughput'; - const nodesCountMetric = 'logstash_cluster_pipeline_nodes_count'; - - // Mapping client and server metric keys together - const sortMetricSetMap = { - latestThroughput: throughputMetric, - latestNodesCount: nodesCountMetric, - }; - if (sort) { - sort.field = sortMetricSetMap[sort.field] || sort.field; - } - try { - const response = await getPaginatedPipelines({ - req, - clusterUuid, - metrics: { throughputMetric, nodesCountMetric }, - pagination, - sort, - queryText, - }); - - return { - ...response, - clusterStatus: await getClusterStatus(req, { clusterUuid }), - }; - } catch (err) { - throw handleError(err, req); - } - }, - }); -} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.ts b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.ts new file mode 100644 index 0000000000000..07404c28894c4 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.ts @@ -0,0 +1,69 @@ +/* + * Copyright 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 { getClusterStatus } from '../../../../../lib/logstash/get_cluster_status'; +import { handleError } from '../../../../../lib/errors'; +import { getPaginatedPipelines } from '../../../../../lib/logstash/get_paginated_pipelines'; +import { MonitoringCore, PipelineMetricKey } from '../../../../../types'; +import { createValidationFunction } from '../../../../../lib/create_route_validation_function'; +import { + postLogstashClusterPipelinesRequestParamsRT, + postLogstashClusterPipelinesRequestPayloadRT, +} from '../../../../../../common/http_api/logstash'; + +const throughputMetric = 'logstash_cluster_pipeline_throughput'; +const nodesCountMetric = 'logstash_cluster_pipeline_nodes_count'; + +// Mapping client and server metric keys together +const sortMetricSetMap = { + latestThroughput: throughputMetric, + latestNodesCount: nodesCountMetric, +}; + +export function logstashClusterPipelinesRoute(server: MonitoringCore) { + const validateParams = createValidationFunction(postLogstashClusterPipelinesRequestParamsRT); + const validateBody = createValidationFunction(postLogstashClusterPipelinesRequestPayloadRT); + + server.route({ + method: 'post', + path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/pipelines', + validate: { + params: validateParams, + body: validateBody, + }, + async handler(req) { + const { + pagination, + sort: { field = '', direction = 'desc' } = {}, + queryText = '', + } = req.payload; + const clusterUuid = req.params.clusterUuid; + + try { + const response = await getPaginatedPipelines({ + req, + clusterUuid, + metrics: { throughputMetric, nodesCountMetric }, + pagination, + sort: { + field: (sortMetricSetMap[field as keyof typeof sortMetricSetMap] ?? + field) as PipelineMetricKey, + direction, + }, + queryText, + }); + + return { + ...response, + clusterStatus: await getClusterStatus(req, { clusterUuid }), + }; + } catch (err) { + throw handleError(err, req); + } + }, + }); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.js deleted file mode 100644 index d47f1e6e88ec8..0000000000000 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.js +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { schema } from '@kbn/config-schema'; -import { getNodeInfo } from '../../../../../lib/logstash/get_node_info'; -import { handleError } from '../../../../../lib/errors'; -import { getPaginatedPipelines } from '../../../../../lib/logstash/get_paginated_pipelines'; - -/** - * Retrieve pipelines for a node - */ -export function logstashNodePipelinesRoute(server) { - server.route({ - method: 'POST', - path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/node/{logstashUuid}/pipelines', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - logstashUuid: schema.string(), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - pagination: schema.object({ - index: schema.number(), - size: schema.number(), - }), - sort: schema.maybe( - schema.object({ - field: schema.string(), - direction: schema.string(), - }) - ), - queryText: schema.string({ defaultValue: '' }), - }), - }, - }, - handler: async (req) => { - const { pagination, sort, queryText } = req.payload; - const { clusterUuid, logstashUuid } = req.params; - - const throughputMetric = 'logstash_node_pipeline_throughput'; - const nodesCountMetric = 'logstash_node_pipeline_nodes_count'; - - // Mapping client and server metric keys together - const sortMetricSetMap = { - latestThroughput: throughputMetric, - latestNodesCount: nodesCountMetric, - }; - if (sort) { - sort.field = sortMetricSetMap[sort.field] || sort.field; - } - - try { - const response = await getPaginatedPipelines({ - req, - clusterUuid, - logstashUuid, - metrics: { throughputMetric, nodesCountMetric }, - pagination, - sort, - queryText, - }); - - return { - ...response, - nodeSummary: await getNodeInfo(req, { clusterUuid, logstashUuid }), - }; - } catch (err) { - throw handleError(err, req); - } - }, - }); -} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.ts b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.ts new file mode 100644 index 0000000000000..8cf74c1d93cc7 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.ts @@ -0,0 +1,69 @@ +/* + * Copyright 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 { getNodeInfo } from '../../../../../lib/logstash/get_node_info'; +import { handleError } from '../../../../../lib/errors'; +import { getPaginatedPipelines } from '../../../../../lib/logstash/get_paginated_pipelines'; +import { MonitoringCore, PipelineMetricKey } from '../../../../../types'; +import { createValidationFunction } from '../../../../../lib/create_route_validation_function'; +import { + postLogstashNodePipelinesRequestParamsRT, + postLogstashNodePipelinesRequestPayloadRT, +} from '../../../../../../common/http_api/logstash'; + +const throughputMetric = 'logstash_node_pipeline_throughput'; +const nodesCountMetric = 'logstash_node_pipeline_nodes_count'; + +// Mapping client and server metric keys together +const sortMetricSetMap = { + latestThroughput: throughputMetric, + latestNodesCount: nodesCountMetric, +}; + +export function logstashNodePipelinesRoute(server: MonitoringCore) { + const validateParams = createValidationFunction(postLogstashNodePipelinesRequestParamsRT); + const validateBody = createValidationFunction(postLogstashNodePipelinesRequestPayloadRT); + server.route({ + method: 'post', + path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/node/{logstashUuid}/pipelines', + validate: { + params: validateParams, + body: validateBody, + }, + async handler(req) { + const { + pagination, + sort: { field = '', direction = 'desc' } = {}, + queryText = '', + } = req.payload; + const { clusterUuid, logstashUuid } = req.params; + + try { + const response = await getPaginatedPipelines({ + req, + clusterUuid, + logstashUuid, + metrics: { throughputMetric, nodesCountMetric }, + pagination, + sort: { + field: (sortMetricSetMap[field as keyof typeof sortMetricSetMap] ?? + field) as PipelineMetricKey, + direction, + }, + queryText, + }); + + return { + ...response, + nodeSummary: await getNodeInfo(req, { clusterUuid, logstashUuid }), + }; + } catch (err) { + throw handleError(err, req); + } + }, + }); +} diff --git a/x-pack/plugins/monitoring/server/types.ts b/x-pack/plugins/monitoring/server/types.ts index 5977484518146..86447a24fdf04 100644 --- a/x-pack/plugins/monitoring/server/types.ts +++ b/x-pack/plugins/monitoring/server/types.ts @@ -198,11 +198,7 @@ export type Pipeline = { [key in PipelineMetricKey]?: number; }; -export type PipelineMetricKey = - | 'logstash_cluster_pipeline_throughput' - | 'logstash_cluster_pipeline_node_count' - | 'logstash_node_pipeline_node_count' - | 'logstash_node_pipeline_throughput'; +export type PipelineMetricKey = PipelineThroughputMetricKey | PipelineNodeCountMetricKey; export type PipelineThroughputMetricKey = | 'logstash_cluster_pipeline_throughput' @@ -210,16 +206,18 @@ export type PipelineThroughputMetricKey = export type PipelineNodeCountMetricKey = | 'logstash_cluster_pipeline_node_count' - | 'logstash_node_pipeline_node_count'; + | 'logstash_cluster_pipeline_nodes_count' + | 'logstash_node_pipeline_node_count' + | 'logstash_node_pipeline_nodes_count'; export interface PipelineWithMetrics { id: string; - metrics: { - logstash_cluster_pipeline_throughput?: PipelineMetricsProcessed; - logstash_cluster_pipeline_node_count?: PipelineMetricsProcessed; - logstash_node_pipeline_throughput?: PipelineMetricsProcessed; - logstash_node_pipeline_node_count?: PipelineMetricsProcessed; - }; + metrics: + | { + [key in PipelineMetricKey]: PipelineMetricsProcessed | undefined; + } + // backward compat with references that don't properly type the metric keys + | { [key: string]: PipelineMetricsProcessed | undefined }; } export interface PipelineResponse { From 3f339f2596236b2c8903a6a37373cdf51e672804 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Thu, 19 May 2022 09:27:45 -0400 Subject: [PATCH 21/37] [Security Solution][Admin][Kql bar] Align kql bar with buttons on same row in endpoint list(#132468) --- .../public/management/pages/endpoint_hosts/view/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 97bae3e150848..9c644f59a8b8a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -726,7 +726,7 @@ export const EndpointList = () => { )} {transformFailedCallout} - + {shouldShowKQLBar && ( From d9e6ef3f23809cc00e87b0caf9ab755b5c7fa9e8 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 19 May 2022 09:28:53 -0400 Subject: [PATCH 22/37] Unskip flaky test; Add retry when parsing JSON from audit log (#132510) --- .../tests/audit/audit_log.ts | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/x-pack/test/security_api_integration/tests/audit/audit_log.ts b/x-pack/test/security_api_integration/tests/audit/audit_log.ts index 7322a2638767b..65ceaa46dd44a 100644 --- a/x-pack/test/security_api_integration/tests/audit/audit_log.ts +++ b/x-pack/test/security_api_integration/tests/audit/audit_log.ts @@ -8,10 +8,11 @@ import Path from 'path'; import Fs from 'fs'; import expect from '@kbn/expect'; +import { RetryService } from '../../../../../test/common/services/retry'; import { FtrProviderContext } from '../../ftr_provider_context'; class FileWrapper { - constructor(private readonly path: string) {} + constructor(private readonly path: string, private readonly retry: RetryService) {} async reset() { // "touch" each file to ensure it exists and is empty before each test await Fs.promises.writeFile(this.path, ''); @@ -21,15 +22,17 @@ class FileWrapper { return content.trim().split('\n'); } async readJSON() { - const content = await this.read(); - try { - return content.map((l) => JSON.parse(l)); - } catch (err) { - const contentString = content.join('\n'); - throw new Error( - `Failed to parse audit log JSON, error: "${err.message}", audit.log contents:\n${contentString}` - ); - } + return this.retry.try(async () => { + const content = await this.read(); + try { + return content.map((l) => JSON.parse(l)); + } catch (err) { + const contentString = content.join('\n'); + throw new Error( + `Failed to parse audit log JSON, error: "${err.message}", audit.log contents:\n${contentString}` + ); + } + }); } // writing in a file is an async operation. we use this method to make sure logs have been written. async isNotEmpty() { @@ -44,10 +47,9 @@ export default function ({ getService }: FtrProviderContext) { const retry = getService('retry'); const { username, password } = getService('config').get('servers.kibana'); - // FLAKY: https://github.com/elastic/kibana/issues/119267 - describe.skip('Audit Log', function () { + describe('Audit Log', function () { const logFilePath = Path.resolve(__dirname, '../../fixtures/audit/audit.log'); - const logFile = new FileWrapper(logFilePath); + const logFile = new FileWrapper(logFilePath, retry); beforeEach(async () => { await logFile.reset(); From dd8bd6fdb3e3de88479568faa9fec2a7910730b0 Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Thu, 19 May 2022 16:32:00 +0300 Subject: [PATCH 23/37] [XY] Mark size configuration. (#130361) * Added tests for the case when markSizeRatio and markSizeAccessor are specified. * Added markSizeAccessor to extendedDataLayer and xyVis. * Fixed markSizeRatio default value. * Added `size` support from `pointseries`. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../expression_xy/common/__mocks__/index.ts | 19 ++++- .../extended_data_layer.test.ts.snap | 5 ++ .../__snapshots__/layered_xy_vis.test.ts.snap | 7 ++ .../__snapshots__/xy_vis.test.ts.snap | 6 ++ .../expression_functions/common_xy_args.ts | 4 + .../extended_data_layer.test.ts | 74 ++++++++++++++++++ .../extended_data_layer.ts | 4 + .../extended_data_layer_fn.ts | 3 + .../layered_xy_vis.test.ts | 76 +++++++++++++++++++ .../expression_functions/layered_xy_vis_fn.ts | 15 +++- .../common/expression_functions/validate.ts | 44 ++++++++++- .../expression_functions/xy_vis.test.ts | 51 +++++++++++++ .../common/expression_functions/xy_vis.ts | 4 + .../common/expression_functions/xy_vis_fn.ts | 11 +++ .../expression_xy/common/helpers/layers.ts | 17 +++-- .../expression_xy/common/i18n/index.tsx | 8 ++ .../common/types/expression_functions.ts | 6 ++ .../__snapshots__/xy_chart.test.tsx.snap | 56 ++++++++------ .../public/components/xy_chart.test.tsx | 38 ++++++++++ .../public/components/xy_chart.tsx | 7 +- .../public/helpers/data_layers.tsx | 25 ++++-- 21 files changed, 438 insertions(+), 42 deletions(-) create mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap create mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/layered_xy_vis.test.ts.snap create mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts create mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.test.ts diff --git a/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts b/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts index b8969fd599765..76e524960b159 100644 --- a/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts @@ -10,7 +10,7 @@ import { Position } from '@elastic/charts'; import type { PaletteOutput } from '@kbn/coloring'; import { Datatable, DatatableRow } from '@kbn/expressions-plugin'; import { LayerTypes } from '../constants'; -import { DataLayerConfig, XYProps } from '../types'; +import { DataLayerConfig, ExtendedDataLayerConfig, XYProps } from '../types'; export const mockPaletteOutput: PaletteOutput = { type: 'palette', @@ -35,7 +35,7 @@ export const createSampleDatatableWithRows = (rows: DatatableRow[]): Datatable = id: 'c', name: 'c', meta: { - type: 'date', + type: 'string', field: 'order_date', sourceParams: { type: 'date-histogram', params: { interval: 'auto' } }, params: { id: 'string' }, @@ -61,6 +61,21 @@ export const sampleLayer: DataLayerConfig = { table: createSampleDatatableWithRows([]), }; +export const sampleExtendedLayer: ExtendedDataLayerConfig = { + layerId: 'first', + type: 'extendedDataLayer', + layerType: LayerTypes.DATA, + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + xScaleType: 'ordinal', + isHistogram: false, + palette: mockPaletteOutput, + table: createSampleDatatableWithRows([]), +}; + export const createArgsWithLayers = ( layers: DataLayerConfig | DataLayerConfig[] = sampleLayer ): XYProps => ({ diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap new file mode 100644 index 0000000000000..68262f8a4f3de --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`extendedDataLayerConfig throws the error if markSizeAccessor doesn't have the corresponding column in the table 1`] = `"Provided column name or index is invalid: nonsense"`; + +exports[`extendedDataLayerConfig throws the error if markSizeAccessor is provided to the not line/area chart 1`] = `"\`markSizeAccessor\` can't be used. Dots are applied only for line or area charts"`; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/layered_xy_vis.test.ts.snap b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/layered_xy_vis.test.ts.snap new file mode 100644 index 0000000000000..b8e7cb8c05d3f --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/layered_xy_vis.test.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`layeredXyVis it should throw error if markSizeRatio is lower then 1 or greater then 100 1`] = `"Mark size ratio must be greater or equal to 1 and less or equal to 100"`; + +exports[`layeredXyVis it should throw error if markSizeRatio is lower then 1 or greater then 100 2`] = `"Mark size ratio must be greater or equal to 1 and less or equal to 100"`; + +exports[`layeredXyVis it should throw error if markSizeRatio is specified if no markSizeAccessor is present 1`] = `"Mark size ratio can be applied only with \`markSizeAccessor\`"`; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap index 3a33797bc0cbf..05109cc65446b 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap @@ -1,5 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`xyVis it should throw error if markSizeRatio is lower then 1 or greater then 100 1`] = `"Mark size ratio must be greater or equal to 1 and less or equal to 100"`; + +exports[`xyVis it should throw error if markSizeRatio is lower then 1 or greater then 100 2`] = `"Mark size ratio must be greater or equal to 1 and less or equal to 100"`; + +exports[`xyVis it should throw error if markSizeRatio is specified while markSizeAccessor is not 1`] = `"Mark size ratio can be applied only with \`markSizeAccessor\`"`; + exports[`xyVis it should throw error if minTimeBarInterval applied for not time bar chart 1`] = `"\`minTimeBarInterval\` argument is applicable only for time bar charts."`; exports[`xyVis it should throw error if minTimeBarInterval is invalid 1`] = `"Provided x-axis interval is invalid. The interval should include quantity and unit names. Examples: 1d, 24h, 1w."`; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts index a09212d59cce3..0921760f9f676 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts @@ -128,6 +128,10 @@ export const commonXYArgs: CommonXYFn['args'] = { types: ['string'], help: strings.getAriaLabelHelp(), }, + markSizeRatio: { + types: ['number'], + help: strings.getMarkSizeRatioHelp(), + }, minTimeBarInterval: { types: ['string'], help: strings.getMinTimeBarIntervalHelp(), diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts new file mode 100644 index 0000000000000..5b943b0790313 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright 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 { ExtendedDataLayerArgs } from '../types'; +import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; +import { mockPaletteOutput, sampleArgs } from '../__mocks__'; +import { LayerTypes } from '../constants'; +import { extendedDataLayerFunction } from './extended_data_layer'; + +describe('extendedDataLayerConfig', () => { + test('produces the correct arguments', async () => { + const { data } = sampleArgs(); + const args: ExtendedDataLayerArgs = { + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + xScaleType: 'linear', + isHistogram: false, + palette: mockPaletteOutput, + markSizeAccessor: 'b', + }; + + const result = await extendedDataLayerFunction.fn(data, args, createMockExecutionContext()); + + expect(result).toEqual({ + type: 'extendedDataLayer', + layerType: LayerTypes.DATA, + ...args, + table: data, + }); + }); + + test('throws the error if markSizeAccessor is provided to the not line/area chart', async () => { + const { data } = sampleArgs(); + const args: ExtendedDataLayerArgs = { + seriesType: 'bar', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + xScaleType: 'linear', + isHistogram: false, + palette: mockPaletteOutput, + markSizeAccessor: 'b', + }; + + expect( + extendedDataLayerFunction.fn(data, args, createMockExecutionContext()) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test("throws the error if markSizeAccessor doesn't have the corresponding column in the table", async () => { + const { data } = sampleArgs(); + const args: ExtendedDataLayerArgs = { + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + xScaleType: 'linear', + isHistogram: false, + palette: mockPaletteOutput, + markSizeAccessor: 'nonsense', + }; + + expect( + extendedDataLayerFunction.fn(data, args, createMockExecutionContext()) + ).rejects.toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.ts index a7aa63645d119..58da88a8d4b25 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.ts @@ -32,6 +32,10 @@ export const extendedDataLayerFunction: ExtendedDataLayerFn = { help: strings.getAccessorsHelp(), multi: true, }, + markSizeAccessor: { + types: ['string'], + help: strings.getMarkSizeAccessorHelp(), + }, table: { types: ['datatable'], help: strings.getTableHelp(), diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts index 47e62f9ccae4a..8e5019e065133 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts @@ -10,6 +10,7 @@ import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; import { ExtendedDataLayerArgs, ExtendedDataLayerFn } from '../types'; import { EXTENDED_DATA_LAYER, LayerTypes } from '../constants'; import { getAccessors, normalizeTable } from '../helpers'; +import { validateMarkSizeForChartType } from './validate'; export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args, context) => { const table = args.table ?? data; @@ -18,6 +19,8 @@ export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args, validateAccessor(accessors.xAccessor, table.columns); validateAccessor(accessors.splitAccessor, table.columns); accessors.accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); + validateMarkSizeForChartType(args.markSizeAccessor, args.seriesType); + validateAccessor(args.markSizeAccessor, table.columns); const normalizedTable = normalizeTable(table, accessors.xAccessor); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.test.ts new file mode 100644 index 0000000000000..79427cbe4d3cc --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.test.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 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 { layeredXyVisFunction } from '.'; +import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; +import { sampleArgs, sampleExtendedLayer } from '../__mocks__'; +import { XY_VIS } from '../constants'; + +describe('layeredXyVis', () => { + test('it renders with the specified data and args', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + const result = await layeredXyVisFunction.fn( + data, + { ...rest, layers: [sampleExtendedLayer] }, + createMockExecutionContext() + ); + + expect(result).toEqual({ + type: 'render', + as: XY_VIS, + value: { args: { ...rest, layers: [sampleExtendedLayer] } }, + }); + }); + + test('it should throw error if markSizeRatio is lower then 1 or greater then 100', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + + expect( + layeredXyVisFunction.fn( + data, + { + ...rest, + markSizeRatio: 0, + layers: [sampleExtendedLayer], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + + expect( + layeredXyVisFunction.fn( + data, + { + ...rest, + markSizeRatio: 101, + layers: [sampleExtendedLayer], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('it should throw error if markSizeRatio is specified if no markSizeAccessor is present', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + + expect( + layeredXyVisFunction.fn( + data, + { + ...rest, + markSizeRatio: 10, + layers: [sampleExtendedLayer], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts index c4e2decb3279d..29624d8037393 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts @@ -10,7 +10,12 @@ import { XY_VIS_RENDERER } from '../constants'; import { appendLayerIds, getDataLayers } from '../helpers'; import { LayeredXyVisFn } from '../types'; import { logDatatables } from '../utils'; -import { validateMinTimeBarInterval, hasBarLayer } from './validate'; +import { + validateMarkSizeRatioLimits, + validateMinTimeBarInterval, + hasBarLayer, + errors, +} from './validate'; export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) => { const layers = appendLayerIds(args.layers ?? [], 'layers'); @@ -19,7 +24,14 @@ export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) const dataLayers = getDataLayers(layers); const hasBar = hasBarLayer(dataLayers); + validateMarkSizeRatioLimits(args.markSizeRatio); validateMinTimeBarInterval(dataLayers, hasBar, args.minTimeBarInterval); + const hasMarkSizeAccessors = + dataLayers.filter((dataLayer) => dataLayer.markSizeAccessor !== undefined).length > 0; + + if (!hasMarkSizeAccessors && args.markSizeRatio !== undefined) { + throw new Error(errors.markSizeRatioWithoutAccessor()); + } return { type: 'render', @@ -28,6 +40,7 @@ export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) args: { ...args, layers, + markSizeRatio: hasMarkSizeAccessors && !args.markSizeRatio ? 10 : args.markSizeRatio, ariaLabel: args.ariaLabel ?? (handlers.variables?.embeddableTitle as string) ?? diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts index 2d1ecb2840c0a..60e590b0f8cca 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts @@ -8,8 +8,10 @@ import { i18n } from '@kbn/i18n'; import { isValidInterval } from '@kbn/data-plugin/common'; +import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; import { AxisExtentModes, ValueLabelModes } from '../constants'; import { + SeriesType, AxisExtentConfigResult, DataLayerConfigResult, CommonXYDataLayerConfigResult, @@ -18,7 +20,23 @@ import { } from '../types'; import { isTimeChart } from '../helpers'; -const errors = { +export const errors = { + markSizeAccessorForNonLineOrAreaChartsError: () => + i18n.translate( + 'expressionXY.reusable.function.dataLayer.errors.markSizeAccessorForNonLineOrAreaChartsError', + { + defaultMessage: + "`markSizeAccessor` can't be used. Dots are applied only for line or area charts", + } + ), + markSizeRatioLimitsError: () => + i18n.translate('expressionXY.reusable.function.xyVis.errors.markSizeLimitsError', { + defaultMessage: 'Mark size ratio must be greater or equal to 1 and less or equal to 100', + }), + markSizeRatioWithoutAccessor: () => + i18n.translate('expressionXY.reusable.function.xyVis.errors.markSizeRatioWithoutAccessor', { + defaultMessage: 'Mark size ratio can be applied only with `markSizeAccessor`', + }), extendBoundsAreInvalidError: () => i18n.translate('expressionXY.reusable.function.xyVis.errors.extendBoundsAreInvalidError', { defaultMessage: @@ -117,6 +135,30 @@ export const validateValueLabels = ( } }; +export const validateMarkSizeForChartType = ( + markSizeAccessor: ExpressionValueVisDimension | string | undefined, + seriesType: SeriesType +) => { + if (markSizeAccessor && !seriesType.includes('line') && !seriesType.includes('area')) { + throw new Error(errors.markSizeAccessorForNonLineOrAreaChartsError()); + } +}; + +export const validateMarkSizeRatioLimits = (markSizeRatio?: number) => { + if (markSizeRatio !== undefined && (markSizeRatio < 1 || markSizeRatio > 100)) { + throw new Error(errors.markSizeRatioLimitsError()); + } +}; + +export const validateMarkSizeRatioWithAccessor = ( + markSizeRatio: number | undefined, + markSizeAccessor: ExpressionValueVisDimension | string | undefined +) => { + if (markSizeRatio !== undefined && !markSizeAccessor) { + throw new Error(errors.markSizeRatioWithoutAccessor()); + } +}; + export const validateMinTimeBarInterval = ( dataLayers: CommonXYDataLayerConfigResult[], hasBar: boolean, diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts index 9348e489ab391..8ec1961416638 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts @@ -50,6 +50,37 @@ describe('xyVis', () => { }); }); + test('it should throw error if markSizeRatio is lower then 1 or greater then 100', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + expect( + xyVisFunction.fn( + data, + { + ...rest, + ...{ ...sampleLayer, markSizeAccessor: 'b' }, + markSizeRatio: 0, + referenceLineLayers: [], + annotationLayers: [], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + + expect( + xyVisFunction.fn( + data, + { + ...rest, + ...{ ...sampleLayer, markSizeAccessor: 'b' }, + markSizeRatio: 101, + referenceLineLayers: [], + annotationLayers: [], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); test('it should throw error if minTimeBarInterval is invalid', async () => { const { data, args } = sampleArgs(); const { layers, ...rest } = args; @@ -129,4 +160,24 @@ describe('xyVis', () => { ) ).rejects.toThrowErrorMatchingSnapshot(); }); + + test('it should throw error if markSizeRatio is specified while markSizeAccessor is not', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; + + expect( + xyVisFunction.fn( + data, + { + ...rest, + ...restLayerArgs, + referenceLineLayers: [], + annotationLayers: [], + markSizeRatio: 5, + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); }); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts index e4e519b0a7433..37baf028178cc 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts @@ -51,6 +51,10 @@ export const xyVisFunction: XyVisFn = { types: ['vis_dimension', 'string'], help: strings.getSplitRowAccessorHelp(), }, + markSizeAccessor: { + types: ['vis_dimension', 'string'], + help: strings.getMarkSizeAccessorHelp(), + }, }, async fn(data, args, handlers) { const { xyVisFn } = await import('./xy_vis_fn'); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts index 292e69988c37e..e879f33b76548 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts @@ -23,8 +23,11 @@ import { hasHistogramBarLayer, validateExtent, validateFillOpacity, + validateMarkSizeRatioLimits, validateValueLabels, validateMinTimeBarInterval, + validateMarkSizeForChartType, + validateMarkSizeRatioWithAccessor, } from './validate'; const createDataLayer = (args: XYArgs, table: Datatable): DataLayerConfigResult => { @@ -63,6 +66,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { isHistogram, yConfig, palette, + markSizeAccessor, ...restArgs } = args; @@ -72,6 +76,9 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { validateAccessor(dataLayers[0].splitAccessor, data.columns); dataLayers[0].accessors.forEach((accessor) => validateAccessor(accessor, data.columns)); + validateMarkSizeForChartType(dataLayers[0].markSizeAccessor, args.seriesType); + validateAccessor(dataLayers[0].markSizeAccessor, data.columns); + const layers: XYLayerConfig[] = [ ...appendLayerIds(dataLayers, 'dataLayers'), ...appendLayerIds(referenceLineLayers, 'referenceLineLayers'), @@ -105,6 +112,8 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { const hasNotHistogramBars = !hasHistogramBarLayer(dataLayers); validateValueLabels(args.valueLabels, hasBar, hasNotHistogramBars); + validateMarkSizeRatioWithAccessor(args.markSizeRatio, dataLayers[0].markSizeAccessor); + validateMarkSizeRatioLimits(args.markSizeRatio); return { type: 'render', @@ -113,6 +122,8 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { args: { ...restArgs, layers, + markSizeRatio: + dataLayers[0].markSizeAccessor && !args.markSizeRatio ? 10 : args.markSizeRatio, ariaLabel: args.ariaLabel ?? (handlers.variables?.embeddableTitle as string) ?? diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts index 23aa8bd3218d2..b70211e4b0682 100644 --- a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts @@ -35,19 +35,24 @@ export function getDataLayers(layers: XYExtendedLayerConfigResult[]) { ); } -export function getAccessors( - args: U, - table: Datatable -) { +export function getAccessors< + T, + U extends { splitAccessor?: T; xAccessor?: T; accessors: T[]; markSizeAccessor?: T } +>(args: U, table: Datatable) { let splitAccessor: T | string | undefined = args.splitAccessor; let xAccessor: T | string | undefined = args.xAccessor; let accessors: Array = args.accessors ?? []; - if (!splitAccessor && !xAccessor && !(accessors && accessors.length)) { + let markSizeAccessor: T | string | undefined = args.markSizeAccessor; + + if (!splitAccessor && !xAccessor && !(accessors && accessors.length) && !markSizeAccessor) { const y = table.columns.find((column) => column.id === PointSeriesColumnNames.Y)?.id; xAccessor = table.columns.find((column) => column.id === PointSeriesColumnNames.X)?.id; splitAccessor = table.columns.find((column) => column.id === PointSeriesColumnNames.COLOR)?.id; accessors = y ? [y] : []; + markSizeAccessor = table.columns.find( + (column) => column.id === PointSeriesColumnNames.SIZE + )?.id; } - return { splitAccessor, xAccessor, accessors }; + return { splitAccessor, xAccessor, accessors, markSizeAccessor }; } diff --git a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx index 21230643fe078..f3425ec2db625 100644 --- a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx +++ b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx @@ -121,6 +121,10 @@ export const strings = { i18n.translate('expressionXY.xyVis.ariaLabel.help', { defaultMessage: 'Specifies the aria label of the xy chart', }), + getMarkSizeRatioHelp: () => + i18n.translate('expressionXY.xyVis.markSizeRatio.help', { + defaultMessage: 'Specifies the ratio of the dots at the line and area charts', + }), getMinTimeBarIntervalHelp: () => i18n.translate('expressionXY.xyVis.xAxisInterval.help', { defaultMessage: 'Specifies the min interval for time bar chart', @@ -169,6 +173,10 @@ export const strings = { i18n.translate('expressionXY.dataLayer.accessors.help', { defaultMessage: 'The columns to display on the y axis.', }), + getMarkSizeAccessorHelp: () => + i18n.translate('expressionXY.dataLayer.markSizeAccessor.help', { + defaultMessage: 'Mark size accessor', + }), getYConfigHelp: () => i18n.translate('expressionXY.dataLayer.yConfig.help', { defaultMessage: 'Additional configuration for y axes', diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts index a9910032699e0..0e10f680811ec 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts @@ -100,6 +100,7 @@ export interface DataLayerArgs { xAccessor?: string | ExpressionValueVisDimension; hide?: boolean; splitAccessor?: string | ExpressionValueVisDimension; + markSizeAccessor?: string | ExpressionValueVisDimension; columnToLabel?: string; // Actually a JSON key-value pair xScaleType: XScaleType; isHistogram: boolean; @@ -118,10 +119,12 @@ export interface ExtendedDataLayerArgs { xAccessor?: string; hide?: boolean; splitAccessor?: string; + markSizeAccessor?: string; columnToLabel?: string; // Actually a JSON key-value pair xScaleType: XScaleType; isHistogram: boolean; palette: PaletteOutput; + // palette will always be set on the expression yConfig?: YConfigResult[]; table?: Datatable; } @@ -203,6 +206,7 @@ export interface XYArgs extends DataLayerArgs { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + markSizeRatio?: number; minTimeBarInterval?: string; splitRowAccessor?: ExpressionValueVisDimension | string; splitColumnAccessor?: ExpressionValueVisDimension | string; @@ -231,6 +235,7 @@ export interface LayeredXYArgs { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + markSizeRatio?: number; minTimeBarInterval?: string; } @@ -257,6 +262,7 @@ export interface XYProps { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + markSizeRatio?: number; minTimeBarInterval?: string; splitRowAccessor?: ExpressionValueVisDimension | string; splitColumnAccessor?: ExpressionValueVisDimension | string; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap index 0bc41100012de..e7a26ec20bbfc 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap @@ -324,6 +324,7 @@ exports[`XYChart component it renders area 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -645,7 +646,7 @@ exports[`XYChart component it renders area 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -735,7 +736,7 @@ exports[`XYChart component it renders area 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -868,6 +869,7 @@ exports[`XYChart component it renders bar 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -1189,7 +1191,7 @@ exports[`XYChart component it renders bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -1279,7 +1281,7 @@ exports[`XYChart component it renders bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -1412,6 +1414,7 @@ exports[`XYChart component it renders horizontal bar 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -1733,7 +1736,7 @@ exports[`XYChart component it renders horizontal bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -1823,7 +1826,7 @@ exports[`XYChart component it renders horizontal bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -1956,6 +1959,7 @@ exports[`XYChart component it renders line 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -2277,7 +2281,7 @@ exports[`XYChart component it renders line 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -2367,7 +2371,7 @@ exports[`XYChart component it renders line 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -2500,6 +2504,7 @@ exports[`XYChart component it renders stacked area 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -2821,7 +2826,7 @@ exports[`XYChart component it renders stacked area 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -2911,7 +2916,7 @@ exports[`XYChart component it renders stacked area 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -3044,6 +3049,7 @@ exports[`XYChart component it renders stacked bar 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -3365,7 +3371,7 @@ exports[`XYChart component it renders stacked bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -3455,7 +3461,7 @@ exports[`XYChart component it renders stacked bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -3588,6 +3594,7 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -3909,7 +3916,7 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -3999,7 +4006,7 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -4132,6 +4139,7 @@ exports[`XYChart component split chart should render split chart if both, splitR "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -4210,7 +4218,7 @@ exports[`XYChart component split chart should render split chart if both, splitR }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -4708,7 +4716,7 @@ exports[`XYChart component split chart should render split chart if both, splitR }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -4798,7 +4806,7 @@ exports[`XYChart component split chart should render split chart if both, splitR }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -4931,6 +4939,7 @@ exports[`XYChart component split chart should render split chart if splitColumnA "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -5009,7 +5018,7 @@ exports[`XYChart component split chart should render split chart if splitColumnA }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -5506,7 +5515,7 @@ exports[`XYChart component split chart should render split chart if splitColumnA }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -5596,7 +5605,7 @@ exports[`XYChart component split chart should render split chart if splitColumnA }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -5729,6 +5738,7 @@ exports[`XYChart component split chart should render split chart if splitRowAcce "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -5807,7 +5817,7 @@ exports[`XYChart component split chart should render split chart if splitRowAcce }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -6304,7 +6314,7 @@ exports[`XYChart component split chart should render split chart if splitRowAcce }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -6394,7 +6404,7 @@ exports[`XYChart component split chart should render split chart if splitRowAcce }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx index 62f23ba86a166..d03a5e648f366 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx @@ -13,6 +13,7 @@ import { AreaSeries, Axis, BarSeries, + ColorVariant, Fit, GeometryValue, GroupBy, @@ -687,6 +688,40 @@ describe('XYChart component', () => { expect(component.find(Settings).at(0).prop('showLegendExtra')).toEqual(true); }); + test('applies the mark size ratio', () => { + const { args } = sampleArgs(); + const markSizeRatioArg = { markSizeRatio: 50 }; + const component = shallow( + + ); + expect(component.find(Settings).at(0).prop('theme')).toEqual( + expect.objectContaining(markSizeRatioArg) + ); + }); + + test('applies the mark size accessor', () => { + const { args } = sampleArgs(); + const markSizeAccessorArg = { markSizeAccessor: 'b' }; + const component = shallow( + + ); + const dataLayers = component.find(DataLayers).dive(); + const lineArea = dataLayers.find(LineSeries).at(0); + expect(lineArea.prop('markSizeAccessor')).toEqual(markSizeAccessorArg.markSizeAccessor); + const expectedSeriesStyle = expect.objectContaining({ + point: expect.objectContaining({ + visible: true, + fill: ColorVariant.Series, + }), + }); + + expect(lineArea.prop('areaSeriesStyle')).toEqual(expectedSeriesStyle); + expect(lineArea.prop('lineSeriesStyle')).toEqual(expectedSeriesStyle); + }); + test('it renders bar', () => { const { args } = sampleArgs(); const component = shallow( @@ -2132,6 +2167,7 @@ describe('XYChart component', () => { mode: 'full', type: 'axisExtentConfig', }, + markSizeRatio: 1, layers: [ { layerId: 'first', @@ -2219,6 +2255,7 @@ describe('XYChart component', () => { mode: 'full', type: 'axisExtentConfig', }, + markSizeRatio: 1, yLeftScale: 'linear', yRightScale: 'linear', layers: [ @@ -2292,6 +2329,7 @@ describe('XYChart component', () => { mode: 'full', type: 'axisExtentConfig', }, + markSizeRatio: 1, yLeftScale: 'linear', yRightScale: 'linear', layers: [ diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx index 7b31112c4b9ed..9bb3ea4f498e4 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx @@ -48,17 +48,15 @@ import { getAnnotationsLayers, getDataLayers, Series, - getFormattedTablesByLayers, - validateExtent, getFormat, -} from '../helpers'; -import { + getFormattedTablesByLayers, getFilteredLayers, getReferenceLayers, isDataLayer, getAxesConfiguration, GroupsConfiguration, getLinesCausedPaddings, + validateExtent, } from '../helpers'; import { getXDomain, XyEndzones } from './x_domain'; import { getLegendAction } from './legend_action'; @@ -571,6 +569,7 @@ export function XYChart({ shouldRotate ), }, + markSizeRatio: args.markSizeRatio, }} baseTheme={chartBaseTheme} tooltip={{ diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx b/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx index c2a7c847e150b..7ac661ed9709d 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx @@ -226,9 +226,14 @@ const getSeriesName: GetSeriesNameFn = ( return splitColumnId ? data.seriesKeys[0] : columnToLabelMap[data.seriesKeys[0]] ?? null; }; -const getPointConfig = (xAccessor?: string, emphasizeFitting?: boolean) => ({ - visible: !xAccessor, +const getPointConfig = ( + xAccessor: string | undefined, + markSizeAccessor: string | undefined, + emphasizeFitting?: boolean +) => ({ + visible: !xAccessor || markSizeAccessor !== undefined, radius: xAccessor && !emphasizeFitting ? 5 : 0, + fill: markSizeAccessor ? ColorVariant.Series : undefined, }); const getLineConfig = () => ({ visible: true, stroke: ColorVariant.Series, opacity: 1, dash: [] }); @@ -276,7 +281,7 @@ export const getSeriesProps: GetSeriesPropsFn = ({ fillOpacity, formattedDatatableInfo, }): SeriesSpec => { - const { table } = layer; + const { table, markSizeAccessor } = layer; const isStacked = layer.seriesType.includes('stacked'); const isPercentage = layer.seriesType.includes('percentage'); const isBarChart = layer.seriesType.includes('bar'); @@ -294,6 +299,14 @@ export const getSeriesProps: GetSeriesPropsFn = ({ : undefined; const splitFormatter = formatFactory(splitHint); + const markSizeColumnId = markSizeAccessor + ? getAccessorByDimension(markSizeAccessor, table.columns) + : undefined; + + const markFormatter = formatFactory( + markSizeAccessor ? getFormat(table.columns, markSizeAccessor) : undefined + ); + // what if row values are not primitive? That is the case of, for instance, Ranges // remaps them to their serialized version with the formatHint metadata // In order to do it we need to make a copy of the table as the raw one is required for more features (filters, etc...) later on @@ -326,6 +339,8 @@ export const getSeriesProps: GetSeriesPropsFn = ({ id: splitColumnId ? `${splitColumnId}-${accessor}` : accessor, xAccessor: xColumnId || 'unifiedX', yAccessors: [accessor], + markSizeAccessor: markSizeColumnId, + markFormat: (value) => markFormatter.convert(value), data: rows, xScaleType: xColumnId ? layer.xScaleType : 'ordinal', yScaleType: @@ -346,14 +361,14 @@ export const getSeriesProps: GetSeriesPropsFn = ({ stackMode: isPercentage ? StackMode.Percentage : undefined, timeZone, areaSeriesStyle: { - point: getPointConfig(xColumnId, emphasizeFitting), + point: getPointConfig(xColumnId, markSizeColumnId, emphasizeFitting), ...(fillOpacity && { area: { opacity: fillOpacity } }), ...(emphasizeFitting && { fit: { area: { opacity: fillOpacity || 0.5 }, line: getLineConfig() }, }), }, lineSeriesStyle: { - point: getPointConfig(xColumnId, emphasizeFitting), + point: getPointConfig(xColumnId, markSizeColumnId, emphasizeFitting), ...(emphasizeFitting && { fit: { line: getLineConfig() } }), }, name(d) { From e2064ae5b1822f7642886bd749d52993fdc0451b Mon Sep 17 00:00:00 2001 From: Michael Dokolin Date: Thu, 19 May 2022 15:38:05 +0200 Subject: [PATCH 24/37] [Screenshotting] Fix failing screenshotting functional test (#132393) --- x-pack/test/examples/screenshotting/index.ts | 5 ++--- .../baseline/screenshotting_example_image.png | Bin 0 -> 10576 bytes 2 files changed, 2 insertions(+), 3 deletions(-) create mode 100644 x-pack/test/functional/screenshots/baseline/screenshotting_example_image.png diff --git a/x-pack/test/examples/screenshotting/index.ts b/x-pack/test/examples/screenshotting/index.ts index c64d84c7fcf3d..94a29f382a771 100644 --- a/x-pack/test/examples/screenshotting/index.ts +++ b/x-pack/test/examples/screenshotting/index.ts @@ -20,8 +20,7 @@ export default function ({ const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['common']); - // FAILING: https://github.com/elastic/kibana/issues/131190 - describe.skip('Screenshotting Example', function () { + describe('Screenshotting Example', function () { before(async () => { await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/visualize.json'); @@ -71,7 +70,7 @@ export default function ({ const memory = await testSubjects.find('cpu'); const text = await memory.getVisibleText(); - expect(text).to.match(/\d+\.\d+%/); + expect(text).to.match(/\d+(\.\d+)?%/); }); it('should show an error message', async () => { diff --git a/x-pack/test/functional/screenshots/baseline/screenshotting_example_image.png b/x-pack/test/functional/screenshots/baseline/screenshotting_example_image.png new file mode 100644 index 0000000000000000000000000000000000000000..e4d80a167ae799a73723888745f2025756127c46 GIT binary patch literal 10576 zcmeHtWn7bg+qZ=+{1FgQLSVFlf`D`g!iXUtAW|wNARrA=Vt|6c*a*of9V0~PQjv~H z_fWdKYtJ$N*YmmG-!Gr%db7{i#(DnY_N=C3$LF~ zD`OBmx`&Y%L=Ep1BqsSj1^LHW|NZfQSL6S=+K7l2fQhsT`#WUHWvJyD($`$;qDaetxh?UR1{I=Z^~KGkO73*`|CY;qOZ21QP*n+p%C{m+>RGRP~p=bN87SlS)_RqDcr$YAAI+&zNnluhIw z`C}s8SRa-&)tSK_W$bwP=!p$W%W2dF0sY20Q<*>SJ2W^%MFX~1=~u9%EwO?E7elXQ z3P#Vl8{v@)V;##lOF`{fXcj_o%;u{GIJMR2t+XkbZnqZYraFw?QH#6zX1qzyaVCRnc@?++ z8edv^HdfUBQ8&Kiz5A||zsWOZOy^yg>x+#!V)e$n#8lrG2?`2|PE}6qStiG;nzWx` z6tF8lQq^Op2TRy3<9tr9w@=>;U~CVHH#cvN7scUwNH<>MS6ij9&l}8s_uJ$kY<|8C zgJnfzd^(26INW6}X!yM0S*y6|#diuXy|W)w_`?=xK4KjxT>O}s8DlKviH7yj!$}9A zg}rOlczJn02w5`gds9li_FiA~MTm)s&0Xb7N=!VDwC3i4D+=3phY#47z4PwsGIw9^ z^1be}r{L)11YsZ^^Ou3Cw54z3$IFX|J_}`K<~vYm%avt2Aac{H{R2Kf-;tLutG=np+(g24 zE*W-fY%DqK-^<ZbF z)iL=`PUXyLSRc>L1@lE>#oSO?RE6iJmY$wcS)uKdwnVWk-RwWrnx^$;f@ZeF@;3}P zLZaY;CXc z>Eg!>dIrDwd670^#TI(y_C`MK7EhGMy8r&Jv^CaoYAo65!%Yv~loW}QnZ~AurY3fq zUMTu@)!dckUX8wDE1`h3g_<=6A^ietVXg#s$$)h!TBNmd!*1mZgfKdzLpm)pGcZ*> z1|1cpYHZB!;ZZU+K92eK4<#zeKq!W`au1>Y0-L5xovdgvC(5R%++-w{)6i*Mv}CUR z3=(nPY`%P@~qtk0T19**-R z_U&o(eNGCWOd#=j@2sV2*PVa9x$q9bq%7MmXS?qo+#D^0o0+*3dClZ|oY-o5c6Lf? zsy~mmW!|$tj|HR8!md233Djwhk&5CDOwY)8H1n0=s$ua5yvuC&7rUY3oa!+uii#A% z!oq6N;;-JEp~uoGQh%`|m~O8`%-QVkoO=p;2?#FobnxfaGEHBht%SMxcz8sFwsB<; z9iLuUfyKNy9As`jzBFPWUDhqvr8Yy)b^DN z`2oxm(~HSv{Mm8b+M+e)wviE2SC_CF?nY4XWzo;(O}YrCa?GapVdRPI%l12mC7K$0 zVev$X?y>5zMR|7nci{3ZgC(ocx2$;8FNsbZoM5)tuMJjDOUicKsIS7%^fr8F!Zurxa?i842*(@t%D zSQo;yHy_c{SL$-vpz!JF7Xs%O=RU{U_Y6t_n2o&yDh(m;+DWL5(8f&O?(BhY(Ar@1 zhdnpuSi_XjDjX$~#1p-xwh}_AzNoD3?2Fsqxi9cPXtwe7#llM7*!qwZ=KSomvUvLX z!q6)gqQ~XM%FV;?{z>os5=D@XRRC~_7Bm;1?=Sq6Z0d<=K*$`lG3`y>mN6%mQ#8j2 z$Ij-I1;*NKi3pI^h1uA04!Y?Ey*i3`_yp1%Mr+lg1>;?92Y!9kr%5GzK&tBq*VHJg z#Y(^OmRedRebi1nPbq`l0Q2(urOy}Va6xMpqp`Dz54s>BQB@)uHmuw9ViX~fhZHHl`9$d0_jb>BGhO< zJ58cQ@WaXu4%7|z0%Kw>i9GF9uES6wl+n1TGYDnGc>tf4EG1?HKtxYfB}zPgq>)d+ za%*Yz;>{)Q#-^r>^z__Bk$;j*9db*(-gqP;Vd*EG?}VZU->d)o&`@ zn3_6#Pghsd*toTlmyfTnz=l4l?)~ToxuaGcsdQ4F>%VsvjjLN61brrn%p``_3bIxs0JzS%o)tD5MmDp!Wvg}o>*mpqTyBHFs0 zm&ce$I-kAF;x7CCfyn)-8v`Z2O?dqBA{| z!RNcj)Bf^kV#V`cX$013e8;divOMolgVPaMc^!;Hpz#P|)_M zcIfiKE=yR%*;6ZyPTQ?6!y|=@q_>>vm$dKQQ!uvUpLUKJ8DTsB?ilMa$9Aze=aIXu z(wmP}RK%b4KJrF8FR3V)$YLt*YHQ!-46JWyV6|B!p2lP#G&x6VW9=q2e7)lE_jna` z&CNM7?#0~WZSb2LGLRFDK!|b$;4?F=wK5P5W|*THz&E1Rv;?4|WJALNjM(1ZMw};K z7FsI2H|y%^3X4$VY{wrRY)hYgsutX);}e z`2sn_c#n^dFPj5Z+qP_#S$rzJ?RWjuw7e(gGplB$4t%UXWX{ z!6{R=Xjkey-RyT&zcg-l!Sn6PiV?X%166`fuQxTcDQ)(1VA0`}U3psl?nbX48TTzDv~B*4BL{uq{>YTcY7ni=dQv4`DXtrUApz)T{ZRu`=-_^X`!L)%$sG zfn8rgnnb)ncvHA8_El#WkM)FmH?Y zWS+U%FYmBqdj0GV8Fi+(5z|@c(aE{rW^+RyPPdN_KC5>5YBv8{M_v7#k+_?g zuau`Vm(9W6o6anJ=Ex_~o-hT)w%6o<9y`(`1$b88G7aZ)GY&WUUI;b5)`6Ok1(MKU zCV11j^X)zzJ$;ytab+xiX2#lQZ*(_36F(9yBPEw~rv?_1=L+)N6GDQNYuPt9r?b;6yWXo$z(!CaC*jkK0+ioC`?D_XV9v>SWF^g@*H z=mY+;IX0Bk_Axk4m%HuFd~JHNxw0$?3qazNOG{(AvU--XvvMtcLnT)(QfzNak_K6( zJ2Q?Utxu8%JhY!Ak)Gb$RN2_r=&|;h3)B~lo{^!T}lzR(Fr zH#MYhXX#!OoBsL9%v_LRXINb?t_Zb!*P-Vz zqV~0$c9jWaV!O|$LfoW)Y6^dGPq2YHU^B&_D5tZjq2W=lRa%}+kKIIbJYhk~^Wx1X zhpL{B$(6b-Hbx=QA9b=WFuL56xg=`+8g8es2A41~q*YYVR5%GIMK4Mob8rrE{uH1p zW^M7xUoR~Mo$PBWP_f4g1T&?$_-#!8gUan-)v4akuSutXA&vi0Vjcw*|0y|Hs^a;E zEOcOxVS2Rlub8DiGfq^Dd;sMY;ACcAlE89RX6&2swsAF?+`+!?T{A*faVMvs*PjHt_cYB3_-iGen zI1B0G_tN#tQ-c}o0F8Ja?bOeYsyTrfe}7q1E<7RyFqx5^{b+YX%XOaE0+e{FyWk9s zbgbH=M~7pJo~Bq~In9D`*Wb~gF8<(v{te`7t8Hl0q3zA2kQ?y`(PPpZy0(gg<_$7Q z8|6|<_T0>Y^#S-|9bY*S!RR0F$fh21b7G(B&KHLc3r@8-wa|qjZh_&JtlSE@8lsA8cw~DjxF<|; zM=+GuuC>PK;_`?8fV0xg4iXgpto}I{y3|%nd;jXuA9rTb~ZC)Lloa`vHy-dc` zD&G~W5N3DU=u&ZJ&pD(tvqXYoP~>&zDTnfnO;LvB4dv&~iz?7jK!}Vlu#nzM2QbSE zMw^Z8vS`}R!Cri}rlWX6(Hvj&;tN4MK8>^|NkJ z*8p8E-Xxxw9x9^;5k49$I{ate?T(hQF$&bKJ9H5Di9Y%##Bzp`k`gm-5v$Wpk-5Lg z+(kEOge?^n`<(^n6KNX4@C`5K#31$jDu&V+G)X~@k$78{9@PZ_Uc1TZ_3~4Kg~oxD?FI4m!0W5Jx~IZx8VKT7nPo)utyB zIHcBtQtYSOA>##ARSn<#_O05JZ!q;2jmo-4EV6NNwKVEv_2oTcXTMVp#t^HF3=VyA zZ{6OPi!0uurm-vT`kH%)F z3j34oJ{r`m1cy;h(AW&pdtM=uD>knc9(K(LKOHI!#-o*&wwwEs+mUu9+s`o#wBQFZ zb{A2>$iZVUy(nrYskFXWS7|B z%ITpXO%`u8aImVYd?-!pyj7qcK@yBXPaUskr<_b`Gi?y4pTq?DcYL)n1!8#@*czyf zqsP}wJC#_pjf`5%qm1j(qoF{Wu^_h1d-Jdvy#{h96lJzS0okL0_C|m?=odV{_Ubs3 zqAuw`=~tPahPd0p&p!d1pFoan=-$&Z(ou&hs^{gsYSt8K`*g54n!VEXqGiI3_ZZ4~ zAc0;Y4uX~m9+#jMn>Jv~uih_yd%4o{T3VbJ8((Cu8Swqd1gGOw9qLla@Ve6=+Z zpe;Q7E3}UGVh2-{xWh>rE)-`3O6b``-yO#OSW2*BGPISRZ_J@!xWSi&6O(VBkj9C{ zmX?aQi&-6K?1D;4``aK?@9F5QVm*CYbD2F3?Qb5etvMxk@Q}>|by1%Hej{<#>EGZp z9lVQ+WAt$y4sDAn18z??YoiP$3E^nwhGvU zbd*==kedW8@UC$mCuojTGrZy1%iCc-g+ zNrbh=OSG3bDS_Lj+8x4_qPF!^5cYXQNoG%4QIYKQ?`%rEtn{WbA1YnP8x;BB$;W{$ z?6-lQ5lT+(Fc{6Sw+<80)%6bwL5PwGz1Go2GeNMZI*K*ffg^meXh|tejk!%Vt*)ub zx-Mehr=p`n_v-k$EAc2zfiUG?lfR7B5+oylv9>2EiSk#N-GVj^o1d3k6BLQj(Q0P~ zJCDrN6g3sBrcvbMKW#~wmYLS)%Tt4K>rn^*PYhkX9VT*gCR!ofI2OcQ`^ zFzW^X35Y+C4cGbp>I2uvh_J3ybw2RL`#_x}NO?BQK$hUW%iQQOnNWk(*z?D?nbv-G zTQvSpW-vckf`y{c*3qds(3-xf-|)|q!E|b1x^#TH`Bji;pu2L1slnjGb`XkR;11?k zH+st4G-5<-ivS}=hkOtWnH?Id>xc13@`1V$}&hQmD3Tm9yof$$OZd&nz=a=TJ z#1krgfB;7MxE^iFATo!DxoOq>ay#gL3MzKKf z3E09ug8S>UAM#tY>iJs$HM02mUeGi26D(C=La)&uOH2K^1B<~qCtF#kSjjr!@2wt^ z)1v;h=%;=85g<9lU1sWEB!wVAa{gUS+y2R`#`)oDoQXi7>sZ{*f~hcPLrAC~!@f;7 zo&($P3m_cQ1Az+9^($e@aABS|phZE>*7RA%*UDg_FH3GcIZh=VBcOf%{^lI}yHpI;`1f@3VUhCY|J5 zhx&Vif4@4x_gx}2|0%XY)Xe#{8lY}cC8FW1H{qGMwrB#nCxKw?xDnV9&P=<7?nxi`k2 z;Q7ZKCHco4JjkD?ZigljZ`3iiv>n|QatG7iyCrxHGC1JzR*sI2IGOgG& zBc;UL-BOp?AFBjXuo-Vdv}K%1Du4A)q&5(WahSmPHvNH$j*Bp$9#9d1j}A$9_SPe+ zuo+G00+#`i(?O?8_IKSna*PaA+#((K-Ez&x8@s&34}@lB1Q{Kb1^6qrj{HYZn2(PS zu8vB0Ob`f8V_^YKt@>%J8xrjLU)_1&RE;Ln_>K6LK zwHR$p&F>P6@ zWc`J=q5%Yww>7#Jp`!zx6(m0S&5Q2r2S-jpdFoqICYd5nEmp_`f;9lqo~n_N|DBqe zv(gh!HoCG6=yh`pTP$g~uwZ+0*QY@+c@@lo{1r#C959AR^f-$U`ky1y&Vfhu%Pg26 zM_|Kdu!{;r1qQ0{Gx{)-+Xi^srZZm&9)D`8K6?_FAAEg+0+$pNh$UbNBxLOcs`Myv zD{g%dKc9*Eia7uJpW@%M zlY%ZYXW^Q1Wvg6dY3(&t1h4Yt)l}N5m()SiS27ljx-n*`+&z#}rnU<|$uc}QOl`&PCMv+5%VE1=|F0|CEpkEy8?Cid7wQ{r2 ze&9Q(%3HI|9ozx}X~SVAqmV6pG$`V~YEp>_jumkj{9Jc;aWS{5q1a)RXQ%{gBJCv; z92n#=))Ok^xgZHlLJe@v{oqYiTcwdff1wrmiGg|=jlD1+7La#3ZRn>7IdwCFK6Re^CtJN8ZUqD&qRdEO2ukvBvrr5p-!8_h|!0qn+ zyRtq$%qO&TjZr?v(ufz_AQA&e1`y4WGXrS))bcNZ&1r(02e~OmM_(UFs|Zpba(KYD zl)Q9w;tzRBGssqG>FKdiQBA&<5dT3~9t<0n7O>or5Pk zD%HTG+p>g5NC+|{L5K}UW+!p4l3(dT#L5Mq_IS;nUK=NSV9+aNG5>u?>%UK-{r~bz c-o60WFC~`rM0YoMQt}W|5v7nL|M2Di0V!)P%>V!Z literal 0 HcmV?d00001 From 2cbedcd29c361e0f05021ef9fdae7c43d236b529 Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Thu, 19 May 2022 07:54:36 -0600 Subject: [PATCH 25/37] [Observability] Use Observability rule type registry for list of rule types (#132484) --- .../observability/public/hooks/use_fetch_rules.ts | 14 ++++++++------ .../alerts/containers/alerts_page/alerts_page.tsx | 5 ++--- .../observability/public/pages/rules/config.ts | 14 -------------- .../create_observability_rule_type_registry.ts | 1 + 4 files changed, 11 insertions(+), 23 deletions(-) diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts index 229a54c754e4f..b8c3445fffabc 100644 --- a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts @@ -10,8 +10,8 @@ import { isEmpty } from 'lodash'; import { loadRules, loadRuleTags } from '@kbn/triggers-actions-ui-plugin/public'; import { RULES_LOAD_ERROR, RULE_TAGS_LOAD_ERROR } from '../pages/rules/translations'; import { FetchRulesProps, RuleState, TagsState } from '../pages/rules/types'; -import { OBSERVABILITY_RULE_TYPES } from '../pages/rules/config'; import { useKibana } from '../utils/kibana_react'; +import { usePluginContext } from './use_plugin_context'; export function useFetchRules({ searchText, @@ -24,6 +24,7 @@ export function useFetchRules({ sort, }: FetchRulesProps) { const { http } = useKibana().services; + const { observabilityRuleTypeRegistry } = usePluginContext(); const [rulesState, setRulesState] = useState({ isLoading: false, @@ -60,7 +61,7 @@ export function useFetchRules({ http, page, searchText, - typesFilter: typesFilter.length > 0 ? typesFilter : OBSERVABILITY_RULE_TYPES, + typesFilter: typesFilter.length > 0 ? typesFilter : observabilityRuleTypeRegistry.list(), tagsFilter, ruleExecutionStatusesFilter: ruleLastResponseFilter, ruleStatusesFilter, @@ -93,14 +94,15 @@ export function useFetchRules({ }, [ http, page, - setPage, searchText, - ruleLastResponseFilter, + typesFilter, + observabilityRuleTypeRegistry, tagsFilter, - loadRuleTagsAggs, + ruleLastResponseFilter, ruleStatusesFilter, - typesFilter, sort, + loadRuleTagsAggs, + setPage, ]); useEffect(() => { fetchRules(); diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx index 8838ccd2ac56f..f51d00787c822 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx @@ -38,7 +38,6 @@ import './styles.scss'; import { AlertsStatusFilter, AlertsDisclaimer, AlertsSearchBar } from '../../components'; import { renderRuleStats } from '../../components/rule_stats'; import { ObservabilityAppServices } from '../../../../application/types'; -import { OBSERVABILITY_RULE_TYPES } from '../../../rules/config'; interface RuleStatsState { total: number; @@ -69,7 +68,7 @@ const ALERT_STATUS_REGEX = new RegExp( const ALERT_TABLE_STATE_STORAGE_KEY = 'xpack.observability.alert.tableState'; function AlertsPage() { - const { ObservabilityPageTemplate, config } = usePluginContext(); + const { ObservabilityPageTemplate, config, observabilityRuleTypeRegistry } = usePluginContext(); const [alertFilterStatus, setAlertFilterStatus] = useState('' as AlertStatusFilterButton); const refetch = useRef<() => void>(); const timefilterService = useTimefilterService(); @@ -110,7 +109,7 @@ function AlertsPage() { try { const response = await loadRuleAggregations({ http, - typesFilter: OBSERVABILITY_RULE_TYPES, + typesFilter: observabilityRuleTypeRegistry.list(), }); const { ruleExecutionStatus, ruleMutedStatus, ruleEnabledStatus, ruleSnoozedStatus } = response; diff --git a/x-pack/plugins/observability/public/pages/rules/config.ts b/x-pack/plugins/observability/public/pages/rules/config.ts index 4e7b9e83d5ab1..de3ef1219fde7 100644 --- a/x-pack/plugins/observability/public/pages/rules/config.ts +++ b/x-pack/plugins/observability/public/pages/rules/config.ts @@ -42,20 +42,6 @@ export const rulesStatusesTranslationsMapping = { warning: RULE_STATUS_WARNING, }; -export const OBSERVABILITY_RULE_TYPES = [ - 'xpack.uptime.alerts.monitorStatus', - 'xpack.uptime.alerts.tls', - 'xpack.uptime.alerts.tlsCertificate', - 'xpack.uptime.alerts.durationAnomaly', - 'apm.error_rate', - 'apm.transaction_error_rate', - 'apm.anomaly', - 'apm.transaction_duration', - 'metrics.alert.inventory.threshold', - 'metrics.alert.threshold', - 'logs.alert.document.count', -]; - export const OBSERVABILITY_SOLUTIONS = ['logs', 'uptime', 'infrastructure', 'apm']; export type InitialRule = Partial & diff --git a/x-pack/plugins/observability/public/rules/create_observability_rule_type_registry.ts b/x-pack/plugins/observability/public/rules/create_observability_rule_type_registry.ts index 5612601ebd803..021203e832441 100644 --- a/x-pack/plugins/observability/public/rules/create_observability_rule_type_registry.ts +++ b/x-pack/plugins/observability/public/rules/create_observability_rule_type_registry.ts @@ -35,6 +35,7 @@ export function createObservabilityRuleTypeRegistry(ruleTypeRegistry: RuleTypeRe getFormatter: (typeId: string) => { return formatters.find((formatter) => formatter.typeId === typeId)?.fn; }, + list: () => formatters.map((formatter) => formatter.typeId), }; } From 51acefc2e2ce8fdfcc26376143c9cbf263f54734 Mon Sep 17 00:00:00 2001 From: Caroline Horn <549577+cchaos@users.noreply.github.com> Date: Thu, 19 May 2022 10:01:13 -0400 Subject: [PATCH 26/37] [KibanaPageTemplateSolutionNavAvatar] Increase specificity of styles (#132448) --- .../public/page_template/solution_nav/solution_nav_avatar.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/kibana_react/public/page_template/solution_nav/solution_nav_avatar.scss b/src/plugins/kibana_react/public/page_template/solution_nav/solution_nav_avatar.scss index 4b47fefc65891..73b4241c8a18b 100644 --- a/src/plugins/kibana_react/public/page_template/solution_nav/solution_nav_avatar.scss +++ b/src/plugins/kibana_react/public/page_template/solution_nav/solution_nav_avatar.scss @@ -1,7 +1,7 @@ .kbnPageTemplateSolutionNavAvatar { @include euiBottomShadowSmall; - &--xxl { + &.kbnPageTemplateSolutionNavAvatar--xxl { @include euiBottomShadowMedium; @include size(100px); line-height: 100px; From d2b61738e2b086a62944ee822670821220b3ad81 Mon Sep 17 00:00:00 2001 From: Vitalii Dmyterko <92328789+vitaliidm@users.noreply.github.com> Date: Thu, 19 May 2022 15:09:31 +0100 Subject: [PATCH 27/37] [Security solution]Dynamic split of cypress tests (#125986) - adds `parallelism: 4` for security_solution cypress buildkite pipeline - added parsing /integrations folder with cypress tests, to retrieve paths to individual test files using `glob` utility - list of test files split equally between agents(there are approx 70+ tests files, split ~20 per job with **parallelism=4**) - small refactoring of existing cypress runners for `security_solution` Old metrics(before @MadameSheema https://github.com/elastic/kibana/pull/127558 performance improvements): before split: average time of completion ~ 1h 40m for tests, 1h 55m for Kibana build after split in 4 chunks: chunk completion between 20m - 30m, Kibana build 1h 20m **Current metrics:** before split: average time of completion ~ 1h for tests, 1h 10m for Kibana build after split in 4 chunks: each chunk completion between 10m - 20m, 1h Kibana build 1h --- .buildkite/ftr_configs.yml | 1 + .../pull_request/security_solution.yml | 1 + .../steps/functional/security_solution.sh | 6 +- .../security_solution/cypress/README.md | 12 ++ .../integration/users/user_details.spec.ts | 5 +- x-pack/plugins/security_solution/package.json | 3 +- .../cli_config_parallel.ts | 25 ++++ .../test/security_solution_cypress/runner.ts | 140 ++++++------------ 8 files changed, 90 insertions(+), 103 deletions(-) create mode 100644 x-pack/test/security_solution_cypress/cli_config_parallel.ts diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index f07ac997e31c2..e070baa844ea9 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -26,6 +26,7 @@ disabled: - x-pack/test/security_solution_cypress/cases_cli_config.ts - x-pack/test/security_solution_cypress/ccs_config.ts - x-pack/test/security_solution_cypress/cli_config.ts + - x-pack/test/security_solution_cypress/cli_config_parallel.ts - x-pack/test/security_solution_cypress/config.firefox.ts - x-pack/test/security_solution_cypress/config.ts - x-pack/test/security_solution_cypress/response_ops_cli_config.ts diff --git a/.buildkite/pipelines/pull_request/security_solution.yml b/.buildkite/pipelines/pull_request/security_solution.yml index 974469a700715..5903aac568a83 100644 --- a/.buildkite/pipelines/pull_request/security_solution.yml +++ b/.buildkite/pipelines/pull_request/security_solution.yml @@ -5,6 +5,7 @@ steps: queue: ci-group-6 depends_on: build timeout_in_minutes: 120 + parallelism: 4 retry: automatic: - exit_status: '*' diff --git a/.buildkite/scripts/steps/functional/security_solution.sh b/.buildkite/scripts/steps/functional/security_solution.sh index ae81eaa4f48e2..5e3b1513826f9 100755 --- a/.buildkite/scripts/steps/functional/security_solution.sh +++ b/.buildkite/scripts/steps/functional/security_solution.sh @@ -5,11 +5,13 @@ set -euo pipefail source .buildkite/scripts/steps/functional/common.sh export JOB=kibana-security-solution-chrome +export CLI_NUMBER=$((BUILDKITE_PARALLEL_JOB+1)) +export CLI_COUNT=$BUILDKITE_PARALLEL_JOB_COUNT echo "--- Security Solution tests (Chrome)" -checks-reporter-with-killswitch "Security Solution Cypress Tests (Chrome)" \ +checks-reporter-with-killswitch "Security Solution Cypress Tests (Chrome) $CLI_NUMBER" \ node scripts/functional_tests \ --debug --bail \ --kibana-install-dir "$KIBANA_BUILD_LOCATION" \ - --config x-pack/test/security_solution_cypress/cli_config.ts + --config x-pack/test/security_solution_cypress/cli_config_parallel.ts diff --git a/x-pack/plugins/security_solution/cypress/README.md b/x-pack/plugins/security_solution/cypress/README.md index e0430ea332e99..620a2148f6cf7 100644 --- a/x-pack/plugins/security_solution/cypress/README.md +++ b/x-pack/plugins/security_solution/cypress/README.md @@ -64,6 +64,18 @@ A headless browser is a browser simulation program that does not have a user int This is the configuration used by CI. It uses the FTR to spawn both a Kibana instance (http://localhost:5620) and an Elasticsearch instance (http://localhost:9220) with a preloaded minimum set of data (see preceding "Test data" section), and then executes cypress against this stack. You can find this configuration in `x-pack/test/security_solution_cypress` +Tests run on buildkite PR pipeline is parallelized(current value = 4 parallel jobs). It can be configured in [.buildkite/pipelines/pull_request/security_solution.yml](https://github.com/elastic/kibana/blob/main/.buildkite/pipelines/pull_request/security_solution.yml) with property `parallelism` + +```yml + ... + agents: + queue: ci-group-6 + depends_on: build + timeout_in_minutes: 120 + parallelism: 4 + ... +``` + #### Custom Targets This configuration runs cypress tests against an arbitrary host. diff --git a/x-pack/plugins/security_solution/cypress/integration/users/user_details.spec.ts b/x-pack/plugins/security_solution/cypress/integration/users/user_details.spec.ts index c1b4a81e14d0a..83eae1d259b2c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/users/user_details.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/users/user_details.spec.ts @@ -24,13 +24,14 @@ describe('user details flyout', () => { before(() => { cleanKibana(); login(); + }); + + it('shows user detail flyout from alert table', () => { visitWithoutDateRange(ALERTS_URL); createCustomRuleEnabled({ ...getNewRule(), customQuery: 'user.name:*' }); refreshPage(); waitForAlertsToPopulate(); - }); - it('shows user detail flyout from alert table', () => { scrollAlertTableColumnIntoView(USER_COLUMN); expandAlertTableCellValue(USER_COLUMN); openUserDetailsFlyout(); diff --git a/x-pack/plugins/security_solution/package.json b/x-pack/plugins/security_solution/package.json index b62b6d08fd892..8853cb9aa582c 100644 --- a/x-pack/plugins/security_solution/package.json +++ b/x-pack/plugins/security_solution/package.json @@ -13,12 +13,13 @@ "cypress:open-as-ci": "node ../../../scripts/functional_tests --config ../../test/security_solution_cypress/visual_config.ts", "cypress:open:upgrade": "yarn cypress:open --config integrationFolder=./cypress/upgrade_integration", "cypress:run": "yarn cypress:run:reporter --browser chrome --spec './cypress/integration/**/*.spec.ts'; status=$?; yarn junit:merge && exit $status", + "cypress:run:spec": "yarn cypress:run:reporter --browser chrome --spec ${SPEC_LIST:-'./cypress/integration/**/*.spec.ts'}; status=$?; yarn junit:merge && exit $status", "cypress:run:cases": "yarn cypress:run:reporter --browser chrome --spec './cypress/integration/cases/*.spec.ts'; status=$?; yarn junit:merge && exit $status", "cypress:run:firefox": "yarn cypress:run:reporter --browser firefox --spec './cypress/integration/**/*.spec.ts'; status=$?; yarn junit:merge && exit $status", "cypress:run:reporter": "yarn cypress run --config-file ./cypress/cypress.json --reporter ../../../node_modules/cypress-multi-reporters --reporter-options configFile=./cypress/reporter_config.json", "cypress:run:respops": "yarn cypress:run:reporter --browser chrome --spec ./cypress/integration/detection_alerts/*.spec.ts,./cypress/integration/detection_rules/*.spec.ts,./cypress/integration/exceptions/*.spec.ts; status=$?; yarn junit:merge && exit $status", "cypress:run:ccs": "yarn cypress:run:reporter --browser chrome --config integrationFolder=./cypress/ccs_integration; status=$?; yarn junit:merge && exit $status", - "cypress:run-as-ci": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/cli_config.ts", + "cypress:run-as-ci": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/cli_config_parallel.ts", "cypress:run-as-ci:firefox": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/config.firefox.ts", "cypress:run:upgrade": "yarn cypress:run:reporter --browser chrome --config integrationFolder=./cypress/upgrade_integration", "cypress:run:upgrade:old": "yarn cypress:run:reporter --browser chrome --config integrationFolder=./cypress/upgrade_integration --spec ./cypress/upgrade_integration/threat_hunting/**/*.spec.ts,./cypress/upgrade_integration/detections/**/custom_query_rule.spec.ts; status=$?; yarn junit:merge && exit $status", diff --git a/x-pack/test/security_solution_cypress/cli_config_parallel.ts b/x-pack/test/security_solution_cypress/cli_config_parallel.ts new file mode 100644 index 0000000000000..20abaed99a1b9 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cli_config_parallel.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; +import { FtrProviderContext } from './ftr_provider_context'; + +import { SecuritySolutionCypressCliTestRunnerCI } from './runner'; + +const cliNumber = parseInt(process.env.CLI_NUMBER ?? '1', 10); +const cliCount = parseInt(process.env.CLI_COUNT ?? '1', 10); + +// eslint-disable-next-line import/no-default-export +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const securitySolutionCypressConfig = await readConfigFile(require.resolve('./config.ts')); + return { + ...securitySolutionCypressConfig.getAll(), + + testRunner: (context: FtrProviderContext) => + SecuritySolutionCypressCliTestRunnerCI(context, cliCount, cliNumber), + }; +} diff --git a/x-pack/test/security_solution_cypress/runner.ts b/x-pack/test/security_solution_cypress/runner.ts index 2c4b69799f1cc..2f4f76de53ced 100644 --- a/x-pack/test/security_solution_cypress/runner.ts +++ b/x-pack/test/security_solution_cypress/runner.ts @@ -5,7 +5,10 @@ * 2.0. */ +import { chunk } from 'lodash'; import { resolve } from 'path'; +import glob from 'glob'; + import Url from 'url'; import { withProcRunner } from '@kbn/dev-proc-runner'; @@ -13,7 +16,22 @@ import { withProcRunner } from '@kbn/dev-proc-runner'; import semver from 'semver'; import { FtrProviderContext } from './ftr_provider_context'; -export async function SecuritySolutionCypressCliTestRunner({ getService }: FtrProviderContext) { +const retrieveIntegrations = (chunksTotal: number, chunkIndex: number) => { + const pattern = resolve( + __dirname, + '../../plugins/security_solution/cypress/integration/**/*.spec.ts' + ); + const integrationsPaths = glob.sync(pattern); + const chunkSize = Math.ceil(integrationsPaths.length / chunksTotal); + + return chunk(integrationsPaths, chunkSize)[chunkIndex - 1]; +}; + +export async function SecuritySolutionConfigurableCypressTestRunner( + { getService }: FtrProviderContext, + command: string, + envVars?: Record +) { const log = getService('log'); const config = getService('config'); const esArchiver = getService('esArchiver'); @@ -23,7 +41,7 @@ export async function SecuritySolutionCypressCliTestRunner({ getService }: FtrPr await withProcRunner(log, async (procs) => { await procs.run('cypress', { cmd: 'yarn', - args: ['cypress:run'], + args: [command], cwd: resolve(__dirname, '../../plugins/security_solution'), env: { FORCE_COLOR: '1', @@ -32,91 +50,42 @@ export async function SecuritySolutionCypressCliTestRunner({ getService }: FtrPr CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), ...process.env, + ...envVars, }, wait: true, }); }); } -export async function SecuritySolutionCypressCliResponseOpsTestRunner({ - getService, -}: FtrProviderContext) { - const log = getService('log'); - const config = getService('config'); - const esArchiver = getService('esArchiver'); - - await esArchiver.load('x-pack/test/security_solution_cypress/es_archives/auditbeat'); - - await withProcRunner(log, async (procs) => { - await procs.run('cypress', { - cmd: 'yarn', - args: ['cypress:run:respops'], - cwd: resolve(__dirname, '../../plugins/security_solution'), - env: { - FORCE_COLOR: '1', - CYPRESS_BASE_URL: Url.format(config.get('servers.kibana')), - CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), - CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), - CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), - ...process.env, - }, - wait: true, - }); +export async function SecuritySolutionCypressCliTestRunnerCI( + context: FtrProviderContext, + totalCiJobs: number, + ciJobNumber: number +) { + const integrations = retrieveIntegrations(totalCiJobs, ciJobNumber); + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:run:spec', { + SPEC_LIST: integrations.join(','), }); } -export async function SecuritySolutionCypressCliCasesTestRunner({ - getService, -}: FtrProviderContext) { - const log = getService('log'); - const config = getService('config'); - const esArchiver = getService('esArchiver'); - - await esArchiver.load('x-pack/test/security_solution_cypress/es_archives/auditbeat'); +export async function SecuritySolutionCypressCliResponseOpsTestRunner(context: FtrProviderContext) { + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:run:respops'); +} - await withProcRunner(log, async (procs) => { - await procs.run('cypress', { - cmd: 'yarn', - args: ['cypress:run:cases'], - cwd: resolve(__dirname, '../../plugins/security_solution'), - env: { - FORCE_COLOR: '1', - CYPRESS_BASE_URL: Url.format(config.get('servers.kibana')), - CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), - CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), - CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), - ...process.env, - }, - wait: true, - }); - }); +export async function SecuritySolutionCypressCliCasesTestRunner(context: FtrProviderContext) { + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:run:cases'); } -export async function SecuritySolutionCypressCliFirefoxTestRunner({ - getService, -}: FtrProviderContext) { - const log = getService('log'); - const config = getService('config'); - const esArchiver = getService('esArchiver'); +export async function SecuritySolutionCypressCliTestRunner(context: FtrProviderContext) { + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:run'); +} - await esArchiver.load('x-pack/test/security_solution_cypress/es_archives/auditbeat'); +export async function SecuritySolutionCypressCliFirefoxTestRunner(context: FtrProviderContext) { + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:run:firefox'); +} - await withProcRunner(log, async (procs) => { - await procs.run('cypress', { - cmd: 'yarn', - args: ['cypress:run:firefox'], - cwd: resolve(__dirname, '../../plugins/security_solution'), - env: { - FORCE_COLOR: '1', - CYPRESS_BASE_URL: Url.format(config.get('servers.kibana')), - CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), - CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), - CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), - ...process.env, - }, - wait: true, - }); - }); +export async function SecuritySolutionCypressVisualTestRunner(context: FtrProviderContext) { + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:open'); } export async function SecuritySolutionCypressCcsTestRunner({ getService }: FtrProviderContext) { @@ -143,31 +112,6 @@ export async function SecuritySolutionCypressCcsTestRunner({ getService }: FtrPr }); } -export async function SecuritySolutionCypressVisualTestRunner({ getService }: FtrProviderContext) { - const log = getService('log'); - const config = getService('config'); - const esArchiver = getService('esArchiver'); - - await esArchiver.load('x-pack/test/security_solution_cypress/es_archives/auditbeat'); - - await withProcRunner(log, async (procs) => { - await procs.run('cypress', { - cmd: 'yarn', - args: ['cypress:open'], - cwd: resolve(__dirname, '../../plugins/security_solution'), - env: { - FORCE_COLOR: '1', - CYPRESS_BASE_URL: Url.format(config.get('servers.kibana')), - CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), - CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), - CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), - ...process.env, - }, - wait: true, - }); - }); -} - export async function SecuritySolutionCypressUpgradeCliTestRunner({ getService, }: FtrProviderContext) { From 14a8997a80613ade9b97e848f3db7ae35e9bc321 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Thu, 19 May 2022 10:13:54 -0400 Subject: [PATCH 28/37] [Response Ops] Use `active/new/recovered` alert counts in event log `execute` doc to populate exec log (#131187) * Using new metrics in event log execute * Returning version from event log docs and updating cell value based on version * Fixing types * Cleanup * Using updated event log fields * importing specific semver function * Moving to library function * Cleanup Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../alerting/common/execution_log_types.ts | 4 + .../lib/get_execution_log_aggregation.test.ts | 248 +++++++++--------- .../lib/get_execution_log_aggregation.ts | 55 ++-- .../routes/get_rule_execution_log.test.ts | 2 + .../server/routes/get_rule_execution_log.ts | 3 + .../tests/get_execution_log.test.ts | 56 ++-- .../public/application/constants/index.ts | 6 + .../components/rule_event_log_data_grid.tsx | 2 + .../components/rule_event_log_list.test.tsx | 4 + ...rule_event_log_list_cell_renderer.test.tsx | 8 + .../rule_event_log_list_cell_renderer.tsx | 9 +- .../lib/format_rule_alert_count.test.ts | 34 +++ .../common/lib/format_rule_alert_count.ts | 23 ++ 13 files changed, 277 insertions(+), 177 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.test.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.ts diff --git a/x-pack/plugins/alerting/common/execution_log_types.ts b/x-pack/plugins/alerting/common/execution_log_types.ts index 4fff1f14ca5bd..cdfc7601190dd 100644 --- a/x-pack/plugins/alerting/common/execution_log_types.ts +++ b/x-pack/plugins/alerting/common/execution_log_types.ts @@ -13,6 +13,9 @@ export const executionLogSortableColumns = [ 'schedule_delay', 'num_triggered_actions', 'num_generated_actions', + 'num_active_alerts', + 'num_recovered_alerts', + 'num_new_alerts', ] as const; export type ExecutionLogSortFields = typeof executionLogSortableColumns[number]; @@ -23,6 +26,7 @@ export interface IExecutionLog { duration_ms: number; status: string; message: string; + version: string; num_active_alerts: number; num_new_alerts: number; num_recovered_alerts: number; diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts index 6927ef86dd47c..f5be4f0fcd34e 100644 --- a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts @@ -83,7 +83,7 @@ describe('getExecutionLogAggregation', () => { sort: [{ notsortable: { order: 'asc' } }], }); }).toThrowErrorMatchingInlineSnapshot( - `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions]"` + `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions,num_active_alerts,num_recovered_alerts,num_new_alerts]"` ); }); @@ -95,7 +95,7 @@ describe('getExecutionLogAggregation', () => { sort: [{ notsortable: { order: 'asc' } }, { timestamp: { order: 'asc' } }], }); }).toThrowErrorMatchingInlineSnapshot( - `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions]"` + `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions,num_active_alerts,num_recovered_alerts,num_new_alerts]"` ); }); @@ -164,15 +164,6 @@ describe('getExecutionLogAggregation', () => { gap_policy: 'insert_zeros', }, }, - alertCounts: { - filters: { - filters: { - newAlerts: { match: { 'event.action': 'new-instance' } }, - activeAlerts: { match: { 'event.action': 'active-instance' } }, - recoveredAlerts: { match: { 'event.action': 'recovered-instance' } }, - }, - }, - }, actionExecution: { filter: { bool: { @@ -216,11 +207,28 @@ describe('getExecutionLogAggregation', () => { field: 'kibana.alert.rule.execution.metrics.number_of_generated_actions', }, }, + numActiveAlerts: { + max: { + field: 'kibana.alert.rule.execution.metrics.alert_counts.active', + }, + }, + numRecoveredAlerts: { + max: { + field: 'kibana.alert.rule.execution.metrics.alert_counts.recovered', + }, + }, + numNewAlerts: { + max: { + field: 'kibana.alert.rule.execution.metrics.alert_counts.new', + }, + }, executionDuration: { max: { field: 'event.duration' } }, outcomeAndMessage: { top_hits: { size: 1, - _source: { includes: ['event.outcome', 'message', 'error.message'] }, + _source: { + includes: ['event.outcome', 'message', 'error.message', 'kibana.version'], + }, }, }, }, @@ -278,20 +286,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 0, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -301,6 +295,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 0.0, + }, outcomeAndMessage: { hits: { total: { @@ -317,6 +320,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -363,20 +369,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -386,6 +378,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -402,6 +403,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -459,6 +463,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 0, @@ -478,6 +483,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -512,20 +518,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 0, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -535,6 +527,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 0.0, + }, outcomeAndMessage: { hits: { total: { @@ -551,6 +552,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'failure', }, + kibana: { + version: '8.2.0', + }, message: "rule execution failure: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", error: { @@ -600,20 +604,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -623,6 +613,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -639,6 +638,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -696,6 +698,7 @@ describe('formatExecutionLogResult', () => { status: 'failure', message: "rule execution failure: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule' - I am erroring in rule execution!!", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 0, @@ -715,6 +718,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -749,20 +753,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 1, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 0, - }, - newAlerts: { - doc_count: 0, - }, - recoveredAlerts: { - doc_count: 0, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -772,6 +762,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 0.0, }, + numActiveAlerts: { + value: 0.0, + }, + numNewAlerts: { + value: 0.0, + }, + numRecoveredAlerts: { + value: 0.0, + }, outcomeAndMessage: { hits: { total: { @@ -788,6 +787,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -829,20 +831,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -852,6 +840,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -868,6 +865,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -925,6 +925,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 0, num_new_alerts: 0, num_recovered_alerts: 0, @@ -944,6 +945,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -978,20 +980,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -1001,6 +989,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -1017,6 +1014,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -1063,20 +1063,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -1086,6 +1072,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -1102,6 +1097,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -1159,6 +1157,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -1178,6 +1177,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts index 03e1077b02eda..aa8a7f6de88cf 100644 --- a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts @@ -20,6 +20,7 @@ const ACTION_FIELD = 'event.action'; const OUTCOME_FIELD = 'event.outcome'; const DURATION_FIELD = 'event.duration'; const MESSAGE_FIELD = 'message'; +const VERSION_FIELD = 'kibana.version'; const ERROR_MESSAGE_FIELD = 'error.message'; const SCHEDULE_DELAY_FIELD = 'kibana.task.schedule_delay'; const ES_SEARCH_DURATION_FIELD = 'kibana.alert.rule.execution.metrics.es_search_duration_ms'; @@ -28,6 +29,10 @@ const NUMBER_OF_TRIGGERED_ACTIONS_FIELD = 'kibana.alert.rule.execution.metrics.number_of_triggered_actions'; const NUMBER_OF_GENERATED_ACTIONS_FIELD = 'kibana.alert.rule.execution.metrics.number_of_generated_actions'; +const NUMBER_OF_ACTIVE_ALERTS_FIELD = 'kibana.alert.rule.execution.metrics.alert_counts.active'; +const NUMBER_OF_NEW_ALERTS_FIELD = 'kibana.alert.rule.execution.metrics.alert_counts.new'; +const NUMBER_OF_RECOVERED_ALERTS_FIELD = + 'kibana.alert.rule.execution.metrics.alert_counts.recovered'; const EXECUTION_UUID_FIELD = 'kibana.alert.rule.execution.uuid'; const Millis2Nanos = 1000 * 1000; @@ -37,14 +42,6 @@ export const EMPTY_EXECUTION_LOG_RESULT = { data: [], }; -interface IAlertCounts extends estypes.AggregationsMultiBucketAggregateBase { - buckets: { - activeAlerts: estypes.AggregationsSingleBucketAggregateBase; - newAlerts: estypes.AggregationsSingleBucketAggregateBase; - recoveredAlerts: estypes.AggregationsSingleBucketAggregateBase; - }; -} - interface IActionExecution extends estypes.AggregationsTermsAggregateBase<{ key: string; doc_count: number }> { buckets: Array<{ key: string; doc_count: number }>; @@ -60,9 +57,11 @@ interface IExecutionUuidAggBucket extends estypes.AggregationsStringTermsBucketK totalSearchDuration: estypes.AggregationsMaxAggregate; numTriggeredActions: estypes.AggregationsMaxAggregate; numGeneratedActions: estypes.AggregationsMaxAggregate; + numActiveAlerts: estypes.AggregationsMaxAggregate; + numRecoveredAlerts: estypes.AggregationsMaxAggregate; + numNewAlerts: estypes.AggregationsMaxAggregate; outcomeAndMessage: estypes.AggregationsTopHitsAggregate; }; - alertCounts: IAlertCounts; actionExecution: { actionOutcomes: IActionExecution; }; @@ -91,6 +90,9 @@ const ExecutionLogSortFields: Record = { schedule_delay: 'ruleExecution>scheduleDelay', num_triggered_actions: 'ruleExecution>numTriggeredActions', num_generated_actions: 'ruleExecution>numGeneratedActions', + num_active_alerts: 'ruleExecution>numActiveAlerts', + num_recovered_alerts: 'ruleExecution>numRecoveredAlerts', + num_new_alerts: 'ruleExecution>numNewAlerts', }; export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLogAggOptions) { @@ -153,16 +155,6 @@ export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLo gap_policy: 'insert_zeros' as estypes.AggregationsGapPolicy, }, }, - // Get counts for types of alerts and whether there was an execution timeout - alertCounts: { - filters: { - filters: { - newAlerts: { match: { [ACTION_FIELD]: 'new-instance' } }, - activeAlerts: { match: { [ACTION_FIELD]: 'active-instance' } }, - recoveredAlerts: { match: { [ACTION_FIELD]: 'recovered-instance' } }, - }, - }, - }, // Filter by action execute doc and get information from this event actionExecution: { filter: getProviderAndActionFilter('actions', 'execute'), @@ -209,6 +201,21 @@ export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLo field: NUMBER_OF_GENERATED_ACTIONS_FIELD, }, }, + numActiveAlerts: { + max: { + field: NUMBER_OF_ACTIVE_ALERTS_FIELD, + }, + }, + numRecoveredAlerts: { + max: { + field: NUMBER_OF_RECOVERED_ALERTS_FIELD, + }, + }, + numNewAlerts: { + max: { + field: NUMBER_OF_NEW_ALERTS_FIELD, + }, + }, executionDuration: { max: { field: DURATION_FIELD, @@ -218,7 +225,7 @@ export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLo top_hits: { size: 1, _source: { - includes: [OUTCOME_FIELD, MESSAGE_FIELD, ERROR_MESSAGE_FIELD], + includes: [OUTCOME_FIELD, MESSAGE_FIELD, ERROR_MESSAGE_FIELD, VERSION_FIELD], }, }, }, @@ -275,15 +282,17 @@ function formatExecutionLogAggBucket(bucket: IExecutionUuidAggBucket): IExecutio status === 'failure' ? `${outcomeAndMessage?.message ?? ''} - ${outcomeAndMessage?.error?.message ?? ''}` : outcomeAndMessage?.message ?? ''; + const version = outcomeAndMessage ? outcomeAndMessage?.kibana?.version ?? '' : ''; return { id: bucket?.key ?? '', timestamp: bucket?.ruleExecution?.executeStartTime.value_as_string ?? '', duration_ms: durationUs / Millis2Nanos, status, message, - num_active_alerts: bucket?.alertCounts?.buckets?.activeAlerts?.doc_count ?? 0, - num_new_alerts: bucket?.alertCounts?.buckets?.newAlerts?.doc_count ?? 0, - num_recovered_alerts: bucket?.alertCounts?.buckets?.recoveredAlerts?.doc_count ?? 0, + version, + num_active_alerts: bucket?.ruleExecution?.numActiveAlerts?.value ?? 0, + num_new_alerts: bucket?.ruleExecution?.numNewAlerts?.value ?? 0, + num_recovered_alerts: bucket?.ruleExecution?.numRecoveredAlerts?.value ?? 0, num_triggered_actions: bucket?.ruleExecution?.numTriggeredActions?.value ?? 0, num_generated_actions: bucket?.ruleExecution?.numGeneratedActions?.value ?? 0, num_succeeded_actions: actionExecutionSuccess, diff --git a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts index cbcff65cdbdca..4a67404ab232e 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts @@ -34,6 +34,7 @@ describe('getRuleExecutionLogRoute', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 0, @@ -53,6 +54,7 @@ describe('getRuleExecutionLogRoute', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, diff --git a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts index 650bdd83a0a83..4a8a91089203d 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts @@ -26,6 +26,9 @@ const sortFieldSchema = schema.oneOf([ schema.object({ schedule_delay: schema.object({ order: sortOrderSchema }) }), schema.object({ num_triggered_actions: schema.object({ order: sortOrderSchema }) }), schema.object({ num_generated_actions: schema.object({ order: sortOrderSchema }) }), + schema.object({ num_active_alerts: schema.object({ order: sortOrderSchema }) }), + schema.object({ num_recovered_alerts: schema.object({ order: sortOrderSchema }) }), + schema.object({ num_new_alerts: schema.object({ order: sortOrderSchema }) }), ]); const sortFieldsSchema = schema.arrayOf(sortFieldSchema, { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts index 541e55f5c8d90..04653d491f28b 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts @@ -111,20 +111,6 @@ const aggregateResults = { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 0, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -134,6 +120,15 @@ const aggregateResults = { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 0.0, + }, outcomeAndMessage: { hits: { total: { @@ -150,6 +145,9 @@ const aggregateResults = { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -196,20 +194,6 @@ const aggregateResults = { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -219,6 +203,15 @@ const aggregateResults = { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -235,6 +228,9 @@ const aggregateResults = { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -631,6 +627,7 @@ describe('getExecutionLogForRule()', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 0, @@ -650,6 +647,7 @@ describe('getExecutionLogForRule()', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -929,7 +927,7 @@ describe('getExecutionLogForRule()', () => { getExecutionLogByIdParams({ sort: [{ foo: { order: 'desc' } }] }) ) ).rejects.toMatchInlineSnapshot( - `[Error: Invalid sort field "foo" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions]]` + `[Error: Invalid sort field "foo" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions,num_active_alerts,num_recovered_alerts,num_new_alerts]]` ); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts index 99c115def07e6..a416eb18b5a52 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts @@ -67,6 +67,12 @@ export const RULE_EXECUTION_LOG_DURATION_COLUMNS = [ 'schedule_delay', ]; +export const RULE_EXECUTION_LOG_ALERT_COUNT_COLUMNS = [ + 'num_new_alerts', + 'num_active_alerts', + 'num_recovered_alerts', +]; + export const RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS = [ 'timestamp', 'execution_duration', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_data_grid.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_data_grid.tsx index 7c2f5518c5c45..6f166af876004 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_data_grid.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_data_grid.tsx @@ -258,10 +258,12 @@ export const RuleEventLogDataGrid = (props: RuleEventLogDataGrid) => { const pagedRowIndex = rowIndex - pageIndex * pageSize; const value = logs[pagedRowIndex]?.[columnId as keyof IExecutionLog] as string; + const version = logs?.[pagedRowIndex]?.version; return ( ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.test.tsx index 0284ab14f6ce0..7bf2c05b843dc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.test.tsx @@ -29,6 +29,7 @@ const mockLogResponse: any = { duration: 5000000, status: 'success', message: 'rule execution #1', + version: '8.2.0', num_active_alerts: 2, num_new_alerts: 4, num_recovered_alerts: 3, @@ -46,6 +47,7 @@ const mockLogResponse: any = { duration: 6000000, status: 'success', message: 'rule execution #2', + version: '8.2.0', num_active_alerts: 4, num_new_alerts: 2, num_recovered_alerts: 4, @@ -63,6 +65,7 @@ const mockLogResponse: any = { duration: 340000, status: 'failure', message: 'rule execution #3', + version: '8.2.0', num_active_alerts: 8, num_new_alerts: 5, num_recovered_alerts: 0, @@ -80,6 +83,7 @@ const mockLogResponse: any = { duration: 3000000, status: 'unknown', message: 'rule execution #4', + version: '8.2.0', num_active_alerts: 4, num_new_alerts: 4, num_recovered_alerts: 4, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.test.tsx index a33bdf7e25916..e38e57f61878b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.test.tsx @@ -38,6 +38,14 @@ describe('rule_event_log_list_cell_renderer', () => { expect(wrapper.find(RuleDurationFormat).props().duration).toEqual(100000); }); + it('renders alert count correctly', () => { + const wrapper = shallow( + + ); + + expect(wrapper.text()).toEqual('3'); + }); + it('renders timestamps correctly', () => { const time = '2022-03-20T07:40:44-07:00'; const wrapper = shallow(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.tsx index 20e9274f2d73e..84fc3404f228e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.tsx @@ -9,11 +9,13 @@ import React from 'react'; import moment from 'moment'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { EcsEventOutcome } from '@kbn/core/server'; +import { formatRuleAlertCount } from '../../../../common/lib/format_rule_alert_count'; import { RuleEventLogListStatus } from './rule_event_log_list_status'; import { RuleDurationFormat } from '../../rules_list/components/rule_duration_format'; import { RULE_EXECUTION_LOG_COLUMN_IDS, RULE_EXECUTION_LOG_DURATION_COLUMNS, + RULE_EXECUTION_LOG_ALERT_COUNT_COLUMNS, } from '../../../constants'; export const DEFAULT_DATE_FORMAT = 'MMM D, YYYY @ HH:mm:ss.SSS'; @@ -22,12 +24,13 @@ export type ColumnId = typeof RULE_EXECUTION_LOG_COLUMN_IDS[number]; interface RuleEventLogListCellRendererProps { columnId: ColumnId; + version?: string; value?: string; dateFormat?: string; } export const RuleEventLogListCellRenderer = (props: RuleEventLogListCellRendererProps) => { - const { columnId, value, dateFormat = DEFAULT_DATE_FORMAT } = props; + const { columnId, value, version, dateFormat = DEFAULT_DATE_FORMAT } = props; if (typeof value === 'undefined') { return null; @@ -41,6 +44,10 @@ export const RuleEventLogListCellRenderer = (props: RuleEventLogListCellRenderer return <>{moment(value).format(dateFormat)}; } + if (RULE_EXECUTION_LOG_ALERT_COUNT_COLUMNS.includes(columnId)) { + return <>{formatRuleAlertCount(value, version)}; + } + if (RULE_EXECUTION_LOG_DURATION_COLUMNS.includes(columnId)) { return ; } diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.test.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.test.ts new file mode 100644 index 0000000000000..99da6c01e66aa --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { formatRuleAlertCount } from './format_rule_alert_count'; + +describe('formatRuleAlertCount', () => { + it('returns value if version is undefined', () => { + expect(formatRuleAlertCount('0')).toEqual('0'); + }); + + it('renders zero value if version is greater than or equal to 8.3.0', () => { + expect(formatRuleAlertCount('0', '8.3.0')).toEqual('0'); + }); + + it('renders non-zero value if version is greater than or equal to 8.3.0', () => { + expect(formatRuleAlertCount('4', '8.3.0')).toEqual('4'); + }); + + it('renders dashes for zero value if version is less than 8.3.0', () => { + expect(formatRuleAlertCount('0', '8.2.9')).toEqual('--'); + }); + + it('renders non-zero value event if version is less than to 8.3.0', () => { + expect(formatRuleAlertCount('5', '8.2.9')).toEqual('5'); + }); + + it('renders as is if value is unexpectedly not an integer', () => { + expect(formatRuleAlertCount('yo', '8.2.9')).toEqual('yo'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.ts new file mode 100644 index 0000000000000..10ceb40ce19b0 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import semverLt from 'semver/functions/lt'; + +export const formatRuleAlertCount = (value: string, version?: string): string => { + if (version) { + try { + const intValue = parseInt(value, 10); + if (intValue === 0 && semverLt(version, '8.3.0')) { + return '--'; + } + } catch (err) { + return value; + } + } + + return value; +}; From a7012a319b9eca89f362e262667c8491232e8739 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Thu, 19 May 2022 15:22:08 +0100 Subject: [PATCH 29/37] [ML] Creating anomaly detection jobs from Lens visualizations (#129762) * [ML] Lens to ML ON week experiment * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * adding enabled check * refactor * type clean up * type updates * adding error text * variable rename * refactoring url generation * query refactor * translations * refactoring create job code * tiny refactor * adding getSavedVis function * adding undefined check * improving isCompatible check * improving field extraction * improving date parsing * code clean up * adding check for filter and timeShift * changing case of menu item * improving ml link generation * adding check for multiple split fields * adding layer types * renaming things * fixing queries and field type checks * using default bucket span * using locator * fixing query merging * fixing from and to string decoding * adding layer selection flyout * error tranlations and improving error reporting * removing annotatio and reference line layers * moving popout button * adding tick icon * tiny code clean up * removing commented code * using full labels * fixing full label selection * changing style of layer panels * fixing error text * adjusting split card border * style changes * removing border color * removing split card border * adding create job permission check Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../lens/public/embeddable/embeddable.tsx | 4 + x-pack/plugins/lens/public/index.ts | 4 +- x-pack/plugins/ml/common/constants/locator.ts | 1 + x-pack/plugins/ml/common/types/locator.ts | 1 + x-pack/plugins/ml/common/util/date_utils.ts | 11 + x-pack/plugins/ml/kibana.json | 2 + .../job_creator/advanced_job_creator.ts | 14 - .../new_job/common/job_creator/job_creator.ts | 14 + .../common/job_creator/util/general.ts | 9 +- .../convert_lens_to_job_action.tsx | 34 ++ .../jobs/new_job/job_from_lens/create_job.ts | 401 ++++++++++++++++++ .../jobs/new_job/job_from_lens/index.ts | 12 + .../new_job/job_from_lens/route_resolver.ts | 92 ++++ .../jobs/new_job/job_from_lens/utils.ts | 176 ++++++++ .../components/split_cards/split_cards.tsx | 5 +- .../components/split_cards/style.scss | 4 + .../jobs/new_job/pages/new_job/page.tsx | 9 +- .../jobs/new_job/utils/new_job_utils.ts | 117 +++-- .../routing/routes/new_job/from_lens.tsx | 36 ++ .../routing/routes/new_job/index.ts | 1 + .../application/services/job_service.d.ts | 1 + .../application/services/job_service.js | 1 + .../ml/public/embeddables/lens/index.ts | 8 + .../flyout.tsx | 80 ++++ .../flyout_body.tsx | 144 +++++++ .../lens_vis_layer_selection_flyout/index.ts | 8 + .../style.scss | 3 + .../public/embeddables/lens/show_flyout.tsx | 87 ++++ .../plugins/ml/public/locator/ml_locator.ts | 1 + x-pack/plugins/ml/public/plugin.ts | 3 + x-pack/plugins/ml/public/ui_actions/index.ts | 4 + .../ui_actions/open_lens_vis_in_ml_action.tsx | 64 +++ 32 files changed, 1283 insertions(+), 68 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/convert_lens_to_job_action.tsx create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/create_job.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/index.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/route_resolver.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/utils.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/style.scss create mode 100644 x-pack/plugins/ml/public/application/routing/routes/new_job/from_lens.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/lens/index.ts create mode 100644 x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout_body.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/index.ts create mode 100644 x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/style.scss create mode 100644 x-pack/plugins/ml/public/embeddables/lens/show_flyout.tsx create mode 100644 x-pack/plugins/ml/public/ui_actions/open_lens_vis_in_ml_action.tsx diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 7ca68c5ca5d21..bc7770e815ba6 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -800,6 +800,10 @@ export class Embeddable return this.savedVis && this.savedVis.description; } + public getSavedVis(): Readonly { + return this.savedVis; + } + destroy() { super.destroy(); this.isDestroyed = true; diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index edf57ba703a2e..caa08ee9cc418 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -68,6 +68,7 @@ export type { FormulaPublicApi, StaticValueIndexPatternColumn, TimeScaleIndexPatternColumn, + IndexPatternLayer, } from './indexpattern_datasource/types'; export type { XYArgs, @@ -103,7 +104,8 @@ export type { LabelsOrientationConfigResult, AxisTitlesVisibilityConfigResult, } from '@kbn/expression-xy-plugin/common'; -export type { LensEmbeddableInput } from './embeddable'; +export type { LensEmbeddableInput, LensSavedObjectAttributes, Embeddable } from './embeddable'; + export { layerTypes } from '../common'; export type { LensPublicStart, LensPublicSetup } from './plugin'; diff --git a/x-pack/plugins/ml/common/constants/locator.ts b/x-pack/plugins/ml/common/constants/locator.ts index 0c19c5b59766c..7b98eefe0ab24 100644 --- a/x-pack/plugins/ml/common/constants/locator.ts +++ b/x-pack/plugins/ml/common/constants/locator.ts @@ -42,6 +42,7 @@ export const ML_PAGES = { ANOMALY_DETECTION_CREATE_JOB_ADVANCED: `jobs/new_job/advanced`, ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE: `jobs/new_job/step/job_type`, ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX: `jobs/new_job/step/index_or_search`, + ANOMALY_DETECTION_CREATE_JOB_FROM_LENS: `jobs/new_job/from_lens`, SETTINGS: 'settings', CALENDARS_MANAGE: 'settings/calendars_list', CALENDARS_NEW: 'settings/calendars_list/new_calendar', diff --git a/x-pack/plugins/ml/common/types/locator.ts b/x-pack/plugins/ml/common/types/locator.ts index a440aaa349bcc..0d5cb7aeddd81 100644 --- a/x-pack/plugins/ml/common/types/locator.ts +++ b/x-pack/plugins/ml/common/types/locator.ts @@ -48,6 +48,7 @@ export type MlGenericUrlState = MLPageState< | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_RECOGNIZER | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_ADVANCED + | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_FROM_LENS | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX | typeof ML_PAGES.DATA_FRAME_ANALYTICS_CREATE_JOB diff --git a/x-pack/plugins/ml/common/util/date_utils.ts b/x-pack/plugins/ml/common/util/date_utils.ts index c5f5fdaabf388..d6605e5856d8b 100644 --- a/x-pack/plugins/ml/common/util/date_utils.ts +++ b/x-pack/plugins/ml/common/util/date_utils.ts @@ -31,6 +31,17 @@ export function validateTimeRange(time?: TimeRange): boolean { return !!(momentDateFrom && momentDateFrom.isValid() && momentDateTo && momentDateTo.isValid()); } +export function createAbsoluteTimeRange(time: TimeRange) { + if (validateTimeRange(time) === false) { + return null; + } + + return { + to: dateMath.parse(time.to)?.valueOf(), + from: dateMath.parse(time.from)?.valueOf(), + }; +} + export const timeFormatter = (value: number) => { return formatDate(value, TIME_FORMAT); }; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index f62cec0ec0fca..fd105b98805ac 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -28,6 +28,7 @@ "charts", "dashboard", "home", + "lens", "licenseManagement", "management", "maps", @@ -44,6 +45,7 @@ "fieldFormats", "kibanaReact", "kibanaUtils", + "lens", "maps", "savedObjects", "usageCollection", diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts index ebf3a43626c99..d6e7a8c3b21e2 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts @@ -21,7 +21,6 @@ import { createBasicDetector } from './util/default_configs'; import { JOB_TYPE } from '../../../../../../common/constants/new_job'; import { getRichDetectors } from './util/general'; import { isValidJson } from '../../../../../../common/util/validation_utils'; -import { ml } from '../../../../services/ml_api_service'; export interface RichDetector { agg: Aggregation | null; @@ -181,19 +180,6 @@ export class AdvancedJobCreator extends JobCreator { return isValidJson(this._queryString); } - // load the start and end times for the selected index - // and apply them to the job creator - public async autoSetTimeRange() { - const { start, end } = await ml.getTimeFieldRange({ - index: this._indexPatternTitle, - timeFieldName: this.timeFieldName, - query: this.query, - runtimeMappings: this.datafeedConfig.runtime_mappings, - indicesOptions: this.datafeedConfig.indices_options, - }); - this.setTimeRange(start.epoch, end.epoch); - } - public cloneFromExistingJob(job: Job, datafeed: Datafeed) { this._overrideConfigs(job, datafeed); const detectors = getRichDetectors(job, datafeed, this.additionalFields, true); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts index 750669a794bd8..4e0ed5f3bdf92 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts @@ -43,6 +43,7 @@ import { Calendar } from '../../../../../../common/types/calendars'; import { mlCalendarService } from '../../../../services/calendar_service'; import { getDatafeedAggregations } from '../../../../../../common/util/datafeed_utils'; import { getFirstKeyInObject } from '../../../../../../common/util/object_utils'; +import { ml } from '../../../../services/ml_api_service'; export class JobCreator { protected _type: JOB_TYPE = JOB_TYPE.SINGLE_METRIC; @@ -762,6 +763,19 @@ export class JobCreator { } } + // load the start and end times for the selected index + // and apply them to the job creator + public async autoSetTimeRange() { + const { start, end } = await ml.getTimeFieldRange({ + index: this._indexPatternTitle, + timeFieldName: this.timeFieldName, + query: this.query, + runtimeMappings: this.datafeedConfig.runtime_mappings, + indicesOptions: this.datafeedConfig.indices_options, + }); + this.setTimeRange(start.epoch, end.epoch); + } + protected _overrideConfigs(job: Job, datafeed: Datafeed) { this._job_config = job; this._datafeed_config = datafeed; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts index 8f7b66b35ec4f..bd7b6277a542d 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts @@ -230,10 +230,11 @@ export function isSparseDataJob(job: Job, datafeed: Datafeed): boolean { return false; } -function stashJobForCloning( +export function stashJobForCloning( jobCreator: JobCreatorType, skipTimeRangeStep: boolean = false, - includeTimeRange: boolean = false + includeTimeRange: boolean = false, + autoSetTimeRange: boolean = false ) { mlJobService.tempJobCloningObjects.job = jobCreator.jobConfig; mlJobService.tempJobCloningObjects.datafeed = jobCreator.datafeedConfig; @@ -242,10 +243,12 @@ function stashJobForCloning( // skip over the time picker step of the wizard mlJobService.tempJobCloningObjects.skipTimeRangeStep = skipTimeRangeStep; - if (includeTimeRange === true) { + if (includeTimeRange === true && autoSetTimeRange === false) { // auto select the start and end dates of the time picker mlJobService.tempJobCloningObjects.start = jobCreator.start; mlJobService.tempJobCloningObjects.end = jobCreator.end; + } else if (autoSetTimeRange === true) { + mlJobService.tempJobCloningObjects.autoSetTimeRange = true; } mlJobService.tempJobCloningObjects.calendars = jobCreator.calendars; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/convert_lens_to_job_action.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/convert_lens_to_job_action.tsx new file mode 100644 index 0000000000000..ab00fa7e2d474 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/convert_lens_to_job_action.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { Embeddable } from '@kbn/lens-plugin/public'; +import { getJobsItemsFromEmbeddable } from './utils'; +import { ML_PAGES, ML_APP_LOCATOR } from '../../../../../common/constants/locator'; + +export async function convertLensToADJob( + embeddable: Embeddable, + share: SharePluginStart, + layerIndex?: number +) { + const { query, filters, to, from, vis } = getJobsItemsFromEmbeddable(embeddable); + const locator = share.url.locators.get(ML_APP_LOCATOR); + + const url = await locator?.getUrl({ + page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_FROM_LENS, + pageState: { + vis: vis as any, + from, + to, + query, + filters, + layerIndex, + }, + }); + + window.open(url, '_blank'); +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/create_job.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/create_job.ts new file mode 100644 index 0000000000000..7abc30c9f924e --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/create_job.ts @@ -0,0 +1,401 @@ +/* + * Copyright 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 { mergeWith } from 'lodash'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { IUiSettingsClient, SavedObjectReference } from '@kbn/core/public'; +import type { DataViewsContract } from '@kbn/data-views-plugin/public'; + +import { Filter, Query, DataViewBase } from '@kbn/es-query'; + +import type { + LensPublicStart, + LensSavedObjectAttributes, + FieldBasedIndexPatternColumn, + XYDataLayerConfig, + IndexPatternPersistedState, + IndexPatternLayer, + XYLayerConfig, +} from '@kbn/lens-plugin/public'; +import { layerTypes } from '@kbn/lens-plugin/public'; +import type { TimefilterContract } from '@kbn/data-plugin/public'; + +import { i18n } from '@kbn/i18n'; + +import type { JobCreatorType } from '../common/job_creator'; +import { createEmptyJob, createEmptyDatafeed } from '../common/job_creator/util/default_configs'; +import { stashJobForCloning } from '../common/job_creator/util/general'; +import { CREATED_BY_LABEL, DEFAULT_BUCKET_SPAN } from '../../../../../common/constants/new_job'; +import { ErrorType } from '../../../../../common/util/errors'; +import { createQueries } from '../utils/new_job_utils'; +import { + getVisTypeFactory, + isCompatibleLayer, + hasIncompatibleProperties, + hasSourceField, + isTermsField, + isStringField, + getMlFunction, +} from './utils'; + +type VisualizationType = Awaited>[number]; + +export interface LayerResult { + id: string; + layerType: typeof layerTypes[keyof typeof layerTypes]; + label: string; + icon: VisualizationType['icon']; + isCompatible: boolean; + jobWizardType: CREATED_BY_LABEL | null; + error?: ErrorType; +} + +export async function canCreateAndStashADJob( + vis: LensSavedObjectAttributes, + startString: string, + endString: string, + query: Query, + filters: Filter[], + dataViewClient: DataViewsContract, + kibanaConfig: IUiSettingsClient, + timeFilter: TimefilterContract, + layerIndex: number | undefined +) { + try { + const { jobConfig, datafeedConfig, createdBy } = await createADJobFromLensSavedObject( + vis, + query, + filters, + dataViewClient, + kibanaConfig, + layerIndex + ); + + let start: number | undefined; + let end: number | undefined; + let includeTimeRange = true; + + try { + // attempt to parse the start and end dates. + // if start and end values cannot be determined + // instruct the job cloning code to auto-select the + // full time range for the index. + const { min, max } = timeFilter.calculateBounds({ to: endString, from: startString }); + start = min?.valueOf(); + end = max?.valueOf(); + + if (start === undefined || end === undefined || isNaN(start) || isNaN(end)) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.timeRange', { + defaultMessage: 'Incompatible time range', + }) + ); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + includeTimeRange = false; + start = undefined; + end = undefined; + } + + // add job config and start and end dates to the + // job cloning stash, so they can be used + // by the new job wizards + stashJobForCloning( + { + jobConfig, + datafeedConfig, + createdBy, + start, + end, + } as JobCreatorType, + true, + includeTimeRange, + !includeTimeRange + ); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } +} +export async function getLayers( + vis: LensSavedObjectAttributes, + dataViewClient: DataViewsContract, + lens: LensPublicStart +): Promise { + const visualization = vis.state.visualization as { layers: XYLayerConfig[] }; + const getVisType = await getVisTypeFactory(lens); + + const layers: LayerResult[] = await Promise.all( + visualization.layers + .filter(({ layerType }) => layerType === layerTypes.DATA) // remove non chart layers + .map(async (layer) => { + const { icon, label } = getVisType(layer); + try { + const { fields, splitField } = await extractFields(layer, vis, dataViewClient); + const detectors = createDetectors(fields, splitField); + const createdBy = + splitField || detectors.length > 1 + ? CREATED_BY_LABEL.MULTI_METRIC + : CREATED_BY_LABEL.SINGLE_METRIC; + + return { + id: layer.layerId, + layerType: layer.layerType, + label, + icon, + jobWizardType: createdBy, + isCompatible: true, + }; + } catch (error) { + return { + id: layer.layerId, + layerType: layer.layerType, + label, + icon, + jobWizardType: null, + isCompatible: false, + error, + }; + } + }) + ); + + return layers; +} + +async function createADJobFromLensSavedObject( + vis: LensSavedObjectAttributes, + query: Query, + filters: Filter[], + dataViewClient: DataViewsContract, + kibanaConfig: IUiSettingsClient, + layerIndex?: number +) { + const visualization = vis.state.visualization as { layers: XYDataLayerConfig[] }; + + const compatibleLayers = visualization.layers.filter(isCompatibleLayer); + + const selectedLayer = + layerIndex !== undefined ? visualization.layers[layerIndex] : compatibleLayers[0]; + + const { fields, timeField, splitField, dataView } = await extractFields( + selectedLayer, + vis, + dataViewClient + ); + + const jobConfig = createEmptyJob(); + const datafeedConfig = createEmptyDatafeed(dataView.title); + + const combinedFiltersAndQueries = combineQueriesAndFilters( + { query, filters }, + { query: vis.state.query, filters: vis.state.filters }, + dataView, + kibanaConfig + ); + + datafeedConfig.query = combinedFiltersAndQueries; + + jobConfig.analysis_config.detectors = createDetectors(fields, splitField); + + jobConfig.data_description.time_field = timeField.sourceField; + jobConfig.analysis_config.bucket_span = DEFAULT_BUCKET_SPAN; + if (splitField) { + jobConfig.analysis_config.influencers = [splitField.sourceField]; + } + + const createdBy = + splitField || jobConfig.analysis_config.detectors.length > 1 + ? CREATED_BY_LABEL.MULTI_METRIC + : CREATED_BY_LABEL.SINGLE_METRIC; + + return { + jobConfig, + datafeedConfig, + createdBy, + }; +} + +async function extractFields( + layer: XYLayerConfig, + vis: LensSavedObjectAttributes, + dataViewClient: DataViewsContract +) { + if (!isCompatibleLayer(layer)) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.incompatibleLayerType', { + defaultMessage: 'Layer is incompatible. Only chart layers can be used.', + }) + ); + } + + const indexpattern = vis.state.datasourceStates.indexpattern as IndexPatternPersistedState; + const compatibleIndexPatternLayer = Object.entries(indexpattern.layers).find( + ([id]) => layer.layerId === id + ); + if (compatibleIndexPatternLayer === undefined) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.noCompatibleLayers', { + defaultMessage: + 'Visualization does not contain any layers which can be used for creating an anomaly detection job.', + }) + ); + } + + const [layerId, columnsLayer] = compatibleIndexPatternLayer; + + const columns = getColumns(columnsLayer, layer); + const timeField = Object.values(columns).find(({ dataType }) => dataType === 'date'); + if (timeField === undefined) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.noDateField', { + defaultMessage: 'Cannot find a date field.', + }) + ); + } + + const fields = layer.accessors.map((a) => columns[a]); + + const splitField = layer.splitAccessor ? columns[layer.splitAccessor] : null; + + if ( + splitField !== null && + isTermsField(splitField) && + splitField.params.secondaryFields?.length + ) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.splitFieldHasMultipleFields', { + defaultMessage: 'Selected split field contains more than one field.', + }) + ); + } + + if (splitField !== null && isStringField(splitField) === false) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.splitFieldMustBeString', { + defaultMessage: 'Selected split field type must be string.', + }) + ); + } + + const dataView = await getDataViewFromLens(vis.references, layerId, dataViewClient); + if (dataView === null) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.noDataViews', { + defaultMessage: 'No data views can be found in the visualization.', + }) + ); + } + + if (timeField.sourceField !== dataView.timeFieldName) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.timeFieldNotInDataView', { + defaultMessage: + 'Selected time field must be the default time field configured for data view.', + }) + ); + } + + return { fields, timeField, splitField, dataView }; +} + +function createDetectors( + fields: FieldBasedIndexPatternColumn[], + splitField: FieldBasedIndexPatternColumn | null +) { + return fields.map(({ operationType, sourceField }) => { + return { + function: getMlFunction(operationType), + field_name: sourceField, + ...(splitField ? { partition_field_name: splitField.sourceField } : {}), + }; + }); +} + +async function getDataViewFromLens( + references: SavedObjectReference[], + layerId: string, + dataViewClient: DataViewsContract +) { + const dv = references.find( + (r) => r.type === 'index-pattern' && r.name === `indexpattern-datasource-layer-${layerId}` + ); + if (!dv) { + return null; + } + return dataViewClient.get(dv.id); +} + +function getColumns( + { columns }: Omit, + layer: XYDataLayerConfig +) { + layer.accessors.forEach((a) => { + const col = columns[a]; + // fail early if any of the cols being used as accessors + // contain functions we don't support + return col.dataType !== 'date' && getMlFunction(col.operationType); + }); + + if (Object.values(columns).some((c) => hasSourceField(c) === false)) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.colsNoSourceField', { + defaultMessage: 'Some columns do not contain a source field.', + }) + ); + } + + if (Object.values(columns).some((c) => hasIncompatibleProperties(c) === true)) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.colsUsingFilterTimeSift', { + defaultMessage: + 'Columns contain settings which are incompatible with ML detectors, time shift and filter by are not supported.', + }) + ); + } + + return columns as Record; +} + +function combineQueriesAndFilters( + dashboard: { query: Query; filters: Filter[] }, + vis: { query: Query; filters: Filter[] }, + dataView: DataViewBase, + kibanaConfig: IUiSettingsClient +): estypes.QueryDslQueryContainer { + const { combinedQuery: dashboardQueries } = createQueries( + { + query: dashboard.query, + filter: dashboard.filters, + }, + dataView, + kibanaConfig + ); + + const { combinedQuery: visQueries } = createQueries( + { + query: vis.query, + filter: vis.filters, + }, + dataView, + kibanaConfig + ); + + const mergedQueries = mergeWith( + dashboardQueries, + visQueries, + (objValue: estypes.QueryDslQueryContainer, srcValue: estypes.QueryDslQueryContainer) => { + if (Array.isArray(objValue)) { + return objValue.concat(srcValue); + } + } + ); + + return mergedQueries; +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/index.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/index.ts new file mode 100644 index 0000000000000..911595f9673da --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { LayerResult } from './create_job'; +export { resolver } from './route_resolver'; +export { getLayers } from './create_job'; +export { convertLensToADJob } from './convert_lens_to_job_action'; +export { getJobsItemsFromEmbeddable, isCompatibleVisualizationType } from './utils'; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/route_resolver.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/route_resolver.ts new file mode 100644 index 0000000000000..b305c69c47d87 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/route_resolver.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import rison from 'rison-node'; +import { Query } from '@kbn/data-plugin/public'; +import { Filter } from '@kbn/es-query'; +import type { LensSavedObjectAttributes } from '@kbn/lens-plugin/public'; +import { canCreateAndStashADJob } from './create_job'; +import { + getUiSettings, + getDataViews, + getSavedObjectsClient, + getTimefilter, +} from '../../../util/dependency_cache'; +import { getDefaultQuery } from '../utils/new_job_utils'; + +export async function resolver( + lensSavedObjectId: string | undefined, + lensSavedObjectRisonString: string | undefined, + fromRisonStrong: string, + toRisonStrong: string, + queryRisonString: string, + filtersRisonString: string, + layerIndexRisonString: string +) { + let vis: LensSavedObjectAttributes; + if (lensSavedObjectId) { + vis = await getLensSavedObject(lensSavedObjectId); + } else if (lensSavedObjectRisonString) { + vis = rison.decode(lensSavedObjectRisonString) as unknown as LensSavedObjectAttributes; + } else { + throw new Error('Cannot create visualization'); + } + + let query: Query; + let filters: Filter[]; + try { + query = rison.decode(queryRisonString) as Query; + } catch (error) { + query = getDefaultQuery(); + } + try { + filters = rison.decode(filtersRisonString) as Filter[]; + } catch (error) { + filters = []; + } + + let from: string; + let to: string; + try { + from = rison.decode(fromRisonStrong) as string; + } catch (error) { + from = ''; + } + try { + to = rison.decode(toRisonStrong) as string; + } catch (error) { + to = ''; + } + let layerIndex: number | undefined; + try { + layerIndex = rison.decode(layerIndexRisonString) as number; + } catch (error) { + layerIndex = undefined; + } + + const dataViewClient = getDataViews(); + const kibanaConfig = getUiSettings(); + const timeFilter = getTimefilter(); + + await canCreateAndStashADJob( + vis, + from, + to, + query, + filters, + dataViewClient, + kibanaConfig, + timeFilter, + layerIndex + ); +} + +async function getLensSavedObject(id: string) { + const savedObjectClient = getSavedObjectsClient(); + const so = await savedObjectClient.get('lens', id); + return so.attributes; +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/utils.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/utils.ts new file mode 100644 index 0000000000000..e4b2ae91b3ba2 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/utils.ts @@ -0,0 +1,176 @@ +/* + * Copyright 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 type { + Embeddable, + LensPublicStart, + LensSavedObjectAttributes, + FieldBasedIndexPatternColumn, + XYDataLayerConfig, + GenericIndexPatternColumn, + TermsIndexPatternColumn, + SeriesType, + XYLayerConfig, +} from '@kbn/lens-plugin/public'; +import { layerTypes } from '@kbn/lens-plugin/public'; + +export const COMPATIBLE_SERIES_TYPES: SeriesType[] = [ + 'line', + 'bar', + 'bar_stacked', + 'bar_percentage_stacked', + 'bar_horizontal', + 'bar_horizontal_stacked', + 'area', + 'area_stacked', + 'area_percentage_stacked', +]; + +export const COMPATIBLE_LAYER_TYPE: XYDataLayerConfig['layerType'] = layerTypes.DATA; + +export const COMPATIBLE_VISUALIZATION = 'lnsXY'; + +export function getJobsItemsFromEmbeddable(embeddable: Embeddable) { + const { query, filters, timeRange } = embeddable.getInput(); + + if (timeRange === undefined) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.noTimeRange', { + defaultMessage: 'Time range not specified.', + }) + ); + } + const { to, from } = timeRange; + + const vis = embeddable.getSavedVis(); + if (vis === undefined) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.visNotFound', { + defaultMessage: 'Visualization cannot be found.', + }) + ); + } + + return { + vis, + from, + to, + query, + filters, + }; +} + +export function lensOperationToMlFunction(operationType: string) { + switch (operationType) { + case 'average': + return 'mean'; + case 'count': + return 'count'; + case 'max': + return 'max'; + case 'median': + return 'median'; + case 'min': + return 'min'; + case 'sum': + return 'sum'; + case 'unique_count': + return 'distinct_count'; + + default: + return null; + } +} + +export function getMlFunction(operationType: string) { + const func = lensOperationToMlFunction(operationType); + if (func === null) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.incorrectFunction', { + defaultMessage: + 'Selected function {operationType} is not supported by anomaly detection detectors', + values: { operationType }, + }) + ); + } + return func; +} + +export async function getVisTypeFactory(lens: LensPublicStart) { + const visTypes = await lens.getXyVisTypes(); + return (layer: XYLayerConfig) => { + switch (layer.layerType) { + case layerTypes.DATA: + const type = visTypes.find((t) => t.id === layer.seriesType); + return { + label: type?.fullLabel || type?.label || layer.layerType, + icon: type?.icon ?? '', + }; + case layerTypes.ANNOTATIONS: + // Annotation and Reference line layers are not displayed. + // but for consistency leave the labels in, in case we decide + // to display these layers in the future + return { + label: i18n.translate('xpack.ml.newJob.fromLens.createJob.VisType.annotations', { + defaultMessage: 'Annotations', + }), + icon: '', + }; + case layerTypes.REFERENCELINE: + return { + label: i18n.translate('xpack.ml.newJob.fromLens.createJob.VisType.referenceLine', { + defaultMessage: 'Reference line', + }), + icon: '', + }; + default: + return { + // @ts-expect-error just in case a new layer type appears in the future + label: layer.layerType, + icon: '', + }; + } + }; +} + +export async function isCompatibleVisualizationType(savedObject: LensSavedObjectAttributes) { + const visualization = savedObject.state.visualization as { layers: XYLayerConfig[] }; + return ( + savedObject.visualizationType === COMPATIBLE_VISUALIZATION && + visualization.layers.some((l) => l.layerType === layerTypes.DATA) + ); +} + +export function isCompatibleLayer(layer: XYLayerConfig): layer is XYDataLayerConfig { + return ( + isDataLayer(layer) && + layer.layerType === COMPATIBLE_LAYER_TYPE && + COMPATIBLE_SERIES_TYPES.includes(layer.seriesType) + ); +} + +export function isDataLayer(layer: XYLayerConfig): layer is XYDataLayerConfig { + return 'seriesType' in layer; +} +export function hasSourceField( + column: GenericIndexPatternColumn +): column is FieldBasedIndexPatternColumn { + return 'sourceField' in column; +} + +export function isTermsField(column: GenericIndexPatternColumn): column is TermsIndexPatternColumn { + return column.operationType === 'terms' && 'params' in column; +} + +export function isStringField(column: GenericIndexPatternColumn) { + return column.dataType === 'string'; +} + +export function hasIncompatibleProperties(column: GenericIndexPatternColumn) { + return 'timeShift' in column || 'filter' in column; +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/split_cards.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/split_cards.tsx index 67b411ebc628e..597645d2fa87e 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/split_cards.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/split_cards.tsx @@ -11,6 +11,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiHorizontalRule, EuiSpacer } fro import { SplitField } from '../../../../../../../../../common/types/fields'; import { JOB_TYPE } from '../../../../../../../../../common/constants/new_job'; +import './style.scss'; interface Props { fieldValues: string[]; @@ -72,7 +73,7 @@ export const SplitCards: FC = memo(
storePanels(ref, marginBottom)} style={style}>
= memo( {getBackPanels()}
= ({ existingJobsAndGroups, jobType }) => { ? WIZARD_STEPS.ADVANCED_CONFIGURE_DATAFEED : WIZARD_STEPS.TIME_RANGE; - let autoSetTimeRange = false; + let autoSetTimeRange = mlJobService.tempJobCloningObjects.autoSetTimeRange; + mlJobService.tempJobCloningObjects.autoSetTimeRange = false; if ( mlJobService.tempJobCloningObjects.job !== undefined && @@ -106,7 +107,7 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { } else { // if not start and end times are set and this is an advanced job, // auto set the time range based on the index - autoSetTimeRange = isAdvancedJobCreator(jobCreator); + autoSetTimeRange = autoSetTimeRange || isAdvancedJobCreator(jobCreator); } if (mlJobService.tempJobCloningObjects.calendars) { @@ -148,7 +149,7 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { } } - if (autoSetTimeRange && isAdvancedJobCreator(jobCreator)) { + if (autoSetTimeRange) { // for advanced jobs, load the full time range start and end times // so they can be used for job validation and bucket span estimation jobCreator.autoSetTimeRange().catch((error) => { @@ -183,7 +184,7 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { chartInterval.setInterval('auto'); const chartLoader = useMemo( - () => new ChartLoader(mlContext.currentDataView, mlContext.combinedQuery), + () => new ChartLoader(mlContext.currentDataView, jobCreator.query), [] ); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts index 69bdfc666b06a..1f0be5bdb0516 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { cloneDeep } from 'lodash'; import { Query, @@ -14,6 +15,7 @@ import { buildQueryFromFilters, DataViewBase, } from '@kbn/es-query'; +import { Filter } from '@kbn/es-query'; import { IUiSettingsClient } from '@kbn/core/public'; import { getEsQueryConfig } from '@kbn/data-plugin/public'; import { SEARCH_QUERY_LANGUAGE } from '../../../../../common/constants/search'; @@ -22,7 +24,7 @@ import { getQueryFromSavedSearchObject } from '../../../util/index_utils'; // Provider for creating the items used for searching and job creation. -const DEFAULT_QUERY = { +const DEFAULT_DSL_QUERY: estypes.QueryDslQueryContainer = { bool: { must: [ { @@ -32,7 +34,16 @@ const DEFAULT_QUERY = { }, }; +export const DEFAULT_QUERY: Query = { + query: '', + language: 'lucene', +}; + export function getDefaultDatafeedQuery() { + return cloneDeep(DEFAULT_DSL_QUERY); +} + +export function getDefaultQuery() { return cloneDeep(DEFAULT_QUERY); } @@ -45,57 +56,75 @@ export function createSearchItems( // a lucene query_string. // Using a blank query will cause match_all:{} to be used // when passed through luceneStringToDsl - let query: Query = { - query: '', - language: 'lucene', - }; - - let combinedQuery: any = getDefaultDatafeedQuery(); - if (savedSearch !== null) { - const data = getQueryFromSavedSearchObject(savedSearch); + if (savedSearch === null) { + return { + query: getDefaultQuery(), + combinedQuery: getDefaultDatafeedQuery(), + }; + } - query = data.query; - const filter = data.filter; + const data = getQueryFromSavedSearchObject(savedSearch); + return createQueries(data, indexPattern, kibanaConfig); +} - const filters = Array.isArray(filter) ? filter : []; +export function createQueries( + data: { query: Query; filter: Filter[] }, + dataView: DataViewBase | undefined, + kibanaConfig: IUiSettingsClient +) { + let query = getDefaultQuery(); + let combinedQuery: estypes.QueryDslQueryContainer = getDefaultDatafeedQuery(); - if (query.language === SEARCH_QUERY_LANGUAGE.KUERY) { - const ast = fromKueryExpression(query.query); - if (query.query !== '') { - combinedQuery = toElasticsearchQuery(ast, indexPattern); - } - const filterQuery = buildQueryFromFilters(filters, indexPattern); - - if (combinedQuery.bool === undefined) { - combinedQuery.bool = {}; - // toElasticsearchQuery may add a single multi_match item to the - // root of its returned query, rather than putting it inside - // a bool.should - // in this case, move it to a bool.should - if (combinedQuery.multi_match !== undefined) { - combinedQuery.bool.should = { - multi_match: combinedQuery.multi_match, - }; - delete combinedQuery.multi_match; - } - } + query = data.query; + const filter = data.filter; + const filters = Array.isArray(filter) ? filter : []; - if (Array.isArray(combinedQuery.bool.filter) === false) { - combinedQuery.bool.filter = - combinedQuery.bool.filter === undefined ? [] : [combinedQuery.bool.filter]; + if (query.language === SEARCH_QUERY_LANGUAGE.KUERY) { + const ast = fromKueryExpression(query.query); + if (query.query !== '') { + combinedQuery = toElasticsearchQuery(ast, dataView); + } + const filterQuery = buildQueryFromFilters(filters, dataView); + + if (combinedQuery.bool === undefined) { + combinedQuery.bool = {}; + // toElasticsearchQuery may add a single multi_match item to the + // root of its returned query, rather than putting it inside + // a bool.should + // in this case, move it to a bool.should + if (combinedQuery.multi_match !== undefined) { + combinedQuery.bool.should = { + multi_match: combinedQuery.multi_match, + }; + delete combinedQuery.multi_match; } + } - if (Array.isArray(combinedQuery.bool.must_not) === false) { - combinedQuery.bool.must_not = - combinedQuery.bool.must_not === undefined ? [] : [combinedQuery.bool.must_not]; - } + if (Array.isArray(combinedQuery.bool.filter) === false) { + combinedQuery.bool.filter = + combinedQuery.bool.filter === undefined + ? [] + : [combinedQuery.bool.filter as estypes.QueryDslQueryContainer]; + } - combinedQuery.bool.filter = [...combinedQuery.bool.filter, ...filterQuery.filter]; - combinedQuery.bool.must_not = [...combinedQuery.bool.must_not, ...filterQuery.must_not]; - } else { - const esQueryConfigs = getEsQueryConfig(kibanaConfig); - combinedQuery = buildEsQuery(indexPattern, [query], filters, esQueryConfigs); + if (Array.isArray(combinedQuery.bool.must_not) === false) { + combinedQuery.bool.must_not = + combinedQuery.bool.must_not === undefined + ? [] + : [combinedQuery.bool.must_not as estypes.QueryDslQueryContainer]; } + + combinedQuery.bool.filter = [ + ...(combinedQuery.bool.filter as estypes.QueryDslQueryContainer[]), + ...filterQuery.filter, + ]; + combinedQuery.bool.must_not = [ + ...(combinedQuery.bool.must_not as estypes.QueryDslQueryContainer[]), + ...filterQuery.must_not, + ]; + } else { + const esQueryConfigs = getEsQueryConfig(kibanaConfig); + combinedQuery = buildEsQuery(dataView, [query], filters, esQueryConfigs); } return { diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/from_lens.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/from_lens.tsx new file mode 100644 index 0000000000000..ad24bcfba89a9 --- /dev/null +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/from_lens.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; + +import { Redirect } from 'react-router-dom'; +import { parse } from 'query-string'; + +import { MlRoute, PageLoader, PageProps } from '../../router'; +import { useResolver } from '../../use_resolver'; + +import { resolver } from '../../../jobs/new_job/job_from_lens'; + +export const fromLensRouteFactory = (): MlRoute => ({ + path: '/jobs/new_job/from_lens', + render: (props, deps) => , + breadcrumbs: [], +}); + +const PageWrapper: FC = ({ location, deps }) => { + const { lensId, vis, from, to, query, filters, layerIndex }: Record = parse( + location.search, + { + sort: false, + } + ); + + const { context } = useResolver(undefined, undefined, deps.config, deps.dataViewsContract, { + redirect: () => resolver(lensId, vis, from, to, query, filters, layerIndex), + }); + return {}; +}; diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/index.ts b/x-pack/plugins/ml/public/application/routing/routes/new_job/index.ts index b76a1b45588de..d02d4b16264c6 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/index.ts +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/index.ts @@ -10,3 +10,4 @@ export * from './job_type'; export * from './new_job'; export * from './wizard'; export * from './recognize'; +export * from './from_lens'; diff --git a/x-pack/plugins/ml/public/application/services/job_service.d.ts b/x-pack/plugins/ml/public/application/services/job_service.d.ts index be0f035786923..465e4528bd9c5 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/job_service.d.ts @@ -25,6 +25,7 @@ declare interface JobService { start?: number; end?: number; calendars: Calendar[] | undefined; + autoSetTimeRange?: boolean; }; skipTimeRangeStep: boolean; saveNewJob(job: Job): Promise; diff --git a/x-pack/plugins/ml/public/application/services/job_service.js b/x-pack/plugins/ml/public/application/services/job_service.js index ebb89b84dd638..32cd957ff0f20 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.js +++ b/x-pack/plugins/ml/public/application/services/job_service.js @@ -35,6 +35,7 @@ class JobService { start: undefined, end: undefined, calendars: undefined, + autoSetTimeRange: false, }; this.jobs = []; diff --git a/x-pack/plugins/ml/public/embeddables/lens/index.ts b/x-pack/plugins/ml/public/embeddables/lens/index.ts new file mode 100644 index 0000000000000..ad44424293dbb --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/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 { showLensVisToADJobFlyout } from './show_flyout'; diff --git a/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout.tsx b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout.tsx new file mode 100644 index 0000000000000..edb882390e1ed --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import type { Embeddable } from '@kbn/lens-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiFlyoutBody, + EuiTitle, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +import { FlyoutBody } from './flyout_body'; + +interface Props { + embeddable: Embeddable; + data: DataPublicPluginStart; + share: SharePluginStart; + lens: LensPublicStart; + onClose: () => void; +} + +export const LensLayerSelectionFlyout: FC = ({ onClose, embeddable, data, share, lens }) => { + return ( + <> + + +

+ +

+
+ + + + +
+ + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout_body.tsx b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout_body.tsx new file mode 100644 index 0000000000000..fbda903daa7e7 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout_body.tsx @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useState, useEffect, useMemo } from 'react'; +import type { Embeddable } from '@kbn/lens-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import './style.scss'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiSpacer, + EuiIcon, + EuiText, + EuiSplitPanel, + EuiHorizontalRule, +} from '@elastic/eui'; + +import { + getLayers, + getJobsItemsFromEmbeddable, + convertLensToADJob, +} from '../../../application/jobs/new_job/job_from_lens'; +import type { LayerResult } from '../../../application/jobs/new_job/job_from_lens'; +import { CREATED_BY_LABEL } from '../../../../common/constants/new_job'; +import { extractErrorMessage } from '../../../../common/util/errors'; + +interface Props { + embeddable: Embeddable; + data: DataPublicPluginStart; + share: SharePluginStart; + lens: LensPublicStart; + onClose: () => void; +} + +export const FlyoutBody: FC = ({ onClose, embeddable, data, share, lens }) => { + const embeddableItems = useMemo(() => getJobsItemsFromEmbeddable(embeddable), [embeddable]); + + const [layerResult, setLayerResults] = useState([]); + + useEffect(() => { + const { vis } = embeddableItems; + + getLayers(vis, data.dataViews, lens).then((layers) => { + setLayerResults(layers); + }); + }, []); + + function createADJob(layerIndex: number) { + convertLensToADJob(embeddable, share, layerIndex); + } + + return ( + <> + {layerResult.map((layer, i) => ( + <> + + + + {layer.icon && ( + + + + )} + + +
{layer.label}
+
+
+
+
+ + + {layer.isCompatible ? ( + <> + + + + + + + + + + + + + + + {' '} + + + + ) : ( + <> + + + + + + + + + {layer.error ? ( + extractErrorMessage(layer.error) + ) : ( + + )} + + + + + )} + +
+ + + ))} + + ); +}; diff --git a/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/index.ts b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/index.ts new file mode 100644 index 0000000000000..4fa9391434162 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/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 { LensLayerSelectionFlyout } from './flyout'; diff --git a/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/style.scss b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/style.scss new file mode 100644 index 0000000000000..0da0eb92c9637 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/style.scss @@ -0,0 +1,3 @@ +.mlLensToJobFlyoutBody { + background-color: $euiColorLightestShade; +} diff --git a/x-pack/plugins/ml/public/embeddables/lens/show_flyout.tsx b/x-pack/plugins/ml/public/embeddables/lens/show_flyout.tsx new file mode 100644 index 0000000000000..525b7aa74cbc7 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/show_flyout.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 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 { takeUntil } from 'rxjs/operators'; +import { from } from 'rxjs'; +import type { Embeddable } from '@kbn/lens-plugin/public'; +import type { CoreStart } from '@kbn/core/public'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; + +import { + toMountPoint, + wrapWithTheme, + KibanaContextProvider, +} from '@kbn/kibana-react-plugin/public'; +import { DashboardConstants } from '@kbn/dashboard-plugin/public'; +import { getMlGlobalServices } from '../../application/app'; +import { LensLayerSelectionFlyout } from './lens_vis_layer_selection_flyout'; + +export async function showLensVisToADJobFlyout( + embeddable: Embeddable, + coreStart: CoreStart, + share: SharePluginStart, + data: DataPublicPluginStart, + lens: LensPublicStart +): Promise { + const { + http, + theme: { theme$ }, + overlays, + application: { currentAppId$ }, + } = coreStart; + + return new Promise(async (resolve, reject) => { + try { + const onFlyoutClose = () => { + flyoutSession.close(); + resolve(); + }; + + const flyoutSession = overlays.openFlyout( + toMountPoint( + wrapWithTheme( + + { + onFlyoutClose(); + resolve(); + }} + data={data} + share={share} + lens={lens} + /> + , + theme$ + ) + ), + { + 'data-test-subj': 'mlFlyoutJobSelector', + ownFocus: true, + closeButtonAriaLabel: 'jobSelectorFlyout', + onClose: onFlyoutClose, + // @ts-expect-error should take any number/string compatible with the CSS width attribute + size: '35vw', + } + ); + + // Close the flyout when user navigates out of the dashboard plugin + currentAppId$.pipe(takeUntil(from(flyoutSession.onClose))).subscribe((appId) => { + if (appId !== DashboardConstants.DASHBOARDS_ID) { + flyoutSession.close(); + } + }); + } catch (error) { + reject(error); + } + }); +} diff --git a/x-pack/plugins/ml/public/locator/ml_locator.ts b/x-pack/plugins/ml/public/locator/ml_locator.ts index 01d63aa0ebf3f..295dbaebbbae6 100644 --- a/x-pack/plugins/ml/public/locator/ml_locator.ts +++ b/x-pack/plugins/ml/public/locator/ml_locator.ts @@ -79,6 +79,7 @@ export class MlLocatorDefinition implements LocatorDefinition { case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB: case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_RECOGNIZER: case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_ADVANCED: + case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_FROM_LENS: case ML_PAGES.DATA_VISUALIZER: case ML_PAGES.DATA_VISUALIZER_FILE: case ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER: diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 79f386d521da1..7a3d605a1e8cf 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -23,6 +23,7 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import type { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; import { AppStatus, AppUpdater, DEFAULT_APP_CATEGORIES } from '@kbn/core/public'; import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; @@ -64,6 +65,7 @@ export interface MlStartDependencies { fieldFormats: FieldFormatsStart; dashboard: DashboardStart; charts: ChartsPluginStart; + lens?: LensPublicStart; } export interface MlSetupDependencies { @@ -130,6 +132,7 @@ export class MlPlugin implements Plugin { aiops: pluginsStart.aiops, usageCollection: pluginsSetup.usageCollection, fieldFormats: pluginsStart.fieldFormats, + lens: pluginsStart.lens, }, params ); diff --git a/x-pack/plugins/ml/public/ui_actions/index.ts b/x-pack/plugins/ml/public/ui_actions/index.ts index a663fa0e2fa01..4aac7c46b70ac 100644 --- a/x-pack/plugins/ml/public/ui_actions/index.ts +++ b/x-pack/plugins/ml/public/ui_actions/index.ts @@ -10,6 +10,7 @@ import { UiActionsSetup } from '@kbn/ui-actions-plugin/public'; import { CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public'; import { createEditSwimlanePanelAction } from './edit_swimlane_panel_action'; import { createOpenInExplorerAction } from './open_in_anomaly_explorer_action'; +import { createLensVisToADJobAction } from './open_lens_vis_in_ml_action'; import { MlPluginStart, MlStartDependencies } from '../plugin'; import { createApplyInfluencerFiltersAction } from './apply_influencer_filters_action'; import { @@ -26,6 +27,7 @@ export { APPLY_TIME_RANGE_SELECTION_ACTION } from './apply_time_range_action'; export { EDIT_SWIMLANE_PANEL_ACTION } from './edit_swimlane_panel_action'; export { APPLY_INFLUENCER_FILTERS_ACTION } from './apply_influencer_filters_action'; export { OPEN_IN_ANOMALY_EXPLORER_ACTION } from './open_in_anomaly_explorer_action'; +export { CREATE_LENS_VIS_TO_ML_AD_JOB_ACTION } from './open_lens_vis_in_ml_action'; export { SWIM_LANE_SELECTION_TRIGGER }; /** * Register ML UI actions @@ -42,6 +44,7 @@ export function registerMlUiActions( const applyTimeRangeSelectionAction = createApplyTimeRangeSelectionAction(core.getStartServices); const clearSelectionAction = createClearSelectionAction(core.getStartServices); const editExplorerPanelAction = createEditAnomalyChartsPanelAction(core.getStartServices); + const lensVisToADJobAction = createLensVisToADJobAction(core.getStartServices); // Register actions uiActions.registerAction(editSwimlanePanelAction); @@ -65,4 +68,5 @@ export function registerMlUiActions( uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, openInExplorerAction); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, clearSelectionAction); uiActions.addTriggerAction(EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER, applyEntityFieldFilterAction); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, lensVisToADJobAction); } diff --git a/x-pack/plugins/ml/public/ui_actions/open_lens_vis_in_ml_action.tsx b/x-pack/plugins/ml/public/ui_actions/open_lens_vis_in_ml_action.tsx new file mode 100644 index 0000000000000..692f0e2ac5f9b --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/open_lens_vis_in_ml_action.tsx @@ -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 { i18n } from '@kbn/i18n'; +import type { Embeddable } from '@kbn/lens-plugin/public'; +import { createAction } from '@kbn/ui-actions-plugin/public'; +import { MlCoreSetup } from '../plugin'; + +export const CREATE_LENS_VIS_TO_ML_AD_JOB_ACTION = 'createMLADJobAction'; + +export function createLensVisToADJobAction(getStartServices: MlCoreSetup['getStartServices']) { + return createAction<{ embeddable: Embeddable }>({ + id: 'create-ml-ad-job-action', + type: CREATE_LENS_VIS_TO_ML_AD_JOB_ACTION, + getIconType(context): string { + return 'machineLearningApp'; + }, + getDisplayName: () => + i18n.translate('xpack.ml.actions.createADJobFromLens', { + defaultMessage: 'Create anomaly detection job', + }), + async execute({ embeddable }) { + if (!embeddable) { + throw new Error('Not possible to execute an action without the embeddable context'); + } + + try { + const [{ showLensVisToADJobFlyout }, [coreStart, { share, data, lens }]] = + await Promise.all([import('../embeddables/lens'), getStartServices()]); + if (lens === undefined) { + return; + } + await showLensVisToADJobFlyout(embeddable, coreStart, share, data, lens); + } catch (e) { + return Promise.reject(); + } + }, + async isCompatible(context: { embeddable: Embeddable }) { + if (context.embeddable.type !== 'lens') { + return false; + } + + const [{ getJobsItemsFromEmbeddable, isCompatibleVisualizationType }, [coreStart]] = + await Promise.all([ + import('../application/jobs/new_job/job_from_lens'), + getStartServices(), + ]); + + if ( + !coreStart.application.capabilities.ml?.canCreateJob || + !coreStart.application.capabilities.ml?.canStartStopDatafeed + ) { + return false; + } + + const { vis } = getJobsItemsFromEmbeddable(context.embeddable); + return isCompatibleVisualizationType(vis); + }, + }); +} From 3effa893da12cd968cc3e34bdab1391857b5b445 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 19 May 2022 07:28:47 -0700 Subject: [PATCH 30/37] [ci] always supply defaults for parallelism vars (#132520) --- .buildkite/scripts/steps/code_coverage/jest_parallel.sh | 6 +++--- .buildkite/scripts/steps/test/ftr_configs.sh | 4 ++-- .buildkite/scripts/steps/test/jest.sh | 4 +++- .buildkite/scripts/steps/test/jest_integration.sh | 4 +++- .buildkite/scripts/steps/test/jest_parallel.sh | 2 +- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.buildkite/scripts/steps/code_coverage/jest_parallel.sh b/.buildkite/scripts/steps/code_coverage/jest_parallel.sh index dc8a67320c5ed..44ea80bf95257 100755 --- a/.buildkite/scripts/steps/code_coverage/jest_parallel.sh +++ b/.buildkite/scripts/steps/code_coverage/jest_parallel.sh @@ -2,8 +2,8 @@ set -uo pipefail -JOB=$BUILDKITE_PARALLEL_JOB -JOB_COUNT=$BUILDKITE_PARALLEL_JOB_COUNT +JOB=${BUILDKITE_PARALLEL_JOB:-0} +JOB_COUNT=${BUILDKITE_PARALLEL_JOB_COUNT:-1} # a jest failure will result in the script returning an exit code of 10 @@ -35,4 +35,4 @@ while read -r config; do # uses heredoc to avoid the while loop being in a sub-shell thus unable to overwrite exitCode done <<< "$(find src x-pack packages -name jest.config.js -not -path "*/__fixtures__/*" | sort)" -exit $exitCode \ No newline at end of file +exit $exitCode diff --git a/.buildkite/scripts/steps/test/ftr_configs.sh b/.buildkite/scripts/steps/test/ftr_configs.sh index 244b108a269f8..447dc5bca9e6b 100755 --- a/.buildkite/scripts/steps/test/ftr_configs.sh +++ b/.buildkite/scripts/steps/test/ftr_configs.sh @@ -4,10 +4,10 @@ set -euo pipefail source .buildkite/scripts/steps/functional/common.sh -export JOB_NUM=$BUILDKITE_PARALLEL_JOB +export JOB_NUM=${BUILDKITE_PARALLEL_JOB:-0} export JOB=ftr-configs-${JOB_NUM} -FAILED_CONFIGS_KEY="${BUILDKITE_STEP_ID}${BUILDKITE_PARALLEL_JOB:-0}" +FAILED_CONFIGS_KEY="${BUILDKITE_STEP_ID}${JOB_NUM}" # a FTR failure will result in the script returning an exit code of 10 exitCode=0 diff --git a/.buildkite/scripts/steps/test/jest.sh b/.buildkite/scripts/steps/test/jest.sh index cbf8bce703cc6..7b09c3f0d788a 100755 --- a/.buildkite/scripts/steps/test/jest.sh +++ b/.buildkite/scripts/steps/test/jest.sh @@ -8,6 +8,8 @@ is_test_execution_step .buildkite/scripts/bootstrap.sh +JOB=${BUILDKITE_PARALLEL_JOB:-0} + echo '--- Jest' -checks-reporter-with-killswitch "Jest Unit Tests $((BUILDKITE_PARALLEL_JOB+1))" \ +checks-reporter-with-killswitch "Jest Unit Tests $((JOB+1))" \ .buildkite/scripts/steps/test/jest_parallel.sh jest.config.js diff --git a/.buildkite/scripts/steps/test/jest_integration.sh b/.buildkite/scripts/steps/test/jest_integration.sh index 13412881cb6fa..2dce8fec0f26c 100755 --- a/.buildkite/scripts/steps/test/jest_integration.sh +++ b/.buildkite/scripts/steps/test/jest_integration.sh @@ -8,6 +8,8 @@ is_test_execution_step .buildkite/scripts/bootstrap.sh +JOB=${BUILDKITE_PARALLEL_JOB:-0} + echo '--- Jest Integration Tests' -checks-reporter-with-killswitch "Jest Integration Tests $((BUILDKITE_PARALLEL_JOB+1))" \ +checks-reporter-with-killswitch "Jest Integration Tests $((JOB+1))" \ .buildkite/scripts/steps/test/jest_parallel.sh jest.integration.config.js diff --git a/.buildkite/scripts/steps/test/jest_parallel.sh b/.buildkite/scripts/steps/test/jest_parallel.sh index 71ecf7a853d4a..8ca025a3e6516 100755 --- a/.buildkite/scripts/steps/test/jest_parallel.sh +++ b/.buildkite/scripts/steps/test/jest_parallel.sh @@ -2,7 +2,7 @@ set -euo pipefail -export JOB=$BUILDKITE_PARALLEL_JOB +export JOB=${BUILDKITE_PARALLEL_JOB:-0} # a jest failure will result in the script returning an exit code of 10 exitCode=0 From 0c2d06dd816780b3aaa3c19bc1f953eda4ae8c39 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Thu, 19 May 2022 10:55:09 -0400 Subject: [PATCH 31/37] [Spacetime] [Maps] Localized basemaps (#130930) --- package.json | 2 +- src/dev/license_checker/config.ts | 2 +- x-pack/plugins/maps/common/constants.ts | 2 + .../layer_descriptor_types.ts | 1 + .../maps/public/actions/layer_actions.ts | 9 +++ .../create_basemap_layer_descriptor.test.ts | 3 +- .../layers/create_basemap_layer_descriptor.ts | 2 + .../ems_vector_tile_layer.test.ts | 20 ++++++ .../ems_vector_tile_layer.tsx | 42 ++++++++++++- .../maps/public/classes/layers/layer.tsx | 10 +++ .../edit_layer_panel/layer_settings/index.tsx | 2 + .../layer_settings/layer_settings.tsx | 62 ++++++++++++++++++- yarn.lock | 9 +-- 13 files changed, 156 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 7e4e2ea78175a..84f9be547e7a1 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "@elastic/charts": "46.0.1", "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@8.2.0-canary.2", - "@elastic/ems-client": "8.3.0", + "@elastic/ems-client": "8.3.2", "@elastic/eui": "55.1.2", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "1.2.1", diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index 0ccab6fcf1b24..f10fb0231352d 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -76,7 +76,7 @@ export const DEV_ONLY_LICENSE_ALLOWED = ['MPL-2.0']; export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint - '@elastic/ems-client@8.3.0': ['Elastic License 2.0'], + '@elastic/ems-client@8.3.2': ['Elastic License 2.0'], '@elastic/eui@55.1.2': ['SSPL-1.0 OR Elastic License 2.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry }; diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index b51259307f3a1..53660c5256497 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -298,6 +298,8 @@ export const MAPS_NEW_VECTOR_LAYER_META_CREATED_BY = 'maps-new-vector-layer'; export const MAX_DRAWING_SIZE_BYTES = 10485760; // 10MB +export const NO_EMS_LOCALE = 'none'; +export const AUTOSELECT_EMS_LOCALE = 'autoselect'; export const emsWorldLayerId = 'world_countries'; export enum WIZARD_ID { diff --git a/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts index 996e3d7303b82..5aba9ba06dc48 100644 --- a/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts @@ -61,6 +61,7 @@ export type LayerDescriptor = { attribution?: Attribution; id: string; label?: string | null; + locale?: string | null; areLabelsOnTop?: boolean; minZoom?: number; maxZoom?: number; diff --git a/x-pack/plugins/maps/public/actions/layer_actions.ts b/x-pack/plugins/maps/public/actions/layer_actions.ts index 257b27e422e2f..6ffd9d59b1434 100644 --- a/x-pack/plugins/maps/public/actions/layer_actions.ts +++ b/x-pack/plugins/maps/public/actions/layer_actions.ts @@ -472,6 +472,15 @@ export function updateLayerLabel(id: string, newLabel: string) { }; } +export function updateLayerLocale(id: string, locale: string) { + return { + type: UPDATE_LAYER_PROP, + id, + propName: 'locale', + newValue: locale, + }; +} + export function setLayerAttribution(id: string, attribution: Attribution) { return { type: UPDATE_LAYER_PROP, diff --git a/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.test.ts b/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.test.ts index eded70a75e4ac..9c81b4c3aa72f 100644 --- a/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.test.ts @@ -80,10 +80,11 @@ describe('EMS is enabled', () => { id: '12345', includeInFitToBounds: true, label: null, + locale: 'autoselect', maxZoom: 24, minZoom: 0, - source: undefined, sourceDescriptor: { + id: undefined, isAutoSelect: true, lightModeDefault: 'road_map_desaturated', type: 'EMS_TMS', diff --git a/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.ts b/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.ts index e104261f90847..dd569951f90e4 100644 --- a/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.ts +++ b/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.ts @@ -14,6 +14,7 @@ import { KibanaTilemapSource } from '../sources/kibana_tilemap_source'; import { RasterTileLayer } from './raster_tile_layer/raster_tile_layer'; import { EmsVectorTileLayer } from './ems_vector_tile_layer/ems_vector_tile_layer'; import { EMSTMSSource } from '../sources/ems_tms_source'; +import { AUTOSELECT_EMS_LOCALE } from '../../../common/constants'; export function createBasemapLayerDescriptor(): LayerDescriptor | null { const tilemapSourceFromKibana = getKibanaTileMap(); @@ -27,6 +28,7 @@ export function createBasemapLayerDescriptor(): LayerDescriptor | null { const isEmsEnabled = getEMSSettings()!.isEMSEnabled(); if (isEmsEnabled) { const layerDescriptor = EmsVectorTileLayer.createDescriptor({ + locale: AUTOSELECT_EMS_LOCALE, sourceDescriptor: EMSTMSSource.createDescriptor({ isAutoSelect: true }), }); return layerDescriptor; diff --git a/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.test.ts b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.test.ts index 21c9c1f79d970..5f12f4cbc2b61 100644 --- a/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.test.ts @@ -55,6 +55,26 @@ describe('EmsVectorTileLayer', () => { expect(actualErrorMessage).toStrictEqual('network error'); }); + describe('getLocale', () => { + test('should set locale to none for existing layers where locale is not defined', () => { + const layer = new EmsVectorTileLayer({ + source: {} as unknown as EMSTMSSource, + layerDescriptor: {} as unknown as LayerDescriptor, + }); + expect(layer.getLocale()).toBe('none'); + }); + + test('should set locale for new layers', () => { + const layer = new EmsVectorTileLayer({ + source: {} as unknown as EMSTMSSource, + layerDescriptor: { + locale: 'xx', + } as unknown as LayerDescriptor, + }); + expect(layer.getLocale()).toBe('xx'); + }); + }); + describe('isInitialDataLoadComplete', () => { test('should return false when tile loading has not started', () => { const layer = new EmsVectorTileLayer({ diff --git a/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx index 646ccb3c09acd..6f8bc3470d792 100644 --- a/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx @@ -6,11 +6,19 @@ */ import type { Map as MbMap, LayerSpecification, StyleSpecification } from '@kbn/mapbox-gl'; +import { TMSService } from '@elastic/ems-client'; +import { i18n } from '@kbn/i18n'; import _ from 'lodash'; // @ts-expect-error import { RGBAImage } from './image_utils'; import { AbstractLayer } from '../layer'; -import { SOURCE_DATA_REQUEST_ID, LAYER_TYPE, LAYER_STYLE_TYPE } from '../../../../common/constants'; +import { + AUTOSELECT_EMS_LOCALE, + NO_EMS_LOCALE, + SOURCE_DATA_REQUEST_ID, + LAYER_TYPE, + LAYER_STYLE_TYPE, +} from '../../../../common/constants'; import { LayerDescriptor } from '../../../../common/descriptor_types'; import { DataRequest } from '../../util/data_request'; import { isRetina } from '../../../util'; @@ -50,6 +58,7 @@ export class EmsVectorTileLayer extends AbstractLayer { const tileLayerDescriptor = super.createDescriptor(options); tileLayerDescriptor.type = LAYER_TYPE.EMS_VECTOR_TILE; tileLayerDescriptor.alpha = _.get(options, 'alpha', 1); + tileLayerDescriptor.locale = _.get(options, 'locale', AUTOSELECT_EMS_LOCALE); tileLayerDescriptor.style = { type: LAYER_STYLE_TYPE.TILE }; return tileLayerDescriptor; } @@ -87,6 +96,10 @@ export class EmsVectorTileLayer extends AbstractLayer { return this._style; } + getLocale() { + return this._descriptor.locale ?? NO_EMS_LOCALE; + } + _canSkipSync({ prevDataRequest, nextMeta, @@ -309,7 +322,6 @@ export class EmsVectorTileLayer extends AbstractLayer { return; } this._addSpriteSheetToMapFromImageData(newJson, imageData, mbMap); - // sync layers const layers = vectorStyle.layers ? vectorStyle.layers : []; layers.forEach((layer) => { @@ -391,6 +403,27 @@ export class EmsVectorTileLayer extends AbstractLayer { }); } + _setLanguage(mbMap: MbMap, mbLayer: LayerSpecification, mbLayerId: string) { + const locale = this.getLocale(); + if (locale === null || locale === NO_EMS_LOCALE) { + if (mbLayer.type !== 'symbol') return; + + const textProperty = mbLayer.layout?.['text-field']; + if (mbLayer.layout && textProperty) { + mbMap.setLayoutProperty(mbLayerId, 'text-field', textProperty); + } + return; + } + + const textProperty = + locale === AUTOSELECT_EMS_LOCALE + ? TMSService.transformLanguageProperty(mbLayer, i18n.getLocale()) + : TMSService.transformLanguageProperty(mbLayer, locale); + if (textProperty !== undefined) { + mbMap.setLayoutProperty(mbLayerId, 'text-field', textProperty); + } + } + _setLayerZoomRange(mbMap: MbMap, mbLayer: LayerSpecification, mbLayerId: string) { let minZoom = this.getMinZoom(); if (typeof mbLayer.minzoom === 'number') { @@ -414,6 +447,7 @@ export class EmsVectorTileLayer extends AbstractLayer { this.syncVisibilityWithMb(mbMap, mbLayerId); this._setLayerZoomRange(mbMap, mbLayer, mbLayerId); this._setOpacityForType(mbMap, mbLayer, mbLayerId); + this._setLanguage(mbMap, mbLayer, mbLayerId); }); } @@ -425,6 +459,10 @@ export class EmsVectorTileLayer extends AbstractLayer { return true; } + supportsLabelLocales(): boolean { + return true; + } + async getLicensedFeatures() { return this._source.getLicensedFeatures(); } diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index 29aa19103e511..369f3a0099d66 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -53,6 +53,7 @@ export interface ILayer { supportsFitToBounds(): Promise; getAttributions(): Promise; getLabel(): string; + getLocale(): string | null; hasLegendDetails(): Promise; renderLegendDetails(): ReactElement | null; showAtZoomLevel(zoom: number): boolean; @@ -101,6 +102,7 @@ export interface ILayer { isPreviewLayer: () => boolean; areLabelsOnTop: () => boolean; supportsLabelsOnTop: () => boolean; + supportsLabelLocales: () => boolean; isFittable(): Promise; isIncludeInFitToBounds(): boolean; getLicensedFeatures(): Promise; @@ -250,6 +252,10 @@ export class AbstractLayer implements ILayer { return this._descriptor.label ? this._descriptor.label : ''; } + getLocale(): string | null { + return null; + } + getLayerIcon(isTocIcon: boolean): LayerIcon { return { icon: , @@ -461,6 +467,10 @@ export class AbstractLayer implements ILayer { return false; } + supportsLabelLocales(): boolean { + return false; + } + async getLicensedFeatures(): Promise { return []; } diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/index.tsx b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/index.tsx index 931557a3febe8..44336a5bbaf56 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/index.tsx +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/index.tsx @@ -12,6 +12,7 @@ import { clearLayerAttribution, setLayerAttribution, updateLayerLabel, + updateLayerLocale, updateLayerMaxZoom, updateLayerMinZoom, updateLayerAlpha, @@ -26,6 +27,7 @@ function mapDispatchToProps(dispatch: Dispatch) { setLayerAttribution: (id: string, attribution: Attribution) => dispatch(setLayerAttribution(id, attribution)), updateLabel: (id: string, label: string) => dispatch(updateLayerLabel(id, label)), + updateLocale: (id: string, locale: string) => dispatch(updateLayerLocale(id, locale)), updateMinZoom: (id: string, minZoom: number) => dispatch(updateLayerMinZoom(id, minZoom)), updateMaxZoom: (id: string, maxZoom: number) => dispatch(updateLayerMaxZoom(id, maxZoom)), updateAlpha: (id: string, alpha: number) => dispatch(updateLayerAlpha(id, alpha)), diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/layer_settings.tsx b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/layer_settings.tsx index e975834f2cf50..4ae95b9dc5c48 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/layer_settings.tsx +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/layer_settings.tsx @@ -11,6 +11,7 @@ import { EuiPanel, EuiFormRow, EuiFieldText, + EuiSelect, EuiSpacer, EuiSwitch, EuiSwitchEvent, @@ -20,7 +21,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { ValidatedDualRange } from '@kbn/kibana-react-plugin/public'; import { Attribution } from '../../../../common/descriptor_types'; -import { MAX_ZOOM } from '../../../../common/constants'; +import { AUTOSELECT_EMS_LOCALE, NO_EMS_LOCALE, MAX_ZOOM } from '../../../../common/constants'; import { AlphaSlider } from '../../../components/alpha_slider'; import { ILayer } from '../../../classes/layers/layer'; import { AttributionFormRow } from './attribution_form_row'; @@ -30,6 +31,7 @@ export interface Props { clearLayerAttribution: (layerId: string) => void; setLayerAttribution: (id: string, attribution: Attribution) => void; updateLabel: (layerId: string, label: string) => void; + updateLocale: (layerId: string, locale: string) => void; updateMinZoom: (layerId: string, minZoom: number) => void; updateMaxZoom: (layerId: string, maxZoom: number) => void; updateAlpha: (layerId: string, alpha: number) => void; @@ -48,6 +50,11 @@ export function LayerSettings(props: Props) { props.updateLabel(layerId, label); }; + const onLocaleChange = (event: ChangeEvent) => { + const { value } = event.target; + if (value) props.updateLocale(layerId, value); + }; + const onZoomChange = (value: [string, string]) => { props.updateMinZoom(layerId, Math.max(minVisibilityZoom, parseInt(value[0], 10))); props.updateMaxZoom(layerId, Math.min(maxVisibilityZoom, parseInt(value[1], 10))); @@ -155,6 +162,58 @@ export function LayerSettings(props: Props) { ); }; + const renderShowLocaleSelector = () => { + if (!props.layer.supportsLabelLocales()) { + return null; + } + + const options = [ + { + text: i18n.translate( + 'xpack.maps.layerPanel.settingsPanel.labelLanguageAutoselectDropDown', + { + defaultMessage: 'Autoselect based on Kibana locale', + } + ), + value: AUTOSELECT_EMS_LOCALE, + }, + { value: 'ar', text: 'العربية' }, + { value: 'de', text: 'Deutsch' }, + { value: 'en', text: 'English' }, + { value: 'es', text: 'Español' }, + { value: 'fr-fr', text: 'Français' }, + { value: 'hi-in', text: 'हिन्दी' }, + { value: 'it', text: 'Italiano' }, + { value: 'ja-jp', text: '日本語' }, + { value: 'ko', text: '한국어' }, + { value: 'pt-pt', text: 'Português' }, + { value: 'ru-ru', text: 'русский' }, + { value: 'zh-cn', text: '简体中文' }, + { + text: i18n.translate('xpack.maps.layerPanel.settingsPanel.labelLanguageNoneDropDown', { + defaultMessage: 'None', + }), + value: NO_EMS_LOCALE, + }, + ]; + + return ( + + + + ); + }; + return ( @@ -172,6 +231,7 @@ export function LayerSettings(props: Props) { {renderZoomSliders()} {renderShowLabelsOnTop()} + {renderShowLocaleSelector()} {renderIncludeInFitToBounds()} diff --git a/yarn.lock b/yarn.lock index ec5afced2df22..ef1d5d849ca75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1483,15 +1483,16 @@ "@elastic/transport" "^8.0.2" tslib "^2.3.0" -"@elastic/ems-client@8.3.0": - version "8.3.0" - resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-8.3.0.tgz#9d40c02e33c407d433b8e509d83c5edec24c4902" - integrity sha512-DlJDyUQzNrxGbS0AWxGiBNfq1hPQUP3Ib/Zyotgv7+VGGklb0mBwppde7WLVvuj0E+CYc6E63TJsoD8KNUO0MQ== +"@elastic/ems-client@8.3.2": + version "8.3.2" + resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-8.3.2.tgz#a12eafcfd9ac8d3068da78a5a77503ea8a89f67c" + integrity sha512-81u+Z7+4Y2Fu+sTl9QOKdG3SVeCzzpfyCsHFR8X0V2WFCpQa+SU4sSN9WhdLHz/pe9oi6Gtt5eFMF90TOO/ckg== dependencies: "@types/geojson" "^7946.0.7" "@types/lru-cache" "^5.1.0" "@types/topojson-client" "^3.0.0" "@types/topojson-specification" "^1.0.1" + chroma-js "^2.1.0" lodash "^4.17.15" lru-cache "^6.0.0" semver "^7.3.2" From d12156ec22324b882ff6aa97bc044537d1f44393 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Thu, 19 May 2022 08:06:32 -0700 Subject: [PATCH 32/37] [DOCS] Add severity field to case APIs (#132289) --- docs/api/cases/cases-api-add-comment.asciidoc | 1 + docs/api/cases/cases-api-create.asciidoc | 5 + docs/api/cases/cases-api-find-cases.asciidoc | 5 + .../cases-api-get-case-activity.asciidoc | 402 +++--------------- docs/api/cases/cases-api-get-case.asciidoc | 1 + docs/api/cases/cases-api-push.asciidoc | 1 + .../cases/cases-api-update-comment.asciidoc | 1 + docs/api/cases/cases-api-update.asciidoc | 5 + .../plugins/cases/docs/openapi/bundled.json | 37 ++ .../plugins/cases/docs/openapi/bundled.yaml | 27 ++ .../examples/create_case_response.yaml | 1 + .../examples/update_case_response.yaml | 1 + .../schemas/case_response_properties.yaml | 2 + .../openapi/components/schemas/severity.yaml | 8 + .../cases/docs/openapi/paths/api@cases.yaml | 4 + .../openapi/paths/s@{spaceid}@api@cases.yaml | 4 + 16 files changed, 151 insertions(+), 354 deletions(-) create mode 100644 x-pack/plugins/cases/docs/openapi/components/schemas/severity.yaml diff --git a/docs/api/cases/cases-api-add-comment.asciidoc b/docs/api/cases/cases-api-add-comment.asciidoc index 203492d6aa632..b179c9ac2e4fb 100644 --- a/docs/api/cases/cases-api-add-comment.asciidoc +++ b/docs/api/cases/cases-api-add-comment.asciidoc @@ -120,6 +120,7 @@ The API returns details about the case and its comments. For example: }, "owner": "cases", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-03-24T00:37:03.906Z", diff --git a/docs/api/cases/cases-api-create.asciidoc b/docs/api/cases/cases-api-create.asciidoc index 73c89937466b3..b39125cf7538e 100644 --- a/docs/api/cases/cases-api-create.asciidoc +++ b/docs/api/cases/cases-api-create.asciidoc @@ -140,6 +140,10 @@ An object that contains the case settings. (Required, boolean) Turns alert syncing on or off. ==== +`severity`:: +(Optional,string) The severity of the case. Valid values are: `critical`, `high`, +`low`, and `medium`. + `tags`:: (Required, string array) The words and phrases that help categorize cases. It can be an empty array. @@ -206,6 +210,7 @@ the case identifier, version, and creation time. For example: "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/docs/api/cases/cases-api-find-cases.asciidoc b/docs/api/cases/cases-api-find-cases.asciidoc index 3e94dd56ffa36..92b23a4aafb8d 100644 --- a/docs/api/cases/cases-api-find-cases.asciidoc +++ b/docs/api/cases/cases-api-find-cases.asciidoc @@ -62,6 +62,10 @@ filters the objects in the response. (Optional, string or array of strings) The fields to perform the `simple_query_string` parsed query against. +`severity`:: +(Optional,string) The severity of the case. Valid values are: `critical`, `high`, +`low`, and `medium`. + `sortField`:: (Optional, string) Determines which field is used to sort the results, `createdAt` or `updatedAt`. Defaults to `createdAt`. @@ -126,6 +130,7 @@ The API returns a JSON object listing the retrieved cases. For example: }, "owner": "securitySolution", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-03-29T13:03:23.533Z", diff --git a/docs/api/cases/cases-api-get-case-activity.asciidoc b/docs/api/cases/cases-api-get-case-activity.asciidoc index 25d102dc11ee7..0f931965df248 100644 --- a/docs/api/cases/cases-api-get-case-activity.asciidoc +++ b/docs/api/cases/cases-api-get-case-activity.asciidoc @@ -51,362 +51,56 @@ The API returns a JSON object with all the activity for the case. For example: [source,json] -------------------------------------------------- [ - { - "action": "create", - "action_id": "5275af50-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:34:48.709Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "connector": { - "fields": null, - "id": "none", - "name": "none", - "type": ".none" - }, - "description": "migrating user actions", - "settings": { - "syncAlerts": true - }, - "status": "open", - "tags": [ - "user", - "actions" - ], - "title": "User actions", - "owner": "securitySolution" - }, - "sub_case_id": "", - "type": "create_case" - }, - { - "action": "create", - "action_id": "72e73240-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": "72a03e30-5e7d-11ec-9ee9-cd64f0b77b3c", - "created_at": "2021-12-16T14:35:42.872Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "comment": { - "comment": "a comment", - "owner": "securitySolution", - "type": "user" - } - }, - "sub_case_id": "", - "type": "comment" - }, - { - "action": "update", - "action_id": "7685b5c0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:35:48.826Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "title": "User actions!" - }, - "sub_case_id": "", - "type": "title" - }, - { - "action": "update", - "action_id": "7a2d8810-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:35:55.421Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "description": "migrating user actions and update!" - }, - "sub_case_id": "", - "type": "description" - }, - { - "action": "update", - "action_id": "7f942160-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": "72a03e30-5e7d-11ec-9ee9-cd64f0b77b3c", - "created_at": "2021-12-16T14:36:04.120Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "comment": { - "comment": "a comment updated!", - "owner": "securitySolution", - "type": "user" - } - }, - "sub_case_id": "", - "type": "comment" - }, - { - "action": "add", - "action_id": "8591a380-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:13.840Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "tags": [ - "migration" - ] - }, - "sub_case_id": "", - "type": "tags" - }, - { - "action": "delete", - "action_id": "8591a381-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:13.840Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "tags": [ - "user" - ] - }, - "sub_case_id": "", - "type": "tags" + { + "created_at": "2022-12-16T14:34:48.709Z", + "created_by": { + "email": "", + "full_name": "", + "username": "elastic" }, - { - "action": "update", - "action_id": "87fadb50-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:17.764Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "settings": { - "syncAlerts": false - } - }, - "sub_case_id": "", - "type": "settings" - }, - { - "action": "update", - "action_id": "89ca4420-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:21.509Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "status": "in-progress" - }, - "sub_case_id": "", - "type": "status" - }, - { - "action": "update", - "action_id": "9060aae0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:32.716Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "connector": { - "fields": { - "issueType": "10001", - "parent": null, - "priority": "High" - }, - "id": "6773fba0-5e7d-11ec-9ee9-cd64f0b77b3c", - "name": "Jira", - "type": ".jira" - } - }, - "sub_case_id": "", - "type": "connector" - }, - { - "action": "push_to_service", - "action_id": "988579d0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:46.443Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "externalService": { - "connector_id": "6773fba0-5e7d-11ec-9ee9-cd64f0b77b3c", - "connector_name": "Jira", - "external_id": "26225", - "external_title": "CASES-229", - "external_url": "https://example.com/browse/CASES-229", - "pushed_at": "2021-12-16T14:36:46.443Z", - "pushed_by": { - "email": "", - "full_name": "", - "username": "elastic" - } - } - }, - "sub_case_id": "", - "type": "pushed" + "owner": "securitySolution", + "action": "create", + "payload": { + "title": "User actions", + "tags": [ + "user", + "actions" + ], + "connector": { + "fields": null, + "id": "none", + "name": "none", + "type": ".none" + }, + "settings": { + "syncAlerts": true + }, + "owner": "cases", + "severity": "low", + "description": "migrating user actions", + "status": "open" }, - { - "action": "update", - "action_id": "bcb76020-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:37:46.863Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "connector": { - "fields": { - "incidentTypes": [ - "17", - "4" - ], - "severityCode": "5" - }, - "id": "b3214df0-5e7d-11ec-9ee9-cd64f0b77b3c", - "name": "IBM", - "type": ".resilient" - } - }, - "sub_case_id": "", - "type": "connector" - }, - { - "action": "push_to_service", - "action_id": "c0338e90-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:37:53.016Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "externalService": { - "connector_id": "b3214df0-5e7d-11ec-9ee9-cd64f0b77b3c", - "connector_name": "IBM", - "external_id": "17574", - "external_title": "17574", - "external_url": "https://example.com/#incidents/17574", - "pushed_at": "2021-12-16T14:37:53.016Z", - "pushed_by": { - "email": "", - "full_name": "", - "username": "elastic" - } - } - }, - "sub_case_id": "", - "type": "pushed" + "type": "create_case", + "action_id": "5275af50-5e7d-11ec-9ee9-cd64f0b77b3c", + "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", + "comment_id": null + }, + { + "created_at": "2022-12-16T14:35:42.872Z", + "created_by": { + "email": "", + "full_name": "", + "username": "elastic" }, - { - "action": "update", - "action_id": "c5b6d7a0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:38:01.895Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "connector": { - "fields": { - "issueType": "10001", - "parent": null, - "priority": "Lowest" - }, - "id": "6773fba0-5e7d-11ec-9ee9-cd64f0b77b3c", - "name": "Jira", - "type": ".jira" - } - }, - "sub_case_id": "", - "type": "connector" + "owner": "cases", + "action": "add", + "payload": { + "tags": ["bubblegum"] }, - { - "action": "create", - "action_id": "ca8f61c0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": "ca1d17f0-5e7d-11ec-9ee9-cd64f0b77b3c", - "created_at": "2021-12-16T14:38:09.649Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "comment": { - "comment": "and another comment!", - "owner": "securitySolution", - "type": "user" - } - }, - "sub_case_id": "", - "type": "comment" - } - ] + "type": "tags", + "action_id": "72e73240-5e7d-11ec-9ee9-cd64f0b77b3c", + "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", + "comment_id": null + } +] -------------------------------------------------- \ No newline at end of file diff --git a/docs/api/cases/cases-api-get-case.asciidoc b/docs/api/cases/cases-api-get-case.asciidoc index 42cf0672065e7..a3adc90fe09bf 100644 --- a/docs/api/cases/cases-api-get-case.asciidoc +++ b/docs/api/cases/cases-api-get-case.asciidoc @@ -91,6 +91,7 @@ The API returns a JSON object with the retrieved case. For example: "syncAlerts": true }, "owner": "securitySolution", + "severity": "low", "duration": null, "tags": [ "phishing", diff --git a/docs/api/cases/cases-api-push.asciidoc b/docs/api/cases/cases-api-push.asciidoc index 16c411104caed..46dbc1110d589 100644 --- a/docs/api/cases/cases-api-push.asciidoc +++ b/docs/api/cases/cases-api-push.asciidoc @@ -68,6 +68,7 @@ The API returns a JSON object representing the pushed case. For example: "syncAlerts": true }, "owner": "securitySolution", + "severity": "low", "duration": null, "closed_at": null, "closed_by": null, diff --git a/docs/api/cases/cases-api-update-comment.asciidoc b/docs/api/cases/cases-api-update-comment.asciidoc index d00d1eb66ea7c..a4ea53ec19468 100644 --- a/docs/api/cases/cases-api-update-comment.asciidoc +++ b/docs/api/cases/cases-api-update-comment.asciidoc @@ -135,6 +135,7 @@ The API returns details about the case and its comments. For example: "settings": {"syncAlerts":false}, "owner": "cases", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-03-24T00:37:03.906Z", diff --git a/docs/api/cases/cases-api-update.asciidoc b/docs/api/cases/cases-api-update.asciidoc index ebad2feaedff4..ea33394a6ee63 100644 --- a/docs/api/cases/cases-api-update.asciidoc +++ b/docs/api/cases/cases-api-update.asciidoc @@ -144,6 +144,10 @@ An object that contains the case settings. (Required, boolean) Turn on or off synching with alerts. ===== +`severity`:: +(Optional,string) The severity of the case. Valid values are: `critical`, `high`, +`low`, and `medium`. + `status`:: (Optional, string) The case status. Valid values are: `closed`, `in-progress`, and `open`. @@ -227,6 +231,7 @@ The API returns the updated case with a new `version` value. For example: "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/bundled.json b/x-pack/plugins/cases/docs/openapi/bundled.json index 0cb084b5beb7c..d673f470de740 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.json +++ b/x-pack/plugins/cases/docs/openapi/bundled.json @@ -157,6 +157,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "tags": { "description": "The words and phrases that help categorize cases. It can be an empty array.", "type": "array", @@ -402,6 +405,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -636,6 +642,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -887,6 +896,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -1093,6 +1105,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "tags": { "description": "The words and phrases that help categorize cases. It can be an empty array.", "type": "array", @@ -1338,6 +1353,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -1578,6 +1596,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -1829,6 +1850,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -1959,6 +1983,17 @@ "securitySolution" ] }, + "severity": { + "type": "string", + "description": "The severity of the case.", + "enum": [ + "critical", + "high", + "low", + "medium" + ], + "default": "low" + }, "status": { "type": "string", "description": "The status of the case.", @@ -2015,6 +2050,7 @@ "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", @@ -2090,6 +2126,7 @@ "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/bundled.yaml b/x-pack/plugins/cases/docs/openapi/bundled.yaml index 083aef3c25ad2..6dcde228ebd7c 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.yaml +++ b/x-pack/plugins/cases/docs/openapi/bundled.yaml @@ -147,6 +147,8 @@ paths: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '#/components/schemas/severity' tags: description: >- The words and phrases that help categorize cases. It can be @@ -363,6 +365,8 @@ paths: syncAlerts: type: boolean example: true + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -569,6 +573,8 @@ paths: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -784,6 +790,8 @@ paths: syncAlerts: type: boolean example: true + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -960,6 +968,8 @@ paths: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '#/components/schemas/severity' tags: description: >- The words and phrases that help categorize cases. It can be @@ -1176,6 +1186,8 @@ paths: syncAlerts: type: boolean example: true + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -1384,6 +1396,8 @@ paths: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -1599,6 +1613,8 @@ paths: syncAlerts: type: boolean example: true + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -1686,6 +1702,15 @@ components: - cases - observability - securitySolution + severity: + type: string + description: The severity of the case. + enum: + - critical + - high + - low + - medium + default: low status: type: string description: The status of the case. @@ -1738,6 +1763,7 @@ components: cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active duration: null + severity: low closed_at: null closed_by: null created_at: '2022-05-13T09:16:17.416Z' @@ -1804,6 +1830,7 @@ components: cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active! duration: null + severity: low closed_at: null closed_by: null created_at: '2022-05-13T09:16:17.416Z' diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml index bc5fa1f5bc049..9646425bca0fe 100644 --- a/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml @@ -18,6 +18,7 @@ value: "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml index 114669b893651..c7b02cd47deaa 100644 --- a/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml @@ -19,6 +19,7 @@ value: "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml index 6a2c3c3963c3c..53f1fd3910224 100644 --- a/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml @@ -84,6 +84,8 @@ settings: syncAlerts: type: boolean example: true +severity: + $ref: 'severity.yaml' status: $ref: 'status.yaml' tags: diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/severity.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/severity.yaml new file mode 100644 index 0000000000000..cf5967f8f012e --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/severity.yaml @@ -0,0 +1,8 @@ +type: string +description: The severity of the case. +enum: + - critical + - high + - low + - medium +default: low \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml b/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml index c37bb3ecef645..62816ae2767cc 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml @@ -30,6 +30,8 @@ post: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '../components/schemas/severity.yaml' tags: description: The words and phrases that help categorize cases. It can be an empty array. type: array @@ -123,6 +125,8 @@ patch: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '../components/schemas/severity.yaml' status: $ref: '../components/schemas/status.yaml' tags: diff --git a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml index c03ea64a53538..b2c2a8e4e11f1 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml @@ -31,6 +31,8 @@ post: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '../components/schemas/severity.yaml' tags: description: The words and phrases that help categorize cases. It can be an empty array. type: array @@ -126,6 +128,8 @@ patch: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '../components/schemas/severity.yaml' status: $ref: '../components/schemas/status.yaml' tags: From dd6dacf0035e959885b04296444603492d6b0c71 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 19 May 2022 08:21:20 -0700 Subject: [PATCH 33/37] [jest/ci-stats] when jest fails to execute a test file, report it as a failure (#132527) --- packages/kbn-test/src/jest/ci_stats_jest_reporter.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/kbn-test/src/jest/ci_stats_jest_reporter.ts b/packages/kbn-test/src/jest/ci_stats_jest_reporter.ts index 3ac4a64c1f3f7..6cf979eb46a26 100644 --- a/packages/kbn-test/src/jest/ci_stats_jest_reporter.ts +++ b/packages/kbn-test/src/jest/ci_stats_jest_reporter.ts @@ -41,6 +41,7 @@ export default class CiStatsJestReporter extends BaseReporter { private startTime: number | undefined; private passCount = 0; private failCount = 0; + private testExecErrorCount = 0; private group: CiStatsReportTestsOptions['group'] | undefined; private readonly testRuns: CiStatsReportTestsOptions['testRuns'] = []; @@ -90,6 +91,10 @@ export default class CiStatsJestReporter extends BaseReporter { return; } + if (testResult.testExecError) { + this.testExecErrorCount += 1; + } + let elapsedTime = 0; for (const t of testResult.testResults) { const result = t.status === 'failed' ? 'fail' : t.status === 'passed' ? 'pass' : 'skip'; @@ -123,7 +128,8 @@ export default class CiStatsJestReporter extends BaseReporter { } this.group.durationMs = Date.now() - this.startTime; - this.group.result = this.failCount ? 'fail' : this.passCount ? 'pass' : 'skip'; + this.group.result = + this.failCount || this.testExecErrorCount ? 'fail' : this.passCount ? 'pass' : 'skip'; await this.reporter.reportTests({ group: this.group, From 75941b1eaaa862ce9525037f99e44302a675b633 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Thu, 19 May 2022 18:01:54 +0200 Subject: [PATCH 34/37] Prevent react event pooling to clear data when used (#132419) --- .../operations/definitions/date_histogram.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx index 3b6d75879640d..3bbd329a39396 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx @@ -197,12 +197,15 @@ export const dateHistogramOperation: OperationDefinition< const onChangeDropPartialBuckets = useCallback( (ev: EuiSwitchEvent) => { + // updateColumnParam will be called async + // store the checked value before the event pooling clears it + const value = ev.target.checked; updateLayer((newLayer) => updateColumnParam({ layer: newLayer, columnId, paramName: 'dropPartials', - value: ev.target.checked, + value, }) ); }, From e2827350e97804601905add05debe2a7ea9690dc Mon Sep 17 00:00:00 2001 From: Ashokaditya <1849116+ashokaditya@users.noreply.github.com> Date: Thu, 19 May 2022 18:02:42 +0200 Subject: [PATCH 35/37] [Security Solution][Endpoint][EventFilters] Port Event Filters to use `ArtifactListPage` component (#130995) * Delete redundant files fixes elastic/security-team/issues/3093 * Make the event filter form work fixes elastic/security-team/issues/3093 * Update event_filters_list.test.tsx fixes elastic/security-team/issues/3093 * update form tests fixes elastic/security-team/issues/3093 * update event filter flyout fixes elastic/security-team/issues/3093 * Show apt copy when OS options are not visible * update tests fixes elastic/security-team/issues/3093 * extract static OS options review changes * test for each type of artifact list review changes * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * update test mocks * update form review changes * update state handler name review changes * extract test id prefix Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../public/common/store/actions.ts | 7 +- .../timeline_actions/alert_context_menu.tsx | 2 +- .../management/pages/event_filters/index.tsx | 4 +- ...nt_filters_api_client.ts => api_client.ts} | 0 .../pages/event_filters/store/action.ts | 92 --- .../pages/event_filters/store/builders.ts | 38 -- .../event_filters/store/middleware.test.ts | 387 ------------ .../pages/event_filters/store/middleware.ts | 342 ----------- .../pages/event_filters/store/reducer.test.ts | 221 ------- .../pages/event_filters/store/reducer.ts | 271 --------- .../pages/event_filters/store/selector.ts | 224 ------- .../event_filters/store/selectors.test.ts | 391 ------------ .../pages/event_filters/test_utils/index.ts | 19 +- .../management/pages/event_filters/types.ts | 29 - .../view/components/empty/index.tsx | 64 -- .../event_filter_delete_modal.test.tsx | 177 ------ .../components/event_filter_delete_modal.tsx | 159 ----- .../components/event_filters_flyout.test.tsx | 222 +++++++ .../view/components/event_filters_flyout.tsx | 239 ++++++++ .../view/components/flyout/index.test.tsx | 287 --------- .../view/components/flyout/index.tsx | 302 ---------- .../view/components/form.test.tsx | 468 +++++++++++++++ .../event_filters/view/components/form.tsx | 558 ++++++++++++++++++ .../view/components/form/index.test.tsx | 338 ----------- .../view/components/form/index.tsx | 487 --------------- .../view/components/form/translations.ts | 44 -- .../view/event_filters_list.test.tsx | 57 ++ .../event_filters/view/event_filters_list.tsx | 150 +++++ .../view/event_filters_list_page.test.tsx | 247 -------- .../view/event_filters_list_page.tsx | 339 ----------- .../pages/event_filters/view/hooks.ts | 78 --- .../pages/event_filters/view/translations.ts | 47 +- .../use_event_filters_notification.test.tsx | 230 -------- .../event_filters/{store => view}/utils.ts | 0 .../policy_artifacts_delete_modal.test.tsx | 48 +- .../flyout/policy_artifacts_flyout.test.tsx | 2 +- .../layout/policy_artifacts_layout.test.tsx | 2 +- .../list/policy_artifacts_list.test.tsx | 2 +- .../components/fleet_artifacts_card.test.tsx | 2 +- .../fleet_integration_artifacts_card.test.tsx | 2 +- .../endpoint_package_custom_extension.tsx | 2 +- .../endpoint_policy_edit_extension.tsx | 2 +- .../pages/policy/view/tabs/policy_tabs.tsx | 2 +- .../public/management/store/middleware.ts | 7 - .../public/management/store/reducer.ts | 5 - .../public/management/types.ts | 2 - .../side_panel/event_details/footer.tsx | 2 +- .../translations/translations/fr-FR.json | 30 - .../translations/translations/ja-JP.json | 30 - .../translations/translations/zh-CN.json | 30 - 50 files changed, 1754 insertions(+), 4936 deletions(-) rename x-pack/plugins/security_solution/public/management/pages/event_filters/service/{event_filters_api_client.ts => api_client.ts} (100%) delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/builders.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.test.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/hooks.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/use_event_filters_notification.test.tsx rename x-pack/plugins/security_solution/public/management/pages/event_filters/{store => view}/utils.ts (100%) diff --git a/x-pack/plugins/security_solution/public/common/store/actions.ts b/x-pack/plugins/security_solution/public/common/store/actions.ts index 585fdb98a0323..f1d5e51e172ba 100644 --- a/x-pack/plugins/security_solution/public/common/store/actions.ts +++ b/x-pack/plugins/security_solution/public/common/store/actions.ts @@ -7,7 +7,6 @@ import { EndpointAction } from '../../management/pages/endpoint_hosts/store/action'; import { PolicyDetailsAction } from '../../management/pages/policy/store/policy_details'; -import { EventFiltersPageAction } from '../../management/pages/event_filters/store/action'; export { appActions } from './app'; export { dragAndDropActions } from './drag_and_drop'; @@ -15,8 +14,4 @@ export { inputsActions } from './inputs'; export { sourcererActions } from './sourcerer'; import { RoutingAction } from './routing'; -export type AppAction = - | EndpointAction - | RoutingAction - | PolicyDetailsAction - | EventFiltersPageAction; +export type AppAction = EndpointAction | RoutingAction | PolicyDetailsAction; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 160252f4d11c1..05a91f094ed38 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -28,7 +28,7 @@ import { TimelineId } from '../../../../../common/types'; import { AlertData, EcsHit } from '../../../../common/components/exceptions/types'; import { useQueryAlerts } from '../../../containers/detection_engine/alerts/use_query'; import { useSignalIndex } from '../../../containers/detection_engine/alerts/use_signal_index'; -import { EventFiltersFlyout } from '../../../../management/pages/event_filters/view/components/flyout'; +import { EventFiltersFlyout } from '../../../../management/pages/event_filters/view/components/event_filters_flyout'; import { useAlertsActions } from './use_alerts_actions'; import { useExceptionFlyout } from './use_add_exception_flyout'; import { useExceptionActions } from './use_add_exception_actions'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/index.tsx index 86c2f2364961d..54d18f85b739a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/index.tsx @@ -9,12 +9,12 @@ import { Route, Switch } from 'react-router-dom'; import React from 'react'; import { NotFoundPage } from '../../../app/404'; import { MANAGEMENT_ROUTING_EVENT_FILTERS_PATH } from '../../common/constants'; -import { EventFiltersListPage } from './view/event_filters_list_page'; +import { EventFiltersList } from './view/event_filters_list'; export const EventFiltersContainer = () => { return ( - + ); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/service/event_filters_api_client.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/service/api_client.ts similarity index 100% rename from x-pack/plugins/security_solution/public/management/pages/event_filters/service/event_filters_api_client.ts rename to x-pack/plugins/security_solution/public/management/pages/event_filters/service/api_client.ts diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts deleted file mode 100644 index 4325c4d90951a..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.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 { Action } from 'redux'; -import type { - ExceptionListItemSchema, - CreateExceptionListItemSchema, - UpdateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { AsyncResourceState } from '../../../state/async_resource_state'; -import { EventFiltersListPageState } from '../types'; - -export type EventFiltersListPageDataChanged = Action<'eventFiltersListPageDataChanged'> & { - payload: EventFiltersListPageState['listPage']['data']; -}; - -export type EventFiltersListPageDataExistsChanged = - Action<'eventFiltersListPageDataExistsChanged'> & { - payload: EventFiltersListPageState['listPage']['dataExist']; - }; - -export type EventFilterForDeletion = Action<'eventFilterForDeletion'> & { - payload: ExceptionListItemSchema; -}; - -export type EventFilterDeletionReset = Action<'eventFilterDeletionReset'>; - -export type EventFilterDeleteSubmit = Action<'eventFilterDeleteSubmit'>; - -export type EventFilterDeleteStatusChanged = Action<'eventFilterDeleteStatusChanged'> & { - payload: EventFiltersListPageState['listPage']['deletion']['status']; -}; - -export type EventFiltersInitForm = Action<'eventFiltersInitForm'> & { - payload: { - entry: UpdateExceptionListItemSchema | CreateExceptionListItemSchema; - }; -}; - -export type EventFiltersInitFromId = Action<'eventFiltersInitFromId'> & { - payload: { - id: string; - }; -}; - -export type EventFiltersChangeForm = Action<'eventFiltersChangeForm'> & { - payload: { - entry?: UpdateExceptionListItemSchema | CreateExceptionListItemSchema; - hasNameError?: boolean; - hasItemsError?: boolean; - hasOSError?: boolean; - newComment?: string; - }; -}; - -export type EventFiltersUpdateStart = Action<'eventFiltersUpdateStart'>; -export type EventFiltersUpdateSuccess = Action<'eventFiltersUpdateSuccess'>; -export type EventFiltersCreateStart = Action<'eventFiltersCreateStart'>; -export type EventFiltersCreateSuccess = Action<'eventFiltersCreateSuccess'>; -export type EventFiltersCreateError = Action<'eventFiltersCreateError'>; - -export type EventFiltersFormStateChanged = Action<'eventFiltersFormStateChanged'> & { - payload: AsyncResourceState; -}; - -export type EventFiltersForceRefresh = Action<'eventFiltersForceRefresh'> & { - payload: { - forceRefresh: boolean; - }; -}; - -export type EventFiltersPageAction = - | EventFiltersListPageDataChanged - | EventFiltersListPageDataExistsChanged - | EventFiltersInitForm - | EventFiltersInitFromId - | EventFiltersChangeForm - | EventFiltersUpdateStart - | EventFiltersUpdateSuccess - | EventFiltersCreateStart - | EventFiltersCreateSuccess - | EventFiltersCreateError - | EventFiltersFormStateChanged - | EventFilterForDeletion - | EventFilterDeletionReset - | EventFilterDeleteSubmit - | EventFilterDeleteStatusChanged - | EventFiltersForceRefresh; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/builders.ts deleted file mode 100644 index 397a7c2ae1e79..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/builders.ts +++ /dev/null @@ -1,38 +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 { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../../common/constants'; -import { EventFiltersListPageState } from '../types'; -import { createUninitialisedResourceState } from '../../../state'; - -export const initialEventFiltersPageState = (): EventFiltersListPageState => ({ - entries: [], - form: { - entry: undefined, - hasNameError: false, - hasItemsError: false, - hasOSError: false, - newComment: '', - submissionResourceState: createUninitialisedResourceState(), - }, - location: { - page_index: MANAGEMENT_DEFAULT_PAGE, - page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, - filter: '', - included_policies: '', - }, - listPage: { - active: false, - forceRefresh: false, - data: createUninitialisedResourceState(), - dataExist: createUninitialisedResourceState(), - deletion: { - item: undefined, - status: createUninitialisedResourceState(), - }, - }, -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.test.ts deleted file mode 100644 index 9ec7e84d693fd..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.test.ts +++ /dev/null @@ -1,387 +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 { applyMiddleware, createStore, Store } from 'redux'; - -import { - createSpyMiddleware, - MiddlewareActionSpyHelper, -} from '../../../../common/store/test_utils'; -import { AppAction } from '../../../../common/store/actions'; -import { createEventFiltersPageMiddleware } from './middleware'; -import { eventFiltersPageReducer } from './reducer'; - -import { initialEventFiltersPageState } from './builders'; -import { getInitialExceptionFromEvent } from './utils'; -import { createdEventFilterEntryMock, ecsEventMock } from '../test_utils'; -import { EventFiltersListPageState, EventFiltersService } from '../types'; -import { getFoundExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/found_exception_list_item_schema.mock'; -import { isFailedResourceState, isLoadedResourceState } from '../../../state'; -import { getListFetchError } from './selector'; -import type { - ExceptionListItemSchema, - CreateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { Immutable } from '../../../../../common/endpoint/types'; -import { parsePoliciesAndFilterToKql } from '../../../common/utils'; - -const createEventFiltersServiceMock = (): jest.Mocked => ({ - addEventFilters: jest.fn(), - getList: jest.fn(), - getOne: jest.fn(), - updateOne: jest.fn(), - deleteOne: jest.fn(), - getSummary: jest.fn(), -}); - -const createStoreSetup = (eventFiltersService: EventFiltersService) => { - const spyMiddleware = createSpyMiddleware(); - - return { - spyMiddleware, - store: createStore( - eventFiltersPageReducer, - applyMiddleware( - createEventFiltersPageMiddleware(eventFiltersService), - spyMiddleware.actionSpyMiddleware - ) - ), - }; -}; - -describe('Event filters middleware', () => { - let service: jest.Mocked; - let store: Store; - let spyMiddleware: MiddlewareActionSpyHelper; - let initialState: EventFiltersListPageState; - - beforeEach(() => { - initialState = initialEventFiltersPageState(); - service = createEventFiltersServiceMock(); - - const storeSetup = createStoreSetup(service); - - store = storeSetup.store as Store; - spyMiddleware = storeSetup.spyMiddleware; - }); - - describe('initial state', () => { - it('sets initial state properly', async () => { - expect(createStoreSetup(createEventFiltersServiceMock()).store.getState()).toStrictEqual( - initialState - ); - }); - }); - - describe('when on the List page', () => { - const changeUrl = (searchParams: string = '') => { - store.dispatch({ - type: 'userChangedUrl', - payload: { - pathname: '/administration/event_filters', - search: searchParams, - hash: '', - key: 'ylsd7h', - }, - }); - }; - - beforeEach(() => { - service.getList.mockResolvedValue(getFoundExceptionListItemSchemaMock()); - }); - - it.each([ - [undefined, undefined, undefined], - [3, 50, ['1', '2']], - ])( - 'should trigger api call to retrieve event filters with url params page_index[%s] page_size[%s] included_policies[%s]', - async (pageIndex, perPage, policies) => { - const dataLoaded = spyMiddleware.waitForAction('eventFiltersListPageDataChanged', { - validate({ payload }) { - return isLoadedResourceState(payload); - }, - }); - - changeUrl( - (pageIndex && - perPage && - `?page_index=${pageIndex}&page_size=${perPage}&included_policies=${policies}`) || - '' - ); - await dataLoaded; - - expect(service.getList).toHaveBeenCalledWith({ - page: (pageIndex ?? 0) + 1, - perPage: perPage ?? 10, - sortField: 'created_at', - sortOrder: 'desc', - filter: policies ? parsePoliciesAndFilterToKql({ policies }) : undefined, - }); - } - ); - - it('should not refresh the list if nothing in the query has changed', async () => { - const dataLoaded = spyMiddleware.waitForAction('eventFiltersListPageDataChanged', { - validate({ payload }) { - return isLoadedResourceState(payload); - }, - }); - - changeUrl(); - await dataLoaded; - const getListCallCount = service.getList.mock.calls.length; - changeUrl('&show=create'); - - expect(service.getList.mock.calls.length).toBe(getListCallCount); - }); - - it('should trigger second api call to check if data exists if first returned no records', async () => { - const dataLoaded = spyMiddleware.waitForAction('eventFiltersListPageDataExistsChanged', { - validate({ payload }) { - return isLoadedResourceState(payload); - }, - }); - - service.getList.mockResolvedValue({ - data: [], - total: 0, - page: 1, - per_page: 10, - }); - - changeUrl(); - await dataLoaded; - - expect(service.getList).toHaveBeenCalledTimes(2); - expect(service.getList).toHaveBeenNthCalledWith(2, { - page: 1, - perPage: 1, - }); - }); - - it('should dispatch a Failure if an API error was encountered', async () => { - const dataLoaded = spyMiddleware.waitForAction('eventFiltersListPageDataChanged', { - validate({ payload }) { - return isFailedResourceState(payload); - }, - }); - - service.getList.mockRejectedValue({ - body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' }, - }); - - changeUrl(); - await dataLoaded; - - expect(getListFetchError(store.getState())).toEqual({ - message: 'error message', - statusCode: 500, - error: 'Internal Server Error', - }); - }); - }); - - describe('submit creation event filter', () => { - it('does not submit when entry is undefined', async () => { - store.dispatch({ type: 'eventFiltersCreateStart' }); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { type: 'UninitialisedResourceState' }, - }, - }); - }); - - it('does submit when entry is not undefined', async () => { - service.addEventFilters.mockResolvedValue(createdEventFilterEntryMock()); - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - store.dispatch({ type: 'eventFiltersCreateStart' }); - - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'LoadedResourceState', - data: createdEventFilterEntryMock(), - }, - }, - }); - }); - - it('does submit when entry has empty comments with white spaces', async () => { - service.addEventFilters.mockImplementation( - async (exception: Immutable) => { - expect(exception.comments).toStrictEqual(createdEventFilterEntryMock().comments); - return createdEventFilterEntryMock(); - } - ); - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - store.dispatch({ - type: 'eventFiltersChangeForm', - payload: { newComment: ' ', entry }, - }); - - store.dispatch({ type: 'eventFiltersCreateStart' }); - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'LoadedResourceState', - data: createdEventFilterEntryMock(), - }, - }, - }); - }); - - it('does throw error when creating', async () => { - service.addEventFilters.mockRejectedValue({ - body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' }, - }); - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - store.dispatch({ type: 'eventFiltersCreateStart' }); - - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'FailedResourceState', - lastLoadedState: undefined, - error: { - error: 'Internal Server Error', - message: 'error message', - statusCode: 500, - }, - }, - }, - }); - }); - }); - describe('load event filterby id', () => { - it('init form with an entry loaded by id from API', async () => { - service.getOne.mockResolvedValue(createdEventFilterEntryMock()); - store.dispatch({ type: 'eventFiltersInitFromId', payload: { id: 'id' } }); - await spyMiddleware.waitForAction('eventFiltersInitForm'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - entry: createdEventFilterEntryMock(), - }, - }); - }); - - it('does throw error when getting by id', async () => { - service.getOne.mockRejectedValue({ - body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' }, - }); - store.dispatch({ type: 'eventFiltersInitFromId', payload: { id: 'id' } }); - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'FailedResourceState', - lastLoadedState: undefined, - error: { - error: 'Internal Server Error', - message: 'error message', - statusCode: 500, - }, - }, - }, - }); - }); - }); - describe('submit update event filter', () => { - it('does not submit when entry is undefined', async () => { - store.dispatch({ type: 'eventFiltersUpdateStart' }); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { type: 'UninitialisedResourceState' }, - }, - }); - }); - - it('does submit when entry is not undefined', async () => { - service.updateOne.mockResolvedValue(createdEventFilterEntryMock()); - - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: createdEventFilterEntryMock() }, - }); - - store.dispatch({ type: 'eventFiltersUpdateStart' }); - - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'LoadedResourceState', - data: createdEventFilterEntryMock(), - }, - }, - }); - }); - - it('does throw error when creating', async () => { - service.updateOne.mockRejectedValue({ - body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' }, - }); - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - store.dispatch({ type: 'eventFiltersUpdateStart' }); - - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'FailedResourceState', - lastLoadedState: undefined, - error: { - error: 'Internal Server Error', - message: 'error message', - statusCode: 500, - }, - }, - }, - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts deleted file mode 100644 index a8bf725e61b2a..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts +++ /dev/null @@ -1,342 +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 { - CreateExceptionListItemSchema, - ExceptionListItemSchema, - UpdateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { transformNewItemOutput, transformOutput } from '@kbn/securitysolution-list-hooks'; -import { AppAction } from '../../../../common/store/actions'; -import { - ImmutableMiddleware, - ImmutableMiddlewareAPI, - ImmutableMiddlewareFactory, -} from '../../../../common/store'; - -import { EventFiltersHttpService } from '../service'; - -import { - getCurrentListPageDataState, - getCurrentLocation, - getListIsLoading, - getListPageDataExistsState, - getListPageIsActive, - listDataNeedsRefresh, - getFormEntry, - getSubmissionResource, - getNewComment, - isDeletionInProgress, - getItemToDelete, - getDeletionState, -} from './selector'; - -import { parseQueryFilterToKQL, parsePoliciesAndFilterToKql } from '../../../common/utils'; -import { SEARCHABLE_FIELDS } from '../constants'; -import { - EventFiltersListPageData, - EventFiltersListPageState, - EventFiltersService, - EventFiltersServiceGetListOptions, -} from '../types'; -import { - asStaleResourceState, - createFailedResourceState, - createLoadedResourceState, - createLoadingResourceState, - getLastLoadedResourceState, -} from '../../../state'; -import { ServerApiError } from '../../../../common/types'; - -const addNewComments = ( - entry: UpdateExceptionListItemSchema | CreateExceptionListItemSchema, - newComment: string -): UpdateExceptionListItemSchema | CreateExceptionListItemSchema => { - if (newComment) { - if (!entry.comments) entry.comments = []; - const trimmedComment = newComment.trim(); - if (trimmedComment) entry.comments.push({ comment: trimmedComment }); - } - return entry; -}; - -type MiddlewareActionHandler = ( - store: ImmutableMiddlewareAPI, - eventFiltersService: EventFiltersService -) => Promise; - -const eventFiltersCreate: MiddlewareActionHandler = async (store, eventFiltersService) => { - const submissionResourceState = store.getState().form.submissionResourceState; - try { - const formEntry = getFormEntry(store.getState()); - if (!formEntry) return; - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: createLoadingResourceState({ - type: 'UninitialisedResourceState', - }), - }); - - const sanitizedEntry = transformNewItemOutput(formEntry as CreateExceptionListItemSchema); - const updatedCommentsEntry = addNewComments( - sanitizedEntry, - getNewComment(store.getState()) - ) as CreateExceptionListItemSchema; - - const exception = await eventFiltersService.addEventFilters(updatedCommentsEntry); - - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadedResourceState', - data: exception, - }, - }); - store.dispatch({ - type: 'eventFiltersCreateSuccess', - }); - } catch (error) { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'FailedResourceState', - error: error.body || error, - lastLoadedState: getLastLoadedResourceState(submissionResourceState), - }, - }); - } -}; - -const eventFiltersUpdate = async ( - store: ImmutableMiddlewareAPI, - eventFiltersService: EventFiltersService -) => { - const submissionResourceState = getSubmissionResource(store.getState()); - try { - const formEntry = getFormEntry(store.getState()); - if (!formEntry) return; - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadingResourceState', - previousState: { type: 'UninitialisedResourceState' }, - }, - }); - - const sanitizedEntry: UpdateExceptionListItemSchema = transformOutput( - formEntry as UpdateExceptionListItemSchema - ); - const updatedCommentsEntry = addNewComments( - sanitizedEntry, - getNewComment(store.getState()) - ) as UpdateExceptionListItemSchema; - - const exception = await eventFiltersService.updateOne(updatedCommentsEntry); - store.dispatch({ - type: 'eventFiltersUpdateSuccess', - }); - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: createLoadedResourceState(exception), - }); - } catch (error) { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: createFailedResourceState( - error.body ?? error, - getLastLoadedResourceState(submissionResourceState) - ), - }); - } -}; - -const eventFiltersLoadById = async ( - store: ImmutableMiddlewareAPI, - eventFiltersService: EventFiltersService, - id: string -) => { - const submissionResourceState = getSubmissionResource(store.getState()); - try { - const entry = await eventFiltersService.getOne(id); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - } catch (error) { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'FailedResourceState', - error: error.body || error, - lastLoadedState: getLastLoadedResourceState(submissionResourceState), - }, - }); - } -}; - -const checkIfEventFilterDataExist: MiddlewareActionHandler = async ( - { dispatch, getState }, - eventFiltersService: EventFiltersService -) => { - dispatch({ - type: 'eventFiltersListPageDataExistsChanged', - payload: createLoadingResourceState( - asStaleResourceState(getListPageDataExistsState(getState())) - ), - }); - - try { - const anythingInListResults = await eventFiltersService.getList({ perPage: 1, page: 1 }); - - dispatch({ - type: 'eventFiltersListPageDataExistsChanged', - payload: createLoadedResourceState(Boolean(anythingInListResults.total)), - }); - } catch (error) { - dispatch({ - type: 'eventFiltersListPageDataExistsChanged', - payload: createFailedResourceState(error.body ?? error), - }); - } -}; - -const refreshListDataIfNeeded: MiddlewareActionHandler = async (store, eventFiltersService) => { - const { dispatch, getState } = store; - const state = getState(); - const isLoading = getListIsLoading(state); - - if (!isLoading && listDataNeedsRefresh(state)) { - dispatch({ - type: 'eventFiltersListPageDataChanged', - payload: { - type: 'LoadingResourceState', - previousState: asStaleResourceState(getCurrentListPageDataState(state)), - }, - }); - - const { - page_size: pageSize, - page_index: pageIndex, - filter, - included_policies: includedPolicies, - } = getCurrentLocation(state); - - const kuery = parseQueryFilterToKQL(filter, SEARCHABLE_FIELDS) || undefined; - - const query: EventFiltersServiceGetListOptions = { - page: pageIndex + 1, - perPage: pageSize, - sortField: 'created_at', - sortOrder: 'desc', - filter: parsePoliciesAndFilterToKql({ - kuery, - policies: includedPolicies ? includedPolicies.split(',') : [], - }), - }; - - try { - const results = await eventFiltersService.getList(query); - - dispatch({ - type: 'eventFiltersListPageDataChanged', - payload: createLoadedResourceState({ - query: { ...query, filter }, - content: results, - }), - }); - - // If no results were returned, then just check to make sure data actually exists for - // event filters. This is used to drive the UI between showing "empty state" and "no items found" - // messages to the user - if (results.total === 0) { - await checkIfEventFilterDataExist(store, eventFiltersService); - } else { - dispatch({ - type: 'eventFiltersListPageDataExistsChanged', - payload: { - type: 'LoadedResourceState', - data: Boolean(results.total), - }, - }); - } - } catch (error) { - dispatch({ - type: 'eventFiltersListPageDataChanged', - payload: createFailedResourceState(error.body ?? error), - }); - } - } -}; - -const eventFilterDeleteEntry: MiddlewareActionHandler = async ( - { getState, dispatch }, - eventFiltersService -) => { - const state = getState(); - - if (isDeletionInProgress(state)) { - return; - } - - const itemId = getItemToDelete(state)?.id; - - if (!itemId) { - return; - } - - dispatch({ - type: 'eventFilterDeleteStatusChanged', - payload: createLoadingResourceState(asStaleResourceState(getDeletionState(state).status)), - }); - - try { - const response = await eventFiltersService.deleteOne(itemId); - dispatch({ - type: 'eventFilterDeleteStatusChanged', - payload: createLoadedResourceState(response), - }); - } catch (e) { - dispatch({ - type: 'eventFilterDeleteStatusChanged', - payload: createFailedResourceState(e.body ?? e), - }); - } -}; - -export const createEventFiltersPageMiddleware = ( - eventFiltersService: EventFiltersService -): ImmutableMiddleware => { - return (store) => (next) => async (action) => { - next(action); - - if (action.type === 'eventFiltersCreateStart') { - await eventFiltersCreate(store, eventFiltersService); - } else if (action.type === 'eventFiltersInitFromId') { - await eventFiltersLoadById(store, eventFiltersService, action.payload.id); - } else if (action.type === 'eventFiltersUpdateStart') { - await eventFiltersUpdate(store, eventFiltersService); - } - - // Middleware that only applies to the List Page for Event Filters - if (getListPageIsActive(store.getState())) { - if ( - action.type === 'userChangedUrl' || - action.type === 'eventFiltersCreateSuccess' || - action.type === 'eventFiltersUpdateSuccess' || - action.type === 'eventFilterDeleteStatusChanged' - ) { - refreshListDataIfNeeded(store, eventFiltersService); - } else if (action.type === 'eventFilterDeleteSubmit') { - eventFilterDeleteEntry(store, eventFiltersService); - } - } - }; -}; - -export const eventFiltersPageMiddlewareFactory: ImmutableMiddlewareFactory< - EventFiltersListPageState -> = (coreStart) => createEventFiltersPageMiddleware(new EventFiltersHttpService(coreStart.http)); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts deleted file mode 100644 index 0deb7cb51c850..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts +++ /dev/null @@ -1,221 +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 { initialEventFiltersPageState } from './builders'; -import { eventFiltersPageReducer } from './reducer'; -import { getInitialExceptionFromEvent } from './utils'; -import { createdEventFilterEntryMock, ecsEventMock } from '../test_utils'; -import { UserChangedUrl } from '../../../../common/store/routing/action'; -import { getListPageIsActive } from './selector'; -import { EventFiltersListPageState } from '../types'; - -describe('event filters reducer', () => { - let initialState: EventFiltersListPageState; - - beforeEach(() => { - initialState = initialEventFiltersPageState(); - }); - - describe('EventFiltersForm', () => { - it('sets the initial form values', () => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - const result = eventFiltersPageReducer(initialState, { - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - expect(result).toStrictEqual({ - ...initialState, - form: { - ...initialState.form, - entry, - hasNameError: !entry.name, - submissionResourceState: { - type: 'UninitialisedResourceState', - }, - }, - }); - }); - - it('change form values', () => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - const nameChanged = 'name changed'; - const newComment = 'new comment'; - const result = eventFiltersPageReducer(initialState, { - type: 'eventFiltersChangeForm', - payload: { entry: { ...entry, name: nameChanged }, newComment }, - }); - - expect(result).toStrictEqual({ - ...initialState, - form: { - ...initialState.form, - entry: { - ...entry, - name: nameChanged, - }, - newComment, - hasNameError: false, - submissionResourceState: { - type: 'UninitialisedResourceState', - }, - }, - }); - }); - - it('change form values without entry', () => { - const newComment = 'new comment'; - const result = eventFiltersPageReducer(initialState, { - type: 'eventFiltersChangeForm', - payload: { newComment }, - }); - - expect(result).toStrictEqual({ - ...initialState, - form: { - ...initialState.form, - newComment, - submissionResourceState: { - type: 'UninitialisedResourceState', - }, - }, - }); - }); - - it('change form status', () => { - const result = eventFiltersPageReducer(initialState, { - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadedResourceState', - data: createdEventFilterEntryMock(), - }, - }); - - expect(result).toStrictEqual({ - ...initialState, - form: { - ...initialState.form, - submissionResourceState: { - type: 'LoadedResourceState', - data: createdEventFilterEntryMock(), - }, - }, - }); - }); - - it('clean form after change form status', () => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - const nameChanged = 'name changed'; - const newComment = 'new comment'; - const result = eventFiltersPageReducer(initialState, { - type: 'eventFiltersChangeForm', - payload: { entry: { ...entry, name: nameChanged }, newComment }, - }); - const cleanState = eventFiltersPageReducer(result, { - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - expect(cleanState).toStrictEqual({ - ...initialState, - form: { ...initialState.form, entry, hasNameError: true, newComment: '' }, - }); - }); - - it('create is success and force list refresh', () => { - const initialStateWithListPageActive = { - ...initialState, - listPage: { ...initialState.listPage, active: true }, - }; - const result = eventFiltersPageReducer(initialStateWithListPageActive, { - type: 'eventFiltersCreateSuccess', - }); - - expect(result).toStrictEqual({ - ...initialStateWithListPageActive, - listPage: { - ...initialStateWithListPageActive.listPage, - forceRefresh: true, - }, - }); - }); - }); - describe('UserChangedUrl', () => { - const userChangedUrlAction = ( - search: string = '', - pathname = '/administration/event_filters' - ): UserChangedUrl => ({ - type: 'userChangedUrl', - payload: { search, pathname, hash: '' }, - }); - - describe('When url is the Event List page', () => { - it('should mark page active when on the list url', () => { - const result = eventFiltersPageReducer(initialState, userChangedUrlAction()); - expect(getListPageIsActive(result)).toBe(true); - }); - - it('should mark page not active when not on the list url', () => { - const result = eventFiltersPageReducer( - initialState, - userChangedUrlAction('', '/some-other-page') - ); - expect(getListPageIsActive(result)).toBe(false); - }); - }); - - describe('When `show=create`', () => { - it('receives a url change with show=create', () => { - const result = eventFiltersPageReducer(initialState, userChangedUrlAction('?show=create')); - - expect(result).toStrictEqual({ - ...initialState, - location: { - ...initialState.location, - id: undefined, - show: 'create', - }, - listPage: { - ...initialState.listPage, - active: true, - }, - }); - }); - }); - }); - - describe('ForceRefresh', () => { - it('sets the force refresh state to true', () => { - const result = eventFiltersPageReducer( - { - ...initialState, - listPage: { ...initialState.listPage, forceRefresh: false }, - }, - { type: 'eventFiltersForceRefresh', payload: { forceRefresh: true } } - ); - - expect(result).toStrictEqual({ - ...initialState, - listPage: { ...initialState.listPage, forceRefresh: true }, - }); - }); - it('sets the force refresh state to false', () => { - const result = eventFiltersPageReducer( - { - ...initialState, - listPage: { ...initialState.listPage, forceRefresh: true }, - }, - { type: 'eventFiltersForceRefresh', payload: { forceRefresh: false } } - ); - - expect(result).toStrictEqual({ - ...initialState, - listPage: { ...initialState.listPage, forceRefresh: false }, - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts deleted file mode 100644 index 95b0078f80f8b..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts +++ /dev/null @@ -1,271 +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. - */ - -// eslint-disable-next-line import/no-nodejs-modules -import { parse } from 'querystring'; -import { matchPath } from 'react-router-dom'; -import { ImmutableReducer } from '../../../../common/store'; -import { AppAction } from '../../../../common/store/actions'; -import { AppLocation, Immutable } from '../../../../../common/endpoint/types'; -import { UserChangedUrl } from '../../../../common/store/routing/action'; -import { MANAGEMENT_ROUTING_EVENT_FILTERS_PATH } from '../../../common/constants'; -import { extractEventFiltersPageLocation } from '../../../common/routing'; -import { - isLoadedResourceState, - isUninitialisedResourceState, -} from '../../../state/async_resource_state'; - -import { - EventFiltersInitForm, - EventFiltersChangeForm, - EventFiltersFormStateChanged, - EventFiltersCreateSuccess, - EventFiltersUpdateSuccess, - EventFiltersListPageDataChanged, - EventFiltersListPageDataExistsChanged, - EventFilterForDeletion, - EventFilterDeletionReset, - EventFilterDeleteStatusChanged, - EventFiltersForceRefresh, -} from './action'; - -import { initialEventFiltersPageState } from './builders'; -import { getListPageIsActive } from './selector'; -import { EventFiltersListPageState } from '../types'; - -type StateReducer = ImmutableReducer; -type CaseReducer = ( - state: Immutable, - action: Immutable -) => Immutable; - -const isEventFiltersPageLocation = (location: Immutable) => { - return ( - matchPath(location.pathname ?? '', { - path: MANAGEMENT_ROUTING_EVENT_FILTERS_PATH, - exact: true, - }) !== null - ); -}; - -const handleEventFiltersListPageDataChanges: CaseReducer = ( - state, - action -) => { - return { - ...state, - listPage: { - ...state.listPage, - forceRefresh: false, - data: action.payload, - }, - }; -}; - -const handleEventFiltersListPageDataExistChanges: CaseReducer< - EventFiltersListPageDataExistsChanged -> = (state, action) => { - return { - ...state, - listPage: { - ...state.listPage, - dataExist: action.payload, - }, - }; -}; - -const eventFiltersInitForm: CaseReducer = (state, action) => { - return { - ...state, - form: { - ...state.form, - entry: action.payload.entry, - hasNameError: !action.payload.entry.name, - hasOSError: !action.payload.entry.os_types?.length, - newComment: '', - submissionResourceState: { - type: 'UninitialisedResourceState', - }, - }, - }; -}; - -const eventFiltersChangeForm: CaseReducer = (state, action) => { - return { - ...state, - form: { - ...state.form, - entry: action.payload.entry !== undefined ? action.payload.entry : state.form.entry, - hasItemsError: - action.payload.hasItemsError !== undefined - ? action.payload.hasItemsError - : state.form.hasItemsError, - hasNameError: - action.payload.hasNameError !== undefined - ? action.payload.hasNameError - : state.form.hasNameError, - hasOSError: - action.payload.hasOSError !== undefined ? action.payload.hasOSError : state.form.hasOSError, - newComment: - action.payload.newComment !== undefined ? action.payload.newComment : state.form.newComment, - }, - }; -}; - -const eventFiltersFormStateChanged: CaseReducer = (state, action) => { - return { - ...state, - form: { - ...state.form, - entry: isUninitialisedResourceState(action.payload) ? undefined : state.form.entry, - newComment: isUninitialisedResourceState(action.payload) ? '' : state.form.newComment, - submissionResourceState: action.payload, - }, - }; -}; - -const eventFiltersCreateSuccess: CaseReducer = (state, action) => { - return { - ...state, - // If we are on the List page, then force a refresh of data - listPage: getListPageIsActive(state) - ? { - ...state.listPage, - forceRefresh: true, - } - : state.listPage, - }; -}; - -const eventFiltersUpdateSuccess: CaseReducer = (state, action) => { - return { - ...state, - // If we are on the List page, then force a refresh of data - listPage: getListPageIsActive(state) - ? { - ...state.listPage, - forceRefresh: true, - } - : state.listPage, - }; -}; - -const userChangedUrl: CaseReducer = (state, action) => { - if (isEventFiltersPageLocation(action.payload)) { - const location = extractEventFiltersPageLocation(parse(action.payload.search.slice(1))); - return { - ...state, - location, - listPage: { - ...state.listPage, - active: true, - }, - }; - } else { - // Reset the list page state if needed - if (state.listPage.active) { - const { listPage } = initialEventFiltersPageState(); - - return { - ...state, - listPage, - }; - } - - return state; - } -}; - -const handleEventFilterForDeletion: CaseReducer = (state, action) => { - return { - ...state, - listPage: { - ...state.listPage, - deletion: { - ...state.listPage.deletion, - item: action.payload, - }, - }, - }; -}; - -const handleEventFilterDeletionReset: CaseReducer = (state) => { - return { - ...state, - listPage: { - ...state.listPage, - deletion: initialEventFiltersPageState().listPage.deletion, - }, - }; -}; - -const handleEventFilterDeleteStatusChanges: CaseReducer = ( - state, - action -) => { - return { - ...state, - listPage: { - ...state.listPage, - forceRefresh: isLoadedResourceState(action.payload) ? true : state.listPage.forceRefresh, - deletion: { - ...state.listPage.deletion, - status: action.payload, - }, - }, - }; -}; - -const handleEventFilterForceRefresh: CaseReducer = (state, action) => { - return { - ...state, - listPage: { - ...state.listPage, - forceRefresh: action.payload.forceRefresh, - }, - }; -}; - -export const eventFiltersPageReducer: StateReducer = ( - state = initialEventFiltersPageState(), - action -) => { - switch (action.type) { - case 'eventFiltersInitForm': - return eventFiltersInitForm(state, action); - case 'eventFiltersChangeForm': - return eventFiltersChangeForm(state, action); - case 'eventFiltersFormStateChanged': - return eventFiltersFormStateChanged(state, action); - case 'eventFiltersCreateSuccess': - return eventFiltersCreateSuccess(state, action); - case 'eventFiltersUpdateSuccess': - return eventFiltersUpdateSuccess(state, action); - case 'userChangedUrl': - return userChangedUrl(state, action); - case 'eventFiltersForceRefresh': - return handleEventFilterForceRefresh(state, action); - } - - // actions only handled if we're on the List Page - if (getListPageIsActive(state)) { - switch (action.type) { - case 'eventFiltersListPageDataChanged': - return handleEventFiltersListPageDataChanges(state, action); - case 'eventFiltersListPageDataExistsChanged': - return handleEventFiltersListPageDataExistChanges(state, action); - case 'eventFilterForDeletion': - return handleEventFilterForDeletion(state, action); - case 'eventFilterDeletionReset': - return handleEventFilterDeletionReset(state, action); - case 'eventFilterDeleteStatusChanged': - return handleEventFilterDeleteStatusChanges(state, action); - } - } - - return state; -}; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts deleted file mode 100644 index 9e5eb5c531b6e..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts +++ /dev/null @@ -1,224 +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 { createSelector } from 'reselect'; -import { Pagination } from '@elastic/eui'; - -import type { - ExceptionListItemSchema, - FoundExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { EventFiltersListPageState, EventFiltersServiceGetListOptions } from '../types'; - -import { ServerApiError } from '../../../../common/types'; -import { - isLoadingResourceState, - isLoadedResourceState, - isFailedResourceState, - isUninitialisedResourceState, - getLastLoadedResourceState, -} from '../../../state/async_resource_state'; -import { - MANAGEMENT_DEFAULT_PAGE_SIZE, - MANAGEMENT_PAGE_SIZE_OPTIONS, -} from '../../../common/constants'; -import { Immutable } from '../../../../../common/endpoint/types'; - -type StoreState = Immutable; -type EventFiltersSelector = (state: StoreState) => T; - -export const getCurrentListPageState: EventFiltersSelector = (state) => { - return state.listPage; -}; - -export const getListPageIsActive: EventFiltersSelector = createSelector( - getCurrentListPageState, - (listPage) => listPage.active -); - -export const getCurrentListPageDataState: EventFiltersSelector = ( - state -) => state.listPage.data; - -/** - * Will return the API response with event filters. If the current state is attempting to load a new - * page of content, then return the previous API response if we have one - */ -export const getListApiSuccessResponse: EventFiltersSelector< - Immutable | undefined -> = createSelector(getCurrentListPageDataState, (listPageData) => { - return getLastLoadedResourceState(listPageData)?.data.content; -}); - -export const getListItems: EventFiltersSelector> = - createSelector(getListApiSuccessResponse, (apiResponseData) => { - return apiResponseData?.data || []; - }); - -export const getTotalCountListItems: EventFiltersSelector> = createSelector( - getListApiSuccessResponse, - (apiResponseData) => { - return apiResponseData?.total || 0; - } -); - -/** - * Will return the query that was used with the currently displayed list of content. If a new page - * of content is being loaded, this selector will then attempt to use the previousState to return - * the query used. - */ -export const getCurrentListItemsQuery: EventFiltersSelector = - createSelector(getCurrentListPageDataState, (pageDataState) => { - return getLastLoadedResourceState(pageDataState)?.data.query ?? {}; - }); - -export const getListPagination: EventFiltersSelector = createSelector( - getListApiSuccessResponse, - // memoized via `reselect` until the API response changes - (response) => { - return { - totalItemCount: response?.total ?? 0, - pageSize: response?.per_page ?? MANAGEMENT_DEFAULT_PAGE_SIZE, - pageSizeOptions: [...MANAGEMENT_PAGE_SIZE_OPTIONS], - pageIndex: (response?.page ?? 1) - 1, - }; - } -); - -export const getListFetchError: EventFiltersSelector | undefined> = - createSelector(getCurrentListPageDataState, (listPageDataState) => { - return (isFailedResourceState(listPageDataState) && listPageDataState.error) || undefined; - }); - -export const getListPageDataExistsState: EventFiltersSelector< - StoreState['listPage']['dataExist'] -> = ({ listPage: { dataExist } }) => dataExist; - -export const getListIsLoading: EventFiltersSelector = createSelector( - getCurrentListPageDataState, - getListPageDataExistsState, - (listDataState, dataExists) => - isLoadingResourceState(listDataState) || isLoadingResourceState(dataExists) -); - -export const getListPageDoesDataExist: EventFiltersSelector = createSelector( - getListPageDataExistsState, - (dataExistsState) => { - return !!getLastLoadedResourceState(dataExistsState)?.data; - } -); - -export const getFormEntryState: EventFiltersSelector = (state) => { - return state.form.entry; -}; -// Needed for form component as we modify the existing entry on exceptuionBuilder component -export const getFormEntryStateMutable = ( - state: EventFiltersListPageState -): EventFiltersListPageState['form']['entry'] => { - return state.form.entry; -}; - -export const getFormEntry = createSelector(getFormEntryState, (entry) => entry); - -export const getNewCommentState: EventFiltersSelector = ( - state -) => { - return state.form.newComment; -}; - -export const getNewComment = createSelector(getNewCommentState, (newComment) => newComment); - -export const getHasNameError = (state: EventFiltersListPageState): boolean => { - return state.form.hasNameError; -}; - -export const getFormHasError = (state: EventFiltersListPageState): boolean => { - return state.form.hasItemsError || state.form.hasNameError || state.form.hasOSError; -}; - -export const isCreationInProgress = (state: EventFiltersListPageState): boolean => { - return isLoadingResourceState(state.form.submissionResourceState); -}; - -export const isCreationSuccessful = (state: EventFiltersListPageState): boolean => { - return isLoadedResourceState(state.form.submissionResourceState); -}; - -export const isUninitialisedForm = (state: EventFiltersListPageState): boolean => { - return isUninitialisedResourceState(state.form.submissionResourceState); -}; - -export const getActionError = (state: EventFiltersListPageState): ServerApiError | undefined => { - const submissionResourceState = state.form.submissionResourceState; - - return isFailedResourceState(submissionResourceState) ? submissionResourceState.error : undefined; -}; - -export const getSubmissionResourceState: EventFiltersSelector< - StoreState['form']['submissionResourceState'] -> = (state) => { - return state.form.submissionResourceState; -}; - -export const getSubmissionResource = createSelector( - getSubmissionResourceState, - (submissionResourceState) => submissionResourceState -); - -export const getCurrentLocation: EventFiltersSelector = (state) => - state.location; - -/** Compares the URL param values to the values used in the last data query */ -export const listDataNeedsRefresh: EventFiltersSelector = createSelector( - getCurrentLocation, - getCurrentListItemsQuery, - (state) => state.listPage.forceRefresh, - (location, currentQuery, forceRefresh) => { - return ( - forceRefresh || - location.page_index + 1 !== currentQuery.page || - location.page_size !== currentQuery.perPage - ); - } -); - -export const getDeletionState = createSelector( - getCurrentListPageState, - (listState) => listState.deletion -); - -export const showDeleteModal: EventFiltersSelector = createSelector( - getDeletionState, - ({ item }) => { - return Boolean(item); - } -); - -export const getItemToDelete: EventFiltersSelector = - createSelector(getDeletionState, ({ item }) => item); - -export const isDeletionInProgress: EventFiltersSelector = createSelector( - getDeletionState, - ({ status }) => { - return isLoadingResourceState(status); - } -); - -export const wasDeletionSuccessful: EventFiltersSelector = createSelector( - getDeletionState, - ({ status }) => { - return isLoadedResourceState(status); - } -); - -export const getDeleteError: EventFiltersSelector = createSelector( - getDeletionState, - ({ status }) => { - if (isFailedResourceState(status)) { - return status.error; - } - } -); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts deleted file mode 100644 index fa3a519bc1908..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts +++ /dev/null @@ -1,391 +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 { initialEventFiltersPageState } from './builders'; -import { - getFormEntry, - getFormHasError, - getCurrentLocation, - getNewComment, - getHasNameError, - getCurrentListPageState, - getListPageIsActive, - getCurrentListPageDataState, - getListApiSuccessResponse, - getListItems, - getTotalCountListItems, - getCurrentListItemsQuery, - getListPagination, - getListFetchError, - getListIsLoading, - getListPageDoesDataExist, - listDataNeedsRefresh, -} from './selector'; -import { ecsEventMock } from '../test_utils'; -import { getInitialExceptionFromEvent } from './utils'; -import { EventFiltersListPageState, EventFiltersPageLocation } from '../types'; -import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../../common/constants'; -import { getFoundExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/found_exception_list_item_schema.mock'; -import { - asStaleResourceState, - createFailedResourceState, - createLoadedResourceState, - createLoadingResourceState, - createUninitialisedResourceState, - getLastLoadedResourceState, -} from '../../../state'; - -describe('event filters selectors', () => { - let initialState: EventFiltersListPageState; - - // When `setToLoadingState()` is called, this variable will hold the prevousState in order to - // avoid ts-ignores due to know issues (#830) around the LoadingResourceState - let previousStateWhileLoading: EventFiltersListPageState['listPage']['data'] | undefined; - - const setToLoadedState = () => { - initialState.listPage.data = createLoadedResourceState({ - query: { page: 2, perPage: 10, filter: '' }, - content: getFoundExceptionListItemSchemaMock(), - }); - }; - - const setToLoadingState = ( - previousState: EventFiltersListPageState['listPage']['data'] = createLoadedResourceState({ - query: { page: 5, perPage: 50, filter: '' }, - content: getFoundExceptionListItemSchemaMock(), - }) - ) => { - previousStateWhileLoading = previousState; - - initialState.listPage.data = createLoadingResourceState(asStaleResourceState(previousState)); - }; - - beforeEach(() => { - initialState = initialEventFiltersPageState(); - }); - - describe('getCurrentListPageState()', () => { - it('should retrieve list page state', () => { - expect(getCurrentListPageState(initialState)).toEqual(initialState.listPage); - }); - }); - - describe('getListPageIsActive()', () => { - it('should return active state', () => { - expect(getListPageIsActive(initialState)).toBe(false); - }); - }); - - describe('getCurrentListPageDataState()', () => { - it('should return list data state', () => { - expect(getCurrentListPageDataState(initialState)).toEqual(initialState.listPage.data); - }); - }); - - describe('getListApiSuccessResponse()', () => { - it('should return api response', () => { - setToLoadedState(); - expect(getListApiSuccessResponse(initialState)).toEqual( - getLastLoadedResourceState(initialState.listPage.data)?.data.content - ); - }); - - it('should return undefined if not available', () => { - setToLoadingState(createUninitialisedResourceState()); - expect(getListApiSuccessResponse(initialState)).toBeUndefined(); - }); - - it('should return previous success response if currently loading', () => { - setToLoadingState(); - expect(getListApiSuccessResponse(initialState)).toEqual( - getLastLoadedResourceState(previousStateWhileLoading!)?.data.content - ); - }); - }); - - describe('getListItems()', () => { - it('should return the list items from api response', () => { - setToLoadedState(); - expect(getListItems(initialState)).toEqual( - getLastLoadedResourceState(initialState.listPage.data)?.data.content.data - ); - }); - - it('should return empty array if no api response', () => { - expect(getListItems(initialState)).toEqual([]); - }); - }); - - describe('getTotalCountListItems()', () => { - it('should return the list items from api response', () => { - setToLoadedState(); - expect(getTotalCountListItems(initialState)).toEqual( - getLastLoadedResourceState(initialState.listPage.data)?.data.content.total - ); - }); - - it('should return empty array if no api response', () => { - expect(getTotalCountListItems(initialState)).toEqual(0); - }); - }); - - describe('getCurrentListItemsQuery()', () => { - it('should return empty object if Uninitialized', () => { - expect(getCurrentListItemsQuery(initialState)).toEqual({}); - }); - - it('should return query from current loaded state', () => { - setToLoadedState(); - expect(getCurrentListItemsQuery(initialState)).toEqual({ page: 2, perPage: 10, filter: '' }); - }); - - it('should return query from previous state while Loading new page', () => { - setToLoadingState(); - expect(getCurrentListItemsQuery(initialState)).toEqual({ page: 5, perPage: 50, filter: '' }); - }); - }); - - describe('getListPagination()', () => { - it('should return pagination defaults if no API response is available', () => { - expect(getListPagination(initialState)).toEqual({ - totalItemCount: 0, - pageSize: 10, - pageSizeOptions: [10, 20, 50], - pageIndex: 0, - }); - }); - - it('should return pagination based on API response', () => { - setToLoadedState(); - expect(getListPagination(initialState)).toEqual({ - totalItemCount: 1, - pageSize: 1, - pageSizeOptions: [10, 20, 50], - pageIndex: 0, - }); - }); - }); - - describe('getListFetchError()', () => { - it('should return undefined if no error exists', () => { - expect(getListFetchError(initialState)).toBeUndefined(); - }); - - it('should return the API error', () => { - const error = { - statusCode: 500, - error: 'Internal Server Error', - message: 'Something is not right', - }; - - initialState.listPage.data = createFailedResourceState(error); - expect(getListFetchError(initialState)).toBe(error); - }); - }); - - describe('getListIsLoading()', () => { - it('should return false if not in a Loading state', () => { - expect(getListIsLoading(initialState)).toBe(false); - }); - - it('should return true if in a Loading state', () => { - setToLoadingState(); - expect(getListIsLoading(initialState)).toBe(true); - }); - }); - - describe('getListPageDoesDataExist()', () => { - it('should return false (default) until we get a Loaded Resource state', () => { - expect(getListPageDoesDataExist(initialState)).toBe(false); - - // Set DataExists to Loading - initialState.listPage.dataExist = createLoadingResourceState( - asStaleResourceState(initialState.listPage.dataExist) - ); - expect(getListPageDoesDataExist(initialState)).toBe(false); - - // Set DataExists to Failure - initialState.listPage.dataExist = createFailedResourceState({ - statusCode: 500, - error: 'Internal Server Error', - message: 'Something is not right', - }); - expect(getListPageDoesDataExist(initialState)).toBe(false); - }); - - it('should return false if no data exists', () => { - initialState.listPage.dataExist = createLoadedResourceState(false); - expect(getListPageDoesDataExist(initialState)).toBe(false); - }); - }); - - describe('listDataNeedsRefresh()', () => { - beforeEach(() => { - setToLoadedState(); - - initialState.location = { - page_index: 1, - page_size: 10, - filter: '', - id: '', - show: undefined, - included_policies: '', - }; - }); - - it('should return false if location url params match those that were used in api call', () => { - expect(listDataNeedsRefresh(initialState)).toBe(false); - }); - - it('should return true if `forceRefresh` is set', () => { - initialState.listPage.forceRefresh = true; - expect(listDataNeedsRefresh(initialState)).toBe(true); - }); - - it('should should return true if any of the url params differ from last api call', () => { - initialState.location.page_index = 10; - expect(listDataNeedsRefresh(initialState)).toBe(true); - }); - }); - - describe('getFormEntry()', () => { - it('returns undefined when there is no entry', () => { - expect(getFormEntry(initialState)).toBe(undefined); - }); - it('returns entry when there is an entry on form', () => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - const state = { - ...initialState, - form: { - ...initialState.form, - entry, - }, - }; - expect(getFormEntry(state)).toBe(entry); - }); - }); - describe('getHasNameError()', () => { - it('returns false when there is no entry', () => { - expect(getHasNameError(initialState)).toBeFalsy(); - }); - it('returns true when entry with name error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasNameError: true, - }, - }; - expect(getHasNameError(state)).toBeTruthy(); - }); - it('returns false when entry with no name error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasNameError: false, - }, - }; - expect(getHasNameError(state)).toBeFalsy(); - }); - }); - describe('getFormHasError()', () => { - it('returns false when there is no entry', () => { - expect(getFormHasError(initialState)).toBeFalsy(); - }); - it('returns true when entry with name error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasNameError: true, - }, - }; - expect(getFormHasError(state)).toBeTruthy(); - }); - it('returns true when entry with item error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasItemsError: true, - }, - }; - expect(getFormHasError(state)).toBeTruthy(); - }); - it('returns true when entry with os error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasOSError: true, - }, - }; - expect(getFormHasError(state)).toBeTruthy(); - }); - it('returns true when entry with item error, name error and os error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasItemsError: true, - hasNameError: true, - hasOSError: true, - }, - }; - expect(getFormHasError(state)).toBeTruthy(); - }); - - it('returns false when entry without errors', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasItemsError: false, - hasNameError: false, - hasOSError: false, - }, - }; - expect(getFormHasError(state)).toBeFalsy(); - }); - }); - describe('getCurrentLocation()', () => { - it('returns current locations', () => { - const expectedLocation: EventFiltersPageLocation = { - show: 'create', - page_index: MANAGEMENT_DEFAULT_PAGE, - page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, - filter: 'filter', - included_policies: '1', - }; - const state = { - ...initialState, - location: expectedLocation, - }; - expect(getCurrentLocation(state)).toBe(expectedLocation); - }); - }); - describe('getNewComment()', () => { - it('returns new comment', () => { - const newComment = 'this is a new comment'; - const state = { - ...initialState, - form: { - ...initialState.form, - newComment, - }, - }; - expect(getNewComment(state)).toBe(newComment); - }); - it('returns empty comment', () => { - const state = { - ...initialState, - }; - expect(getNewComment(state)).toBe(''); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts index 398b3d9fa6d37..6edff2d89c416 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { combineReducers, createStore } from 'redux'; import type { FoundExceptionListItemSchema, ExceptionListItemSchema, @@ -17,27 +16,11 @@ import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas import { getSummaryExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_summary_schema.mock'; import { Ecs } from '../../../../../common/ecs'; -import { - MANAGEMENT_STORE_GLOBAL_NAMESPACE, - MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE, -} from '../../../common/constants'; - -import { eventFiltersPageReducer } from '../store/reducer'; import { httpHandlerMockFactory, ResponseProvidersInterface, } from '../../../../common/mock/endpoint/http_handler_mock_factory'; -export const createGlobalNoMiddlewareStore = () => { - return createStore( - combineReducers({ - [MANAGEMENT_STORE_GLOBAL_NAMESPACE]: combineReducers({ - [MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE]: eventFiltersPageReducer, - }), - }) - ); -}; - export const ecsEventMock = (): Ecs => ({ _id: 'unLfz3gB2mJZsMY3ytx3', timestamp: '2021-04-14T15:34:15.330Z', @@ -206,6 +189,8 @@ export const esResponseData = () => ({ ], }, }, + indexFields: [], + indicesExist: [], isPartial: false, isRunning: false, total: 1, diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/types.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/types.ts index f15bd47e0f3e7..b6a7c3b555daa 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/types.ts @@ -12,7 +12,6 @@ import type { UpdateExceptionListItemSchema, ExceptionListSummarySchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { AsyncResourceState } from '../../state/async_resource_state'; import { Immutable } from '../../../../common/endpoint/types'; export interface EventFiltersPageLocation { @@ -25,15 +24,6 @@ export interface EventFiltersPageLocation { included_policies: string; } -export interface EventFiltersForm { - entry: UpdateExceptionListItemSchema | CreateExceptionListItemSchema | undefined; - newComment: string; - hasNameError: boolean; - hasItemsError: boolean; - hasOSError: boolean; - submissionResourceState: AsyncResourceState; -} - export type EventFiltersServiceGetListOptions = Partial<{ page: number; perPage: number; @@ -60,22 +50,3 @@ export interface EventFiltersListPageData { /** The data retrieved from the API */ content: FoundExceptionListItemSchema; } - -export interface EventFiltersListPageState { - entries: ExceptionListItemSchema[]; - form: EventFiltersForm; - location: EventFiltersPageLocation; - /** State for the Event Filters List page */ - listPage: { - active: boolean; - forceRefresh: boolean; - data: AsyncResourceState; - /** tracks if the overall list (not filtered or with invalid page numbers) contains data */ - dataExist: AsyncResourceState; - /** state for deletion of items from the list */ - deletion: { - item: ExceptionListItemSchema | undefined; - status: AsyncResourceState; - }; - }; -} diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx deleted file mode 100644 index e48d4f8fb4d21..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx +++ /dev/null @@ -1,64 +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, { memo } from 'react'; -import styled, { css } from 'styled-components'; -import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { ManagementEmptyStateWrapper } from '../../../../../components/management_empty_state_wrapper'; - -const EmptyPrompt = styled(EuiEmptyPrompt)` - ${() => css` - max-width: 100%; - `} -`; - -export const EventFiltersListEmptyState = memo<{ - onAdd: () => void; - /** Should the Add button be disabled */ - isAddDisabled?: boolean; - backComponent?: React.ReactNode; -}>(({ onAdd, isAddDisabled = false, backComponent }) => { - return ( - - - - - } - body={ - - } - actions={[ - - - , - ...(backComponent ? [backComponent] : []), - ]} - /> - - ); -}); - -EventFiltersListEmptyState.displayName = 'EventFiltersListEmptyState'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.test.tsx deleted file mode 100644 index 9e245e5c8214e..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.test.tsx +++ /dev/null @@ -1,177 +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 { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../common/mock/endpoint'; -import { act } from '@testing-library/react'; -import { fireEvent } from '@testing-library/dom'; -import { EventFilterDeleteModal } from './event_filter_delete_modal'; -import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; -import { showDeleteModal } from '../../store/selector'; -import { isFailedResourceState, isLoadedResourceState } from '../../../../state'; -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; - -describe('When event filters delete modal is shown', () => { - let renderAndSetup: ( - customEventFilterProps?: Partial - ) => Promise>; - let renderResult: ReturnType; - let coreStart: AppContextTestRender['coreStart']; - let history: AppContextTestRender['history']; - let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; - let store: AppContextTestRender['store']; - - const getConfirmButton = () => - renderResult.baseElement.querySelector( - '[data-test-subj="eventFilterDeleteModalConfirmButton"]' - ) as HTMLButtonElement; - - const getCancelButton = () => - renderResult.baseElement.querySelector( - '[data-test-subj="eventFilterDeleteModalCancelButton"]' - ) as HTMLButtonElement; - - const getCurrentState = () => store.getState().management.eventFilters; - - beforeEach(() => { - const mockedContext = createAppRootMockRenderer(); - - ({ history, store, coreStart } = mockedContext); - renderAndSetup = async (customEventFilterProps) => { - renderResult = mockedContext.render(); - - await act(async () => { - history.push('/administration/event_filters'); - - await waitForAction('userChangedUrl'); - - mockedContext.store.dispatch({ - type: 'eventFilterForDeletion', - payload: getExceptionListItemSchemaMock({ - id: '123', - name: 'tic-tac-toe', - tags: [], - ...(customEventFilterProps ? customEventFilterProps : {}), - }), - }); - }); - - return renderResult; - }; - - waitForAction = mockedContext.middlewareSpy.waitForAction; - }); - - it("should display calllout when it's assigned to one policy", async () => { - await renderAndSetup({ tags: ['policy:1'] }); - expect(renderResult.getByTestId('eventFilterDeleteModalCalloutMessage').textContent).toMatch( - /Deleting this entry will remove it from 1 associated policy./ - ); - }); - - it("should display calllout when it's assigned to more than one policy", async () => { - await renderAndSetup({ tags: ['policy:1', 'policy:2', 'policy:3'] }); - expect(renderResult.getByTestId('eventFilterDeleteModalCalloutMessage').textContent).toMatch( - /Deleting this entry will remove it from 3 associated policies./ - ); - }); - - it("should display calllout when it's assigned globally", async () => { - await renderAndSetup({ tags: ['policy:all'] }); - expect(renderResult.getByTestId('eventFilterDeleteModalCalloutMessage').textContent).toMatch( - /Deleting this entry will remove it from all associated policies./ - ); - }); - - it("should display calllout when it's unassigned", async () => { - await renderAndSetup({ tags: [] }); - expect(renderResult.getByTestId('eventFilterDeleteModalCalloutMessage').textContent).toMatch( - /Deleting this entry will remove it from 0 associated policies./ - ); - }); - - it('should close dialog if cancel button is clicked', async () => { - await renderAndSetup(); - act(() => { - fireEvent.click(getCancelButton()); - }); - - expect(showDeleteModal(getCurrentState())).toBe(false); - }); - - it('should close dialog if the close X button is clicked', async () => { - await renderAndSetup(); - const dialogCloseButton = renderResult.baseElement.querySelector( - '[aria-label="Closes this modal window"]' - )!; - act(() => { - fireEvent.click(dialogCloseButton); - }); - - expect(showDeleteModal(getCurrentState())).toBe(false); - }); - - it('should disable action buttons when confirmed', async () => { - await renderAndSetup(); - act(() => { - fireEvent.click(getConfirmButton()); - }); - - expect(getCancelButton().disabled).toBe(true); - expect(getConfirmButton().disabled).toBe(true); - }); - - it('should set confirm button to loading', async () => { - await renderAndSetup(); - act(() => { - fireEvent.click(getConfirmButton()); - }); - - expect(getConfirmButton().querySelector('.euiLoadingSpinner')).not.toBeNull(); - }); - - it('should show success toast', async () => { - await renderAndSetup(); - const updateCompleted = waitForAction('eventFilterDeleteStatusChanged', { - validate(action) { - return isLoadedResourceState(action.payload); - }, - }); - - await act(async () => { - fireEvent.click(getConfirmButton()); - await updateCompleted; - }); - - expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( - '"tic-tac-toe" has been removed from the event filters list.' - ); - }); - - it('should show error toast if error is countered', async () => { - coreStart.http.delete.mockRejectedValue(new Error('oh oh')); - await renderAndSetup(); - const updateFailure = waitForAction('eventFilterDeleteStatusChanged', { - validate(action) { - return isFailedResourceState(action.payload); - }, - }); - - await act(async () => { - fireEvent.click(getConfirmButton()); - await updateFailure; - }); - - expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( - 'Unable to remove "tic-tac-toe" from the event filters list. Reason: oh oh' - ); - expect(showDeleteModal(getCurrentState())).toBe(true); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.tsx deleted file mode 100644 index 75e49bf270bab..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.tsx +++ /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 { - EuiButtonEmpty, - EuiCallOut, - EuiModal, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import React, { memo, useCallback, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { Dispatch } from 'redux'; -import { AutoFocusButton } from '../../../../../common/components/autofocus_button/autofocus_button'; -import { useToasts } from '../../../../../common/lib/kibana'; -import { AppAction } from '../../../../../common/store/actions'; -import { - getArtifactPoliciesIdByTag, - isGlobalPolicyEffected, -} from '../../../../components/effected_policy_select/utils'; -import { - getDeleteError, - getItemToDelete, - isDeletionInProgress, - wasDeletionSuccessful, -} from '../../store/selector'; -import { useEventFiltersSelector } from '../hooks'; - -export const EventFilterDeleteModal = memo<{}>(() => { - const dispatch = useDispatch>(); - const toasts = useToasts(); - - const isDeleting = useEventFiltersSelector(isDeletionInProgress); - const eventFilter = useEventFiltersSelector(getItemToDelete); - const wasDeleted = useEventFiltersSelector(wasDeletionSuccessful); - const deleteError = useEventFiltersSelector(getDeleteError); - - const onCancel = useCallback(() => { - dispatch({ type: 'eventFilterDeletionReset' }); - }, [dispatch]); - - const onConfirm = useCallback(() => { - dispatch({ type: 'eventFilterDeleteSubmit' }); - }, [dispatch]); - - // Show toast for success - useEffect(() => { - if (wasDeleted) { - toasts.addSuccess( - i18n.translate('xpack.securitySolution.eventFilters.deletionDialog.deleteSuccess', { - defaultMessage: '"{name}" has been removed from the event filters list.', - values: { name: eventFilter?.name }, - }) - ); - - dispatch({ type: 'eventFilterDeletionReset' }); - } - }, [dispatch, eventFilter?.name, toasts, wasDeleted]); - - // show toast for failures - useEffect(() => { - if (deleteError) { - toasts.addDanger( - i18n.translate('xpack.securitySolution.eventFilters.deletionDialog.deleteFailure', { - defaultMessage: - 'Unable to remove "{name}" from the event filters list. Reason: {message}', - values: { name: eventFilter?.name, message: deleteError.message }, - }) - ); - } - }, [deleteError, eventFilter?.name, toasts]); - - return ( - - - - {eventFilter?.name ?? ''} }} - /> - - - - - - -

- -

-
- -

- -

-
-
- - - - - - - - - - -
- ); -}); - -EventFilterDeleteModal.displayName = 'EventFilterDeleteModal'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.test.tsx new file mode 100644 index 0000000000000..21bd1fa655c2e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.test.tsx @@ -0,0 +1,222 @@ +/* + * Copyright 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 { EventFiltersFlyout, EventFiltersFlyoutProps } from './event_filters_flyout'; +import { act, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../common/mock/endpoint'; + +import { getInitialExceptionFromEvent } from '../utils'; +import { useCreateArtifact } from '../../../../hooks/artifacts/use_create_artifact'; +import { useGetEndpointSpecificPolicies } from '../../../../services/policies/hooks'; +import { ecsEventMock, esResponseData } from '../../test_utils'; + +import { useKibana, useToasts } from '../../../../../common/lib/kibana'; +import { of } from 'rxjs'; +import { ExceptionsListItemGenerator } from '../../../../../../common/endpoint/data_generators/exceptions_list_item_generator'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +// mocked modules +jest.mock('../../../../../common/lib/kibana'); +jest.mock('../../../../services/policies/hooks'); +jest.mock('../../../../services/policies/policies'); +jest.mock('../../../../hooks/artifacts/use_create_artifact'); +jest.mock('../utils'); + +let mockedContext: AppContextTestRender; +let render: ( + props?: Partial +) => ReturnType; +let renderResult: ReturnType; +let onCancelMock: jest.Mock; +const exceptionsGenerator = new ExceptionsListItemGenerator(); + +describe('Event filter flyout', () => { + beforeEach(async () => { + mockedContext = createAppRootMockRenderer(); + onCancelMock = jest.fn(); + + (useKibana as jest.Mock).mockReturnValue({ + services: { + docLinks: { + links: { + securitySolution: { + eventFilters: '', + }, + }, + }, + http: {}, + data: { + search: { + search: jest.fn().mockImplementation(() => of(esResponseData())), + }, + }, + notifications: {}, + unifiedSearch: {}, + }, + }); + (useToasts as jest.Mock).mockReturnValue({ + addSuccess: jest.fn(), + addError: jest.fn(), + addWarning: jest.fn(), + remove: jest.fn(), + }); + + (useCreateArtifact as jest.Mock).mockImplementation(() => { + return { + isLoading: false, + mutateAsync: jest.fn(), + }; + }); + + (useGetEndpointSpecificPolicies as jest.Mock).mockImplementation(() => { + return { isLoading: false, isRefetching: false }; + }); + + render = (props) => { + renderResult = mockedContext.render( + + ); + return renderResult; + }; + }); + + afterEach(() => { + cleanup(); + }); + + describe('On initial render', () => { + const exception = exceptionsGenerator.generateEventFilterForCreate({ + meta: {}, + entries: [ + { + field: 'event.category', + operator: 'included', + type: 'match', + value: 'a', + }, + { + field: 'process.executable', + operator: 'included', + type: 'match', + value: 'b', + }, + ], + name: '', + }); + beforeEach(() => { + (getInitialExceptionFromEvent as jest.Mock).mockImplementation(() => { + return exception; + }); + }); + it('should render correctly without data ', () => { + render(); + expect(renderResult.getAllByText('Add event filter')).not.toBeNull(); + expect(renderResult.getByText('Cancel')).not.toBeNull(); + }); + + it('should render correctly with data ', () => { + act(() => { + render({ data: ecsEventMock() }); + }); + expect(renderResult.getAllByText('Add endpoint event filter')).not.toBeNull(); + expect(renderResult.getByText('Cancel')).not.toBeNull(); + }); + + it('should start with "add event filter" button disabled', () => { + render(); + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton.hasAttribute('disabled')).toBeTruthy(); + }); + + it('should close when click on cancel button', () => { + render(); + const cancelButton = renderResult.getByTestId('cancelExceptionAddButton'); + expect(onCancelMock).toHaveBeenCalledTimes(0); + + userEvent.click(cancelButton); + expect(onCancelMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('When valid form state', () => { + const exceptionOptions: Partial = { + meta: {}, + entries: [ + { + field: 'event.category', + operator: 'included', + type: 'match', + value: 'a', + }, + { + field: 'process.executable', + operator: 'included', + type: 'match', + value: 'b', + }, + ], + name: 'some name', + }; + beforeEach(() => { + const exception = exceptionsGenerator.generateEventFilterForCreate(exceptionOptions); + (getInitialExceptionFromEvent as jest.Mock).mockImplementation(() => { + return exception; + }); + }); + it('should change to "add event filter" button enabled', () => { + render(); + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton.hasAttribute('disabled')).toBeFalsy(); + }); + it('should prevent close when submitting data', () => { + (useCreateArtifact as jest.Mock).mockImplementation(() => { + return { isLoading: true, mutateAsync: jest.fn() }; + }); + render(); + const cancelButton = renderResult.getByTestId('cancelExceptionAddButton'); + expect(onCancelMock).toHaveBeenCalledTimes(0); + + userEvent.click(cancelButton); + expect(onCancelMock).toHaveBeenCalledTimes(0); + }); + + it('should close when exception has been submitted successfully and close flyout', () => { + // mock submit query + (useCreateArtifact as jest.Mock).mockImplementation(() => { + return { + isLoading: false, + mutateAsync: ( + _: Parameters['mutateAsync']>[0], + options: Parameters['mutateAsync']>[1] + ) => { + if (!options) return; + if (!options.onSuccess) return; + const exception = exceptionsGenerator.generateEventFilter(exceptionOptions); + + options.onSuccess(exception, exception, () => null); + }, + }; + }); + + render(); + + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton.hasAttribute('disabled')).toBeFalsy(); + expect(onCancelMock).toHaveBeenCalledTimes(0); + userEvent.click(confirmButton); + + expect(useToasts().addSuccess).toHaveBeenCalled(); + expect(onCancelMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.tsx new file mode 100644 index 0000000000000..c370f548e6812 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.tsx @@ -0,0 +1,239 @@ +/* + * Copyright 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, { memo, useMemo, useEffect, useCallback, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiTextColor, +} from '@elastic/eui'; +import { lastValueFrom } from 'rxjs'; + +import { useWithArtifactSubmitData } from '../../../../components/artifact_list_page/hooks/use_with_artifact_submit_data'; +import { + ArtifactFormComponentOnChangeCallbackProps, + ArtifactFormComponentProps, +} from '../../../../components/artifact_list_page/types'; +import { EventFiltersForm } from './form'; + +import { getInitialExceptionFromEvent } from '../utils'; +import { Ecs } from '../../../../../../common/ecs'; +import { useHttp, useKibana, useToasts } from '../../../../../common/lib/kibana'; +import { useGetEndpointSpecificPolicies } from '../../../../services/policies/hooks'; +import { getLoadPoliciesError } from '../../../../common/translations'; + +import { EventFiltersApiClient } from '../../service/api_client'; +import { getCreationSuccessMessage, getCreationErrorMessage } from '../translations'; +export interface EventFiltersFlyoutProps { + data?: Ecs; + onCancel(): void; + maskProps?: { + style?: string; + }; +} + +export const EventFiltersFlyout: React.FC = memo( + ({ onCancel: onClose, data, ...flyoutProps }) => { + const toasts = useToasts(); + const http = useHttp(); + + const { isLoading: isSubmittingData, mutateAsync: submitData } = useWithArtifactSubmitData( + EventFiltersApiClient.getInstance(http), + 'create' + ); + + const [enrichedData, setEnrichedData] = useState(); + const [isFormValid, setIsFormValid] = useState(false); + const { + data: { search }, + } = useKibana().services; + + // load the list of policies> + const policiesRequest = useGetEndpointSpecificPolicies({ + perPage: 1000, + onError: (error) => { + toasts.addWarning(getLoadPoliciesError(error)); + }, + }); + + const [exception, setException] = useState( + getInitialExceptionFromEvent(data) + ); + + const policiesIsLoading = useMemo( + () => policiesRequest.isLoading || policiesRequest.isRefetching, + [policiesRequest] + ); + + useEffect(() => { + const enrichEvent = async () => { + if (!data || !data._index) return; + const searchResponse = await lastValueFrom( + search.search({ + params: { + index: data._index, + body: { + query: { + match: { + _id: data._id, + }, + }, + }, + }, + }) + ); + setEnrichedData({ + ...data, + host: { + ...data.host, + os: { + ...(data?.host?.os || {}), + name: [searchResponse.rawResponse.hits.hits[0]._source.host.os.name], + }, + }, + }); + }; + + if (data) { + enrichEvent(); + } + + return () => { + setException(getInitialExceptionFromEvent()); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleOnClose = useCallback(() => { + if (policiesIsLoading || isSubmittingData) return; + onClose(); + }, [isSubmittingData, policiesIsLoading, onClose]); + + const handleOnSubmit = useCallback(() => { + return submitData(exception, { + onSuccess: (result) => { + toasts.addSuccess(getCreationSuccessMessage(result)); + onClose(); + }, + onError: (error) => { + toasts.addError(error, getCreationErrorMessage(error)); + }, + }); + }, [exception, onClose, submitData, toasts]); + + const confirmButtonMemo = useMemo( + () => ( + + {data ? ( + + ) : ( + + )} + + ), + [data, enrichedData, handleOnSubmit, isFormValid, isSubmittingData, policiesIsLoading] + ); + + // update flyout state with form state + const onChange = useCallback((formState?: ArtifactFormComponentOnChangeCallbackProps) => { + if (!formState) return; + setIsFormValid(formState.isValid); + setException(formState.item); + }, []); + + return ( + + + +

+ {data ? ( + + ) : ( + + )} +

+
+ {data ? ( + + + + ) : null} +
+ + + + + + + + + + + + + {confirmButtonMemo} + + +
+ ); + } +); + +EventFiltersFlyout.displayName = 'EventFiltersFlyout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx deleted file mode 100644 index 0ba0a3385dcb6..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx +++ /dev/null @@ -1,287 +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 { EventFiltersFlyout, EventFiltersFlyoutProps } from '.'; -import * as reactTestingLibrary from '@testing-library/react'; -import { fireEvent } from '@testing-library/dom'; -import { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../../common/mock/endpoint'; -import { MiddlewareActionSpyHelper } from '../../../../../../common/store/test_utils'; -import { sendGetEndpointSpecificPackagePolicies } from '../../../../../services/policies/policies'; -import { sendGetEndpointSpecificPackagePoliciesMock } from '../../../../../services/policies/test_mock_utils'; -import type { - CreateExceptionListItemSchema, - ExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { ecsEventMock, esResponseData, eventFiltersListQueryHttpMock } from '../../../test_utils'; -import { getFormEntryState, isUninitialisedForm } from '../../../store/selector'; -import { EventFiltersListPageState } from '../../../types'; -import { useKibana } from '../../../../../../common/lib/kibana'; -import { licenseService } from '../../../../../../common/hooks/use_license'; -import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; -import { of } from 'rxjs'; - -jest.mock('../../../../../../common/lib/kibana'); -jest.mock('../form'); -jest.mock('../../../../../services/policies/policies'); - -jest.mock('../../hooks', () => { - const originalModule = jest.requireActual('../../hooks'); - const useEventFiltersNotification = jest.fn().mockImplementation(() => {}); - - return { - ...originalModule, - useEventFiltersNotification, - }; -}); - -jest.mock('../../../../../../common/hooks/use_license', () => { - const licenseServiceInstance = { - isPlatinumPlus: jest.fn(), - }; - return { - licenseService: licenseServiceInstance, - useLicense: () => { - return licenseServiceInstance; - }, - }; -}); - -(sendGetEndpointSpecificPackagePolicies as jest.Mock).mockImplementation( - sendGetEndpointSpecificPackagePoliciesMock -); - -let component: reactTestingLibrary.RenderResult; -let mockedContext: AppContextTestRender; -let waitForAction: MiddlewareActionSpyHelper['waitForAction']; -let render: ( - props?: Partial -) => ReturnType; -const act = reactTestingLibrary.act; -let onCancelMock: jest.Mock; -let getState: () => EventFiltersListPageState; -let mockedApi: ReturnType; - -describe('Event filter flyout', () => { - beforeEach(() => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); - mockedContext = createAppRootMockRenderer(); - waitForAction = mockedContext.middlewareSpy.waitForAction; - onCancelMock = jest.fn(); - getState = () => mockedContext.store.getState().management.eventFilters; - mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); - - render = (props) => { - return mockedContext.render(); - }; - - (useKibana as jest.Mock).mockReturnValue({ - services: { - docLinks: { - links: { - securitySolution: { - eventFilters: '', - }, - }, - }, - http: {}, - data: { - search: { - search: jest.fn().mockImplementation(() => of(esResponseData())), - }, - }, - notifications: {}, - }, - }); - }); - - afterEach(() => reactTestingLibrary.cleanup()); - - it('should renders correctly', () => { - component = render(); - expect(component.getAllByText('Add event filter')).not.toBeNull(); - expect(component.getByText('Cancel')).not.toBeNull(); - }); - - it('should renders correctly with data ', async () => { - await act(async () => { - component = render({ data: ecsEventMock() }); - await waitForAction('eventFiltersInitForm'); - }); - expect(component.getAllByText('Add endpoint event filter')).not.toBeNull(); - expect(component.getByText('Cancel')).not.toBeNull(); - }); - - it('should dispatch action to init form store on mount', async () => { - await act(async () => { - render(); - await waitForAction('eventFiltersInitForm'); - }); - - expect(getFormEntryState(getState())).not.toBeUndefined(); - expect(getFormEntryState(getState())?.entries[0].field).toBe(''); - }); - - it('should confirm form when button is disabled', () => { - component = render(); - const confirmButton = component.getByTestId('add-exception-confirm-button'); - act(() => { - fireEvent.click(confirmButton); - }); - expect(isUninitialisedForm(getState())).toBeTruthy(); - }); - - it('should confirm form when button is enabled', async () => { - component = render(); - - mockedContext.store.dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: { - ...(getState().form?.entry as CreateExceptionListItemSchema), - name: 'test', - os_types: ['windows'], - }, - hasNameError: false, - hasOSError: false, - }, - }); - await reactTestingLibrary.waitFor(() => { - expect(sendGetEndpointSpecificPackagePolicies).toHaveBeenCalled(); - }); - const confirmButton = component.getByTestId('add-exception-confirm-button'); - - await act(async () => { - fireEvent.click(confirmButton); - await waitForAction('eventFiltersCreateSuccess'); - }); - expect(isUninitialisedForm(getState())).toBeTruthy(); - expect(confirmButton.hasAttribute('disabled')).toBeFalsy(); - }); - - it('should close when exception has been submitted correctly', () => { - render(); - expect(onCancelMock).toHaveBeenCalledTimes(0); - - act(() => { - mockedContext.store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadedResourceState', - data: getState().form?.entry as ExceptionListItemSchema, - }, - }); - }); - - expect(onCancelMock).toHaveBeenCalledTimes(1); - }); - - it('should close when click on cancel button', () => { - component = render(); - const cancelButton = component.getByText('Cancel'); - expect(onCancelMock).toHaveBeenCalledTimes(0); - - act(() => { - fireEvent.click(cancelButton); - }); - - expect(onCancelMock).toHaveBeenCalledTimes(1); - }); - - it('should close when close flyout', () => { - component = render(); - const flyoutCloseButton = component.getByTestId('euiFlyoutCloseButton'); - expect(onCancelMock).toHaveBeenCalledTimes(0); - - act(() => { - fireEvent.click(flyoutCloseButton); - }); - - expect(onCancelMock).toHaveBeenCalledTimes(1); - }); - - it('should prevent close when is loading action', () => { - component = render(); - act(() => { - mockedContext.store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadingResourceState', - previousState: { type: 'UninitialisedResourceState' }, - }, - }); - }); - - const cancelButton = component.getByText('Cancel'); - expect(onCancelMock).toHaveBeenCalledTimes(0); - - act(() => { - fireEvent.click(cancelButton); - }); - - expect(onCancelMock).toHaveBeenCalledTimes(0); - }); - - it('should renders correctly when id and edit type', () => { - component = render({ id: 'fakeId', type: 'edit' }); - - expect(component.getAllByText('Update event filter')).not.toBeNull(); - expect(component.getByText('Cancel')).not.toBeNull(); - }); - - it('should dispatch action to init form store on mount with id', async () => { - await act(async () => { - render({ id: 'fakeId', type: 'edit' }); - await waitForAction('eventFiltersInitFromId'); - }); - - expect(getFormEntryState(getState())).not.toBeUndefined(); - expect(getFormEntryState(getState())?.item_id).toBe( - mockedApi.responseProvider.eventFiltersGetOne.getMockImplementation()!().item_id - ); - }); - - it('should not display banner when platinum license', async () => { - await act(async () => { - component = render({ id: 'fakeId', type: 'edit' }); - await waitForAction('eventFiltersInitFromId'); - }); - - expect(component.queryByTestId('expired-license-callout')).toBeNull(); - }); - - it('should not display banner when under platinum license and create mode', async () => { - component = render(); - expect(component.queryByTestId('expired-license-callout')).toBeNull(); - }); - - it('should not display banner when under platinum license and edit mode with global assignment', async () => { - mockedApi.responseProvider.eventFiltersGetOne.mockReturnValue({ - ...getExceptionListItemSchemaMock(), - tags: ['policy:all'], - }); - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - await act(async () => { - component = render({ id: 'fakeId', type: 'edit' }); - await waitForAction('eventFiltersInitFromId'); - }); - - expect(component.queryByTestId('expired-license-callout')).toBeNull(); - }); - - it('should display banner when under platinum license and edit mode with by policy assignment', async () => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - await act(async () => { - component = render({ id: 'fakeId', type: 'edit' }); - await waitForAction('eventFiltersInitFromId'); - }); - - expect(component.queryByTestId('expired-license-callout')).not.toBeNull(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx deleted file mode 100644 index ed4e0e11975c7..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx +++ /dev/null @@ -1,302 +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, { memo, useMemo, useEffect, useCallback, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { Dispatch } from 'redux'; - -import { FormattedMessage } from '@kbn/i18n-react'; -import { i18n } from '@kbn/i18n'; -import { - EuiFlyout, - EuiFlyoutHeader, - EuiTitle, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiTextColor, - EuiCallOut, - EuiLink, -} from '@elastic/eui'; -import { lastValueFrom } from 'rxjs'; -import { AppAction } from '../../../../../../common/store/actions'; -import { EventFiltersForm } from '../form'; -import { useEventFiltersSelector, useEventFiltersNotification } from '../../hooks'; -import { - getFormEntryStateMutable, - getFormHasError, - isCreationInProgress, - isCreationSuccessful, -} from '../../../store/selector'; -import { getInitialExceptionFromEvent } from '../../../store/utils'; -import { Ecs } from '../../../../../../../common/ecs'; -import { useKibana, useToasts } from '../../../../../../common/lib/kibana'; -import { useGetEndpointSpecificPolicies } from '../../../../../services/policies/hooks'; -import { getLoadPoliciesError } from '../../../../../common/translations'; -import { useLicense } from '../../../../../../common/hooks/use_license'; -import { isGlobalPolicyEffected } from '../../../../../components/effected_policy_select/utils'; - -export interface EventFiltersFlyoutProps { - type?: 'create' | 'edit'; - id?: string; - data?: Ecs; - onCancel(): void; - maskProps?: { - style?: string; - }; -} - -export const EventFiltersFlyout: React.FC = memo( - ({ onCancel, id, type = 'create', data, ...flyoutProps }) => { - useEventFiltersNotification(); - const [enrichedData, setEnrichedData] = useState(); - const toasts = useToasts(); - const dispatch = useDispatch>(); - const formHasError = useEventFiltersSelector(getFormHasError); - const creationInProgress = useEventFiltersSelector(isCreationInProgress); - const creationSuccessful = useEventFiltersSelector(isCreationSuccessful); - const exception = useEventFiltersSelector(getFormEntryStateMutable); - const { - data: { search }, - docLinks, - } = useKibana().services; - - // load the list of policies> - const policiesRequest = useGetEndpointSpecificPolicies({ - perPage: 1000, - onError: (error) => { - toasts.addWarning(getLoadPoliciesError(error)); - }, - }); - - const isPlatinumPlus = useLicense().isPlatinumPlus(); - const isEditMode = useMemo(() => type === 'edit' && !!id, [type, id]); - const [wasByPolicy, setWasByPolicy] = useState(undefined); - - const showExpiredLicenseBanner = useMemo(() => { - return !isPlatinumPlus && isEditMode && wasByPolicy; - }, [isPlatinumPlus, isEditMode, wasByPolicy]); - - useEffect(() => { - if (exception && wasByPolicy === undefined) { - setWasByPolicy(!isGlobalPolicyEffected(exception?.tags)); - } - }, [exception, wasByPolicy]); - - useEffect(() => { - if (creationSuccessful) { - onCancel(); - dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'UninitialisedResourceState', - }, - }); - } - }, [creationSuccessful, onCancel, dispatch]); - - // Initialize the store with the id passed as prop to allow render the form. It acts as componentDidMount - useEffect(() => { - const enrichEvent = async () => { - if (!data || !data._index) return; - const searchResponse = await lastValueFrom( - search.search({ - params: { - index: data._index, - body: { - query: { - match: { - _id: data._id, - }, - }, - }, - }, - }) - ); - - setEnrichedData({ - ...data, - host: { - ...data.host, - os: { - ...(data?.host?.os || {}), - name: [searchResponse.rawResponse.hits.hits[0]._source.host.os.name], - }, - }, - }); - }; - - if (type === 'edit' && !!id) { - dispatch({ - type: 'eventFiltersInitFromId', - payload: { id }, - }); - } else if (data) { - enrichEvent(); - } else { - dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: getInitialExceptionFromEvent() }, - }); - } - - return () => { - dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'UninitialisedResourceState', - }, - }); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Initialize the store with the enriched event to allow render the form - useEffect(() => { - if (enrichedData) { - dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: getInitialExceptionFromEvent(enrichedData) }, - }); - } - }, [dispatch, enrichedData]); - - const handleOnCancel = useCallback(() => { - if (creationInProgress) return; - onCancel(); - }, [creationInProgress, onCancel]); - - const confirmButtonMemo = useMemo( - () => ( - - id - ? dispatch({ type: 'eventFiltersUpdateStart' }) - : dispatch({ type: 'eventFiltersCreateStart' }) - } - isLoading={creationInProgress} - > - {id ? ( - - ) : data ? ( - - ) : ( - - )} - - ), - [formHasError, creationInProgress, data, enrichedData, id, dispatch, policiesRequest] - ); - - return ( - - - -

- {id ? ( - - ) : data ? ( - - ) : ( - - )} -

-
- {data ? ( - - - - ) : null} -
- - {showExpiredLicenseBanner && ( - - - - - - - )} - - - - - - - - - - - - - {confirmButtonMemo} - - -
- ); - } -); - -EventFiltersFlyout.displayName = 'EventFiltersFlyout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx new file mode 100644 index 0000000000000..e20abb2f93264 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx @@ -0,0 +1,468 @@ +/* + * Copyright 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 { act, cleanup } from '@testing-library/react'; +import { fireEvent } from '@testing-library/dom'; +import { stubIndexPattern } from '@kbn/data-plugin/common/stubs'; +import { useFetchIndex } from '../../../../../common/containers/source'; +import { NAME_ERROR } from '../event_filters_list'; +import { useCurrentUser, useKibana } from '../../../../../common/lib/kibana'; +import { licenseService } from '../../../../../common/hooks/use_license'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../common/mock/endpoint'; +import userEvent from '@testing-library/user-event'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import { + ArtifactFormComponentOnChangeCallbackProps, + ArtifactFormComponentProps, +} from '../../../../components/artifact_list_page'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { EventFiltersForm } from './form'; +import { EndpointDocGenerator } from '../../../../../../common/endpoint/generate_data'; +import { PolicyData } from '../../../../../../common/endpoint/types'; + +jest.mock('../../../../../common/lib/kibana'); +jest.mock('../../../../../common/containers/source'); +jest.mock('../../../../../common/hooks/use_license', () => { + const licenseServiceInstance = { + isPlatinumPlus: jest.fn(), + isGoldPlus: jest.fn(), + }; + return { + licenseService: licenseServiceInstance, + useLicense: () => { + return licenseServiceInstance; + }, + }; +}); + +describe('Event filter form', () => { + const formPrefix = 'eventFilters-form'; + const generator = new EndpointDocGenerator('effected-policy-select'); + + let formProps: jest.Mocked; + let mockedContext: AppContextTestRender; + let renderResult: ReturnType; + let latestUpdatedItem: ArtifactFormComponentProps['item']; + + const getUI = () => ; + const render = () => { + return (renderResult = mockedContext.render(getUI())); + }; + const rerender = () => renderResult.rerender(getUI()); + const rerenderWithLatestProps = () => { + formProps.item = latestUpdatedItem; + rerender(); + }; + + function createEntry( + overrides?: ExceptionListItemSchema['entries'][number] + ): ExceptionListItemSchema['entries'][number] { + const defaultEntry: ExceptionListItemSchema['entries'][number] = { + field: '', + operator: 'included', + type: 'match', + value: '', + }; + + return { + ...defaultEntry, + ...overrides, + }; + } + + function createItem( + overrides: Partial = {} + ): ArtifactFormComponentProps['item'] { + const defaults: ArtifactFormComponentProps['item'] = { + id: 'some_item_id', + list_id: ENDPOINT_EVENT_FILTERS_LIST_ID, + name: '', + description: '', + os_types: [OperatingSystem.WINDOWS], + entries: [createEntry()], + type: 'simple', + tags: ['policy:all'], + }; + return { + ...defaults, + ...overrides, + }; + } + + function createOnChangeArgs( + overrides: Partial + ): ArtifactFormComponentOnChangeCallbackProps { + const defaults = { + item: createItem(), + isValid: false, + }; + return { + ...defaults, + ...overrides, + }; + } + + function createPolicies(): PolicyData[] { + const policies = [ + generator.generatePolicyPackagePolicy(), + generator.generatePolicyPackagePolicy(), + ]; + policies.map((p, i) => { + p.id = `id-${i}`; + p.name = `some-policy-${Math.random().toString(36).split('.').pop()}`; + return p; + }); + return policies; + } + + beforeEach(async () => { + (useCurrentUser as jest.Mock).mockReturnValue({ username: 'test-username' }); + (useKibana as jest.Mock).mockReturnValue({ + services: { + http: {}, + data: {}, + unifiedSearch: {}, + notifications: {}, + }, + }); + (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); + mockedContext = createAppRootMockRenderer(); + latestUpdatedItem = createItem(); + (useFetchIndex as jest.Mock).mockImplementation(() => [ + false, + { + indexPatterns: stubIndexPattern, + }, + ]); + + formProps = { + item: latestUpdatedItem, + mode: 'create', + disabled: false, + error: undefined, + policiesIsLoading: false, + onChange: jest.fn((updates) => { + latestUpdatedItem = updates.item; + }), + policies: [], + }; + }); + + afterEach(() => { + cleanup(); + }); + + describe('Details and Conditions', () => { + it('should render correctly without data', () => { + formProps.policies = createPolicies(); + formProps.policiesIsLoading = true; + formProps.item.tags = [formProps.policies.map((p) => `policy:${p.id}`)[0]]; + formProps.item.entries = []; + render(); + expect(renderResult.getByTestId('loading-spinner')).not.toBeNull(); + }); + + it('should render correctly with data', async () => { + formProps.policies = createPolicies(); + render(); + expect(renderResult.queryByTestId('loading-spinner')).toBeNull(); + expect(renderResult.getByTestId('exceptionsBuilderWrapper')).not.toBeNull(); + }); + + it('should display sections', async () => { + render(); + expect(renderResult.queryByText('Details')).not.toBeNull(); + expect(renderResult.queryByText('Conditions')).not.toBeNull(); + expect(renderResult.queryByText('Comments')).not.toBeNull(); + }); + + it('should display name error only when on blur and empty name', async () => { + render(); + expect(renderResult.queryByText(NAME_ERROR)).toBeNull(); + const nameInput = renderResult.getByTestId(`${formPrefix}-name-input`); + act(() => { + fireEvent.blur(nameInput); + }); + rerenderWithLatestProps(); + expect(renderResult.queryByText(NAME_ERROR)).not.toBeNull(); + }); + + it('should change name', async () => { + render(); + const nameInput = renderResult.getByTestId(`${formPrefix}-name-input`); + + act(() => { + fireEvent.change(nameInput, { + target: { + value: 'Exception name', + }, + }); + fireEvent.blur(nameInput); + }); + rerenderWithLatestProps(); + + expect(formProps.item?.name).toBe('Exception name'); + expect(renderResult.queryByText(NAME_ERROR)).toBeNull(); + }); + + it('should change name with a white space still shows an error', async () => { + render(); + const nameInput = renderResult.getByTestId(`${formPrefix}-name-input`); + + act(() => { + fireEvent.change(nameInput, { + target: { + value: ' ', + }, + }); + fireEvent.blur(nameInput); + }); + rerenderWithLatestProps(); + + expect(formProps.item.name).toBe(''); + expect(renderResult.queryByText(NAME_ERROR)).not.toBeNull(); + }); + + it('should change description', async () => { + render(); + const nameInput = renderResult.getByTestId(`${formPrefix}-description-input`); + + act(() => { + fireEvent.change(nameInput, { + target: { + value: 'Exception description', + }, + }); + fireEvent.blur(nameInput); + }); + rerenderWithLatestProps(); + + expect(formProps.item.description).toBe('Exception description'); + }); + + it('should change comments', async () => { + render(); + const commentInput = renderResult.getByLabelText('Comment Input'); + + act(() => { + fireEvent.change(commentInput, { + target: { + value: 'Exception comment', + }, + }); + fireEvent.blur(commentInput); + }); + rerenderWithLatestProps(); + + expect(formProps.item.comments).toEqual([{ comment: 'Exception comment' }]); + }); + }); + + describe('Policy section', () => { + beforeEach(() => { + formProps.policies = createPolicies(); + }); + + afterEach(() => { + cleanup(); + }); + + it('should display loader when policies are still loading', () => { + formProps.policiesIsLoading = true; + formProps.item.tags = [formProps.policies.map((p) => `policy:${p.id}`)[0]]; + render(); + expect(renderResult.getByTestId('loading-spinner')).not.toBeNull(); + }); + + it('should display the policy list when "per policy" is selected', async () => { + render(); + userEvent.click(renderResult.getByTestId('perPolicy')); + rerenderWithLatestProps(); + // policy selector should show up + expect(renderResult.getByTestId('effectedPolicies-select-policiesSelectable')).toBeTruthy(); + }); + + it('should call onChange when a policy is selected from the policy selection', async () => { + formProps.item.tags = [formProps.policies.map((p) => `policy:${p.id}`)[0]]; + render(); + const policyId = formProps.policies[0].id; + userEvent.click(renderResult.getByTestId('effectedPolicies-select-perPolicy')); + userEvent.click(renderResult.getByTestId(`policy-${policyId}`)); + formProps.item.tags = formProps.onChange.mock.calls[0][0].item.tags; + rerender(); + const expected = createOnChangeArgs({ + item: { + ...formProps.item, + tags: [`policy:${policyId}`], + }, + }); + expect(formProps.onChange).toHaveBeenCalledWith(expected); + }); + + it('should have global policy by default', async () => { + render(); + expect(renderResult.getByTestId('globalPolicy')).toBeChecked(); + expect(renderResult.getByTestId('perPolicy')).not.toBeChecked(); + }); + + it('should retain the previous policy selection when switching from per-policy to global', async () => { + formProps.item.tags = [formProps.policies.map((p) => `policy:${p.id}`)[0]]; + render(); + const policyId = formProps.policies[0].id; + + // move to per-policy and select the first + userEvent.click(renderResult.getByTestId('perPolicy')); + userEvent.click(renderResult.getByTestId(`policy-${policyId}`)); + formProps.item.tags = formProps.onChange.mock.calls[0][0].item.tags; + rerender(); + expect(renderResult.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeTruthy(); + expect(formProps.item.tags).toEqual([`policy:${policyId}`]); + + // move back to global + userEvent.click(renderResult.getByTestId('globalPolicy')); + formProps.item.tags = ['policy:all']; + rerenderWithLatestProps(); + expect(formProps.item.tags).toEqual(['policy:all']); + expect(renderResult.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeFalsy(); + + // move back to per-policy + userEvent.click(renderResult.getByTestId('perPolicy')); + formProps.item.tags = [`policy:${policyId}`]; + rerender(); + // on change called with the previous policy + expect(formProps.item.tags).toEqual([`policy:${policyId}`]); + // the previous selected policy should be selected + // expect(renderResult.getByTestId(`policy-${policyId}`)).toHaveAttribute( + // 'data-test-selected', + // 'true' + // ); + }); + }); + + describe('Policy section with downgraded license', () => { + beforeEach(() => { + const policies = createPolicies(); + formProps.policies = policies; + formProps.item.tags = [policies.map((p) => `policy:${p.id}`)[0]]; + formProps.mode = 'edit'; + // downgrade license + (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); + }); + + it('should hide assignment section when no license', () => { + render(); + formProps.item.tags = ['policy:all']; + rerender(); + expect(renderResult.queryByTestId('effectedPolicies-select')).toBeNull(); + }); + + it('should hide assignment section when create mode and no license even with by policy', () => { + render(); + formProps.mode = 'create'; + rerender(); + expect(renderResult.queryByTestId('effectedPolicies-select')).toBeNull(); + }); + + it('should show disabled assignment section when edit mode and no license with by policy', async () => { + render(); + formProps.item.tags = ['policy:id-0']; + rerender(); + + expect(renderResult.queryByTestId('perPolicy')).not.toBeNull(); + expect(renderResult.getByTestId('policy-id-0').getAttribute('aria-disabled')).toBe('true'); + }); + + it("allows the user to set the event filter entry to 'Global' in the edit option", () => { + render(); + const globalButtonInput = renderResult.getByTestId('globalPolicy') as HTMLButtonElement; + userEvent.click(globalButtonInput); + formProps.item.tags = ['policy:all']; + rerender(); + const expected = createOnChangeArgs({ + item: { + ...formProps.item, + tags: ['policy:all'], + }, + }); + expect(formProps.onChange).toHaveBeenCalledWith(expected); + + const policyItem = formProps.onChange.mock.calls[0][0].item.tags + ? formProps.onChange.mock.calls[0][0].item.tags[0] + : ''; + + expect(policyItem).toBe('policy:all'); + }); + }); + + describe('Warnings', () => { + beforeEach(() => { + render(); + }); + + it('should not show warning text when unique fields are added', async () => { + formProps.item.entries = [ + { + field: 'event.category', + operator: 'included', + type: 'match', + value: 'some value', + }, + { + field: 'file.name', + operator: 'excluded', + type: 'match', + value: 'some other value', + }, + ]; + rerender(); + expect(renderResult.queryByTestId('duplicate-fields-warning-message')).toBeNull(); + }); + + it('should not show warning text when field values are not added', async () => { + formProps.item.entries = [ + { + field: 'event.category', + operator: 'included', + type: 'match', + value: '', + }, + { + field: 'event.category', + operator: 'excluded', + type: 'match', + value: '', + }, + ]; + rerender(); + expect(renderResult.queryByTestId('duplicate-fields-warning-message')).toBeNull(); + }); + + it('should show warning text when duplicate fields are added with values', async () => { + formProps.item.entries = [ + { + field: 'event.category', + operator: 'included', + type: 'match', + value: 'some value', + }, + { + field: 'event.category', + operator: 'excluded', + type: 'match', + value: 'some other value', + }, + ]; + rerender(); + expect(renderResult.findByTestId('duplicate-fields-warning-message')).not.toBeNull(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx new file mode 100644 index 0000000000000..4e021d12dac36 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx @@ -0,0 +1,558 @@ +/* + * Copyright 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, { memo, useMemo, useCallback, useState, useEffect } from 'react'; + +import { isEqual } from 'lodash'; +import { + EuiFieldText, + EuiSpacer, + EuiForm, + EuiFormRow, + EuiSuperSelect, + EuiSuperSelectOption, + EuiText, + EuiHorizontalRule, + EuiTextArea, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { EVENT_FILTERS_OPERATORS } from '@kbn/securitysolution-list-utils'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; + +import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; +import type { OnChangeProps } from '@kbn/lists-plugin/public'; +import { useTestIdGenerator } from '../../../../components/hooks/use_test_id_generator'; +import { PolicyData } from '../../../../../../common/endpoint/types'; +import { AddExceptionComments } from '../../../../../common/components/exceptions/add_exception_comments'; +import { useFetchIndex } from '../../../../../common/containers/source'; +import { Loader } from '../../../../../common/components/loader'; +import { useLicense } from '../../../../../common/hooks/use_license'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { ArtifactFormComponentProps } from '../../../../components/artifact_list_page'; +import { filterIndexPatterns } from '../../../../../common/components/exceptions/helpers'; +import { + isArtifactGlobal, + getPolicyIdsFromArtifact, + GLOBAL_ARTIFACT_TAG, + BY_POLICY_ARTIFACT_TAG_PREFIX, +} from '../../../../../../common/endpoint/service/artifacts'; + +import { + ABOUT_EVENT_FILTERS, + NAME_LABEL, + NAME_ERROR, + DESCRIPTION_LABEL, + OS_LABEL, + RULE_NAME, +} from '../event_filters_list'; +import { OS_TITLES } from '../../../../common/translations'; +import { ENDPOINT_EVENT_FILTERS_LIST_ID, EVENT_FILTER_LIST_TYPE } from '../../constants'; + +import { + EffectedPolicySelect, + EffectedPolicySelection, +} from '../../../../components/effected_policy_select'; +import { isGlobalPolicyEffected } from '../../../../components/effected_policy_select/utils'; + +const OPERATING_SYSTEMS: readonly OperatingSystem[] = [ + OperatingSystem.MAC, + OperatingSystem.WINDOWS, + OperatingSystem.LINUX, +]; + +// OS options +const osOptions: Array> = OPERATING_SYSTEMS.map((os) => ({ + value: os, + inputDisplay: OS_TITLES[os], +})); + +const getAddedFieldsCounts = (formFields: string[]): { [k: string]: number } => + formFields.reduce<{ [k: string]: number }>((allFields, field) => { + if (field in allFields) { + allFields[field]++; + } else { + allFields[field] = 1; + } + return allFields; + }, {}); + +const computeHasDuplicateFields = (formFieldsList: Record): boolean => + Object.values(formFieldsList).some((e) => e > 1); + +const defaultConditionEntry = (): ExceptionListItemSchema['entries'] => [ + { + field: '', + operator: 'included', + type: 'match', + value: '', + }, +]; + +const cleanupEntries = ( + item: ArtifactFormComponentProps['item'] +): ArtifactFormComponentProps['item']['entries'] => { + return item.entries.map( + (e: ArtifactFormComponentProps['item']['entries'][number] & { id?: string }) => { + delete e.id; + return e; + } + ); +}; + +type EventFilterItemEntries = Array<{ + field: string; + value: string; + operator: 'included' | 'excluded'; + type: Exclude; +}>; + +export const EventFiltersForm: React.FC = + memo(({ allowSelectOs = true, item: exception, policies, policiesIsLoading, onChange, mode }) => { + const getTestId = useTestIdGenerator('eventFilters-form'); + const { http, unifiedSearch } = useKibana().services; + + const [hasFormChanged, setHasFormChanged] = useState(false); + const [hasNameError, toggleHasNameError] = useState(!exception.name); + const [newComment, setNewComment] = useState(''); + const [hasBeenInputNameVisited, setHasBeenInputNameVisited] = useState(false); + const [selectedPolicies, setSelectedPolicies] = useState([]); + const isPlatinumPlus = useLicense().isPlatinumPlus(); + const isGlobal = useMemo( + () => isArtifactGlobal(exception as ExceptionListItemSchema), + [exception] + ); + const [wasByPolicy, setWasByPolicy] = useState(!isGlobalPolicyEffected(exception?.tags)); + + const [hasDuplicateFields, setHasDuplicateFields] = useState(false); + // This value has to be memoized to avoid infinite useEffect loop on useFetchIndex + const indexNames = useMemo(() => ['logs-endpoint.events.*'], []); + const [isIndexPatternLoading, { indexPatterns }] = useFetchIndex(indexNames); + const [areConditionsValid, setAreConditionsValid] = useState( + !!exception.entries.length || false + ); + // compute this for initial render only + const existingComments = useMemo( + () => (exception as ExceptionListItemSchema)?.comments, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const showAssignmentSection = useMemo(() => { + return ( + isPlatinumPlus || + (mode === 'edit' && (!isGlobal || (wasByPolicy && isGlobal && hasFormChanged))) + ); + }, [mode, isGlobal, hasFormChanged, isPlatinumPlus, wasByPolicy]); + + const isFormValid = useMemo(() => { + // verify that it has legit entries + // and not just default entry without values + return ( + !hasNameError && + !!exception.entries.length && + (exception.entries as EventFilterItemEntries).some((e) => e.value !== '' || e.value.length) + ); + }, [hasNameError, exception.entries]); + + const processChanged = useCallback( + (updatedItem?: Partial) => { + const item = updatedItem + ? { + ...exception, + ...updatedItem, + } + : exception; + cleanupEntries(item); + onChange({ + item, + isValid: isFormValid && areConditionsValid, + }); + }, + [areConditionsValid, exception, isFormValid, onChange] + ); + + // set initial state of `wasByPolicy` that checks + // if the initial state of the exception was by policy or not + useEffect(() => { + if (!hasFormChanged && exception.tags) { + setWasByPolicy(!isGlobalPolicyEffected(exception.tags)); + } + }, [exception.tags, hasFormChanged]); + + // select policies if editing + useEffect(() => { + if (hasFormChanged) return; + const policyIds = exception.tags ? getPolicyIdsFromArtifact({ tags: exception.tags }) : []; + + if (!policyIds.length) return; + const policiesData = policies.filter((policy) => policyIds.includes(policy.id)); + setSelectedPolicies(policiesData); + }, [hasFormChanged, exception, policies]); + + const eventFilterItem = useMemo(() => { + const ef: ArtifactFormComponentProps['item'] = exception; + ef.entries = exception.entries.length + ? (exception.entries as ExceptionListItemSchema['entries']) + : defaultConditionEntry(); + + // TODO: `id` gets added to the exception.entries item + // Is there a simpler way to this? + cleanupEntries(ef); + + setAreConditionsValid(!!exception.entries.length); + return ef; + }, [exception]); + + // name and handler + const handleOnChangeName = useCallback( + (event: React.ChangeEvent) => { + if (!exception) return; + const name = event.target.value.trim(); + toggleHasNameError(!name); + processChanged({ name }); + if (!hasFormChanged) setHasFormChanged(true); + }, + [exception, hasFormChanged, processChanged] + ); + + const nameInputMemo = useMemo( + () => ( + + !hasBeenInputNameVisited && setHasBeenInputNameVisited(true)} + /> + + ), + [getTestId, hasNameError, handleOnChangeName, hasBeenInputNameVisited, exception?.name] + ); + + // description and handler + const handleOnDescriptionChange = useCallback( + (event: React.ChangeEvent) => { + if (!exception) return; + if (!hasFormChanged) setHasFormChanged(true); + processChanged({ description: event.target.value.toString().trim() }); + }, + [exception, hasFormChanged, processChanged] + ); + const descriptionInputMemo = useMemo( + () => ( + + + + ), + [exception?.description, getTestId, handleOnDescriptionChange] + ); + + // selected OS and handler + const selectedOs = useMemo((): OperatingSystem => { + if (!exception?.os_types?.length) { + return OperatingSystem.WINDOWS; + } + return exception.os_types[0] as OperatingSystem; + }, [exception?.os_types]); + + const handleOnOsChange = useCallback( + (os: OperatingSystem) => { + if (!exception) return; + processChanged({ + os_types: [os], + entries: exception.entries, + }); + if (!hasFormChanged) setHasFormChanged(true); + }, + [exception, hasFormChanged, processChanged] + ); + + const osInputMemo = useMemo( + () => ( + + + + ), + [handleOnOsChange, selectedOs] + ); + + // comments and handler + const handleOnChangeComment = useCallback( + (value: string) => { + if (!exception) return; + setNewComment(value); + processChanged({ comments: [{ comment: value }] }); + if (!hasFormChanged) setHasFormChanged(true); + }, + [exception, hasFormChanged, processChanged] + ); + const commentsInputMemo = useMemo( + () => ( + + ), + [existingComments, handleOnChangeComment, newComment] + ); + + // comments + const commentsSection = useMemo( + () => ( + <> + +

+ +

+
+ + +

+ +

+
+ + {commentsInputMemo} + + ), + [commentsInputMemo] + ); + + // details + const detailsSection = useMemo( + () => ( + <> + +

+ +

+
+ + +

{ABOUT_EVENT_FILTERS}

+
+ + {nameInputMemo} + {descriptionInputMemo} + + ), + [nameInputMemo, descriptionInputMemo] + ); + + // conditions and handler + const handleOnBuilderChange = useCallback( + (arg: OnChangeProps) => { + const hasDuplicates = + (!hasFormChanged && arg.exceptionItems[0] === undefined) || + isEqual(arg.exceptionItems[0]?.entries, exception?.entries); + if (hasDuplicates) { + const addedFields = arg.exceptionItems[0]?.entries.map((e) => e.field) || ['']; + setHasDuplicateFields(computeHasDuplicateFields(getAddedFieldsCounts(addedFields))); + if (!hasFormChanged) setHasFormChanged(true); + return; + } + const updatedItem: Partial = + arg.exceptionItems[0] !== undefined + ? { + ...arg.exceptionItems[0], + name: exception?.name ?? '', + description: exception?.description ?? '', + comments: exception?.comments ?? [], + os_types: exception?.os_types ?? [OperatingSystem.WINDOWS], + tags: exception?.tags ?? [], + } + : exception; + const hasValidConditions = + arg.exceptionItems[0] !== undefined + ? !(arg.errorExists && !arg.exceptionItems[0]?.entries?.length) + : false; + + setAreConditionsValid(hasValidConditions); + processChanged(updatedItem); + if (!hasFormChanged) setHasFormChanged(true); + }, + [exception, hasFormChanged, processChanged] + ); + const exceptionBuilderComponentMemo = useMemo( + () => + getExceptionBuilderComponentLazy({ + allowLargeValueLists: false, + httpService: http, + autocompleteService: unifiedSearch.autocomplete, + exceptionListItems: [eventFilterItem as ExceptionListItemSchema], + listType: EVENT_FILTER_LIST_TYPE, + listId: ENDPOINT_EVENT_FILTERS_LIST_ID, + listNamespaceType: 'agnostic', + ruleName: RULE_NAME, + indexPatterns, + isOrDisabled: true, + isOrHidden: true, + isAndDisabled: false, + isNestedDisabled: false, + dataTestSubj: 'alert-exception-builder', + idAria: 'alert-exception-builder', + onChange: handleOnBuilderChange, + listTypeSpecificIndexPatternFilter: filterIndexPatterns, + operatorsList: EVENT_FILTERS_OPERATORS, + osTypes: exception.os_types, + }), + [unifiedSearch, handleOnBuilderChange, http, indexPatterns, exception, eventFilterItem] + ); + + // conditions + const criteriaSection = useMemo( + () => ( + <> + +

+ +

+
+ + +

+ {allowSelectOs ? ( + + ) : ( + + )} +

+
+ + {allowSelectOs ? ( + <> + {osInputMemo} + + + ) : null} + {exceptionBuilderComponentMemo} + + ), + [allowSelectOs, exceptionBuilderComponentMemo, osInputMemo] + ); + + // policy and handler + const handleOnPolicyChange = useCallback( + (change: EffectedPolicySelection) => { + const tags = change.isGlobal + ? [GLOBAL_ARTIFACT_TAG] + : change.selected.map((policy) => `${BY_POLICY_ARTIFACT_TAG_PREFIX}${policy.id}`); + + // Preserve old selected policies when switching to global + if (!change.isGlobal) { + setSelectedPolicies(change.selected); + } + processChanged({ tags }); + if (!hasFormChanged) setHasFormChanged(true); + }, + [processChanged, hasFormChanged, setSelectedPolicies] + ); + + const policiesSection = useMemo( + () => ( + + ), + [ + policies, + selectedPolicies, + isGlobal, + isPlatinumPlus, + handleOnPolicyChange, + policiesIsLoading, + ] + ); + + useEffect(() => { + processChanged(); + }, [processChanged]); + + if (isIndexPatternLoading || !exception) { + return ; + } + + return ( + + {detailsSection} + + {criteriaSection} + {hasDuplicateFields && ( + <> + + + + + + )} + {showAssignmentSection && ( + <> + + {policiesSection} + + )} + + {commentsSection} + + ); + }); + +EventFiltersForm.displayName = 'EventFiltersForm'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.test.tsx deleted file mode 100644 index f0589099a8077..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.test.tsx +++ /dev/null @@ -1,338 +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 { EventFiltersForm } from '.'; -import { RenderResult, act } from '@testing-library/react'; -import { fireEvent, waitFor } from '@testing-library/dom'; -import { stubIndexPattern } from '@kbn/data-plugin/common/stubs'; -import { getInitialExceptionFromEvent } from '../../../store/utils'; -import { useFetchIndex } from '../../../../../../common/containers/source'; -import { ecsEventMock } from '../../../test_utils'; -import { NAME_ERROR, NAME_PLACEHOLDER } from './translations'; -import { useCurrentUser, useKibana } from '../../../../../../common/lib/kibana'; -import { licenseService } from '../../../../../../common/hooks/use_license'; -import { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../../common/mock/endpoint'; -import { EventFiltersListPageState } from '../../../types'; -import { sendGetEndpointSpecificPackagePoliciesMock } from '../../../../../services/policies/test_mock_utils'; -import { GetPolicyListResponse } from '../../../../policy/types'; -import userEvent from '@testing-library/user-event'; -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; - -jest.mock('../../../../../../common/lib/kibana'); -jest.mock('../../../../../../common/containers/source'); -jest.mock('../../../../../../common/hooks/use_license', () => { - const licenseServiceInstance = { - isPlatinumPlus: jest.fn(), - }; - return { - licenseService: licenseServiceInstance, - useLicense: () => { - return licenseServiceInstance; - }, - }; -}); - -describe('Event filter form', () => { - let component: RenderResult; - let mockedContext: AppContextTestRender; - let render: ( - props?: Partial> - ) => ReturnType; - let renderWithData: ( - customEventFilterProps?: Partial - ) => Promise>; - let getState: () => EventFiltersListPageState; - let policiesRequest: GetPolicyListResponse; - - beforeEach(async () => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); - mockedContext = createAppRootMockRenderer(); - policiesRequest = await sendGetEndpointSpecificPackagePoliciesMock(); - getState = () => mockedContext.store.getState().management.eventFilters; - render = (props) => - mockedContext.render( - - ); - renderWithData = async (customEventFilterProps = {}) => { - const renderResult = render(); - const entry = getInitialExceptionFromEvent(ecsEventMock()); - - act(() => { - mockedContext.store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: { ...entry, ...customEventFilterProps } }, - }); - }); - await waitFor(() => { - expect(renderResult.getByTestId('exceptionsBuilderWrapper')).toBeInTheDocument(); - }); - return renderResult; - }; - - (useFetchIndex as jest.Mock).mockImplementation(() => [ - false, - { - indexPatterns: stubIndexPattern, - }, - ]); - (useCurrentUser as jest.Mock).mockReturnValue({ username: 'test-username' }); - (useKibana as jest.Mock).mockReturnValue({ - services: { - http: {}, - data: {}, - unifiedSearch: {}, - notifications: {}, - }, - }); - }); - - it('should renders correctly without data', () => { - component = render(); - expect(component.getByTestId('loading-spinner')).not.toBeNull(); - }); - - it('should renders correctly with data', async () => { - component = await renderWithData(); - - expect(component.getByTestId('exceptionsBuilderWrapper')).not.toBeNull(); - }); - - it('should displays loader when policies are still loading', () => { - component = render({ arePoliciesLoading: true }); - - expect(component.queryByTestId('exceptionsBuilderWrapper')).toBeNull(); - expect(component.getByTestId('loading-spinner')).not.toBeNull(); - }); - - it('should display sections', async () => { - component = await renderWithData(); - - expect(component.queryByText('Details')).not.toBeNull(); - expect(component.queryByText('Conditions')).not.toBeNull(); - expect(component.queryByText('Comments')).not.toBeNull(); - }); - - it('should display name error only when on blur and empty name', async () => { - component = await renderWithData(); - expect(component.queryByText(NAME_ERROR)).toBeNull(); - const nameInput = component.getByPlaceholderText(NAME_PLACEHOLDER); - act(() => { - fireEvent.blur(nameInput); - }); - expect(component.queryByText(NAME_ERROR)).not.toBeNull(); - }); - - it('should change name', async () => { - component = await renderWithData(); - - const nameInput = component.getByPlaceholderText(NAME_PLACEHOLDER); - - act(() => { - fireEvent.change(nameInput, { - target: { - value: 'Exception name', - }, - }); - }); - - expect(getState().form.entry?.name).toBe('Exception name'); - expect(getState().form.hasNameError).toBeFalsy(); - }); - - it('should change name with a white space still shows an error', async () => { - component = await renderWithData(); - - const nameInput = component.getByPlaceholderText(NAME_PLACEHOLDER); - - act(() => { - fireEvent.change(nameInput, { - target: { - value: ' ', - }, - }); - }); - - expect(getState().form.entry?.name).toBe(''); - expect(getState().form.hasNameError).toBeTruthy(); - }); - - it('should change description', async () => { - component = await renderWithData(); - - const nameInput = component.getByTestId('eventFilters-form-description-input'); - - act(() => { - fireEvent.change(nameInput, { - target: { - value: 'Exception description', - }, - }); - }); - - expect(getState().form.entry?.description).toBe('Exception description'); - }); - - it('should change comments', async () => { - component = await renderWithData(); - - const commentInput = component.getByPlaceholderText('Add a new comment...'); - - act(() => { - fireEvent.change(commentInput, { - target: { - value: 'Exception comment', - }, - }); - }); - - expect(getState().form.newComment).toBe('Exception comment'); - }); - - it('should display the policy list when "per policy" is selected', async () => { - component = await renderWithData(); - userEvent.click(component.getByTestId('perPolicy')); - - // policy selector should show up - expect(component.getByTestId('effectedPolicies-select-policiesSelectable')).toBeTruthy(); - }); - - it('should call onChange when a policy is selected from the policy selection', async () => { - component = await renderWithData(); - - const policyId = policiesRequest.items[0].id; - userEvent.click(component.getByTestId('perPolicy')); - userEvent.click(component.getByTestId(`policy-${policyId}`)); - expect(getState().form.entry?.tags).toEqual([`policy:${policyId}`]); - }); - - it('should have global policy by default', async () => { - component = await renderWithData(); - - expect(component.getByTestId('globalPolicy')).toBeChecked(); - expect(component.getByTestId('perPolicy')).not.toBeChecked(); - }); - - it('should retain the previous policy selection when switching from per-policy to global', async () => { - const policyId = policiesRequest.items[0].id; - - component = await renderWithData(); - - // move to per-policy and select the first - userEvent.click(component.getByTestId('perPolicy')); - userEvent.click(component.getByTestId(`policy-${policyId}`)); - expect(component.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeTruthy(); - expect(getState().form.entry?.tags).toEqual([`policy:${policyId}`]); - - // move back to global - userEvent.click(component.getByTestId('globalPolicy')); - expect(component.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeFalsy(); - expect(getState().form.entry?.tags).toEqual([`policy:all`]); - - // move back to per-policy - userEvent.click(component.getByTestId('perPolicy')); - // the previous selected policy should be selected - expect(component.getByTestId(`policy-${policyId}`)).toHaveAttribute( - 'data-test-selected', - 'true' - ); - // on change called with the previous policy - expect(getState().form.entry?.tags).toEqual([`policy:${policyId}`]); - }); - - it('should hide assignment section when no license', async () => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - component = await renderWithData(); - expect(component.queryByTestId('perPolicy')).toBeNull(); - }); - - it('should hide assignment section when create mode and no license even with by policy', async () => { - const policyId = policiesRequest.items[0].id; - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - component = await renderWithData({ tags: [`policy:${policyId}`] }); - expect(component.queryByTestId('perPolicy')).toBeNull(); - }); - - it('should show disabled assignment section when edit mode and no license with by policy', async () => { - const policyId = policiesRequest.items[0].id; - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - component = await renderWithData({ tags: [`policy:${policyId}`], item_id: '1' }); - expect(component.queryByTestId('perPolicy')).not.toBeNull(); - expect(component.getByTestId(`policy-${policyId}`).getAttribute('aria-disabled')).toBe('true'); - }); - - it('should change from by policy to global when edit mode and no license with by policy', async () => { - const policyId = policiesRequest.items[0].id; - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - component = await renderWithData({ tags: [`policy:${policyId}`], item_id: '1' }); - userEvent.click(component.getByTestId('globalPolicy')); - expect(component.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeFalsy(); - expect(getState().form.entry?.tags).toEqual([`policy:all`]); - }); - - it('should not show warning text when unique fields are added', async () => { - component = await renderWithData({ - entries: [ - { - field: 'event.category', - operator: 'included', - type: 'match', - value: 'some value', - }, - { - field: 'file.name', - operator: 'excluded', - type: 'match', - value: 'some other value', - }, - ], - }); - expect(component.queryByTestId('duplicate-fields-warning-message')).toBeNull(); - }); - - it('should not show warning text when field values are not added', async () => { - component = await renderWithData({ - entries: [ - { - field: 'event.category', - operator: 'included', - type: 'match', - value: '', - }, - { - field: 'event.category', - operator: 'excluded', - type: 'match', - value: '', - }, - ], - }); - expect(component.queryByTestId('duplicate-fields-warning-message')).toBeNull(); - }); - - it('should show warning text when duplicate fields are added with values', async () => { - component = await renderWithData({ - entries: [ - { - field: 'event.category', - operator: 'included', - type: 'match', - value: 'some value', - }, - { - field: 'event.category', - operator: 'excluded', - type: 'match', - value: 'some other value', - }, - ], - }); - expect(component.queryByTestId('duplicate-fields-warning-message')).not.toBeNull(); - }); -}); 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 deleted file mode 100644 index 11d1af0a5a2e9..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx +++ /dev/null @@ -1,487 +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, { memo, useMemo, useCallback, useState, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { Dispatch } from 'redux'; - -import { isEqual } from 'lodash'; -import { - EuiFieldText, - EuiSpacer, - EuiForm, - EuiFormRow, - EuiSuperSelect, - EuiSuperSelectOption, - EuiText, - EuiHorizontalRule, - EuiTextArea, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { EVENT_FILTERS_OPERATORS } from '@kbn/securitysolution-list-utils'; -import { OperatingSystem } from '@kbn/securitysolution-utils'; - -import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; -import type { OnChangeProps } from '@kbn/lists-plugin/public'; -import { PolicyData } from '../../../../../../../common/endpoint/types'; -import { AddExceptionComments } from '../../../../../../common/components/exceptions/add_exception_comments'; -import { filterIndexPatterns } from '../../../../../../common/components/exceptions/helpers'; -import { Loader } from '../../../../../../common/components/loader'; -import { useKibana } from '../../../../../../common/lib/kibana'; -import { useFetchIndex } from '../../../../../../common/containers/source'; -import { AppAction } from '../../../../../../common/store/actions'; -import { useEventFiltersSelector } from '../../hooks'; -import { getFormEntryStateMutable, getHasNameError, getNewComment } from '../../../store/selector'; -import { - NAME_LABEL, - NAME_ERROR, - DESCRIPTION_LABEL, - DESCRIPTION_PLACEHOLDER, - NAME_PLACEHOLDER, - OS_LABEL, - RULE_NAME, -} from './translations'; -import { OS_TITLES } from '../../../../../common/translations'; -import { ENDPOINT_EVENT_FILTERS_LIST_ID, EVENT_FILTER_LIST_TYPE } from '../../../constants'; -import { ABOUT_EVENT_FILTERS } from '../../translations'; -import { - EffectedPolicySelect, - EffectedPolicySelection, - EffectedPolicySelectProps, -} from '../../../../../components/effected_policy_select'; -import { - getArtifactTagsByEffectedPolicySelection, - getArtifactTagsWithoutPolicies, - getEffectedPolicySelectionByTags, - isGlobalPolicyEffected, -} from '../../../../../components/effected_policy_select/utils'; -import { useLicense } from '../../../../../../common/hooks/use_license'; - -const OPERATING_SYSTEMS: readonly OperatingSystem[] = [ - OperatingSystem.MAC, - OperatingSystem.WINDOWS, - OperatingSystem.LINUX, -]; - -const getAddedFieldsCounts = (formFields: string[]): { [k: string]: number } => - formFields.reduce<{ [k: string]: number }>((allFields, field) => { - if (field in allFields) { - allFields[field]++; - } else { - allFields[field] = 1; - } - return allFields; - }, {}); - -const computeHasDuplicateFields = (formFieldsList: Record): boolean => - Object.values(formFieldsList).some((e) => e > 1); -interface EventFiltersFormProps { - allowSelectOs?: boolean; - policies: PolicyData[]; - arePoliciesLoading: boolean; -} -export const EventFiltersForm: React.FC = memo( - ({ allowSelectOs = false, policies, arePoliciesLoading }) => { - const { http, unifiedSearch } = useKibana().services; - - const dispatch = useDispatch>(); - const exception = useEventFiltersSelector(getFormEntryStateMutable); - const hasNameError = useEventFiltersSelector(getHasNameError); - const newComment = useEventFiltersSelector(getNewComment); - const [hasBeenInputNameVisited, setHasBeenInputNameVisited] = useState(false); - const isPlatinumPlus = useLicense().isPlatinumPlus(); - const [hasFormChanged, setHasFormChanged] = useState(false); - const [hasDuplicateFields, setHasDuplicateFields] = useState(false); - - // This value has to be memoized to avoid infinite useEffect loop on useFetchIndex - const indexNames = useMemo(() => ['logs-endpoint.events.*'], []); - const [isIndexPatternLoading, { indexPatterns }] = useFetchIndex(indexNames); - - const [selection, setSelection] = useState({ - selected: [], - isGlobal: isGlobalPolicyEffected(exception?.tags), - }); - - const isEditMode = useMemo(() => !!exception?.item_id, [exception?.item_id]); - const [wasByPolicy, setWasByPolicy] = useState(!isGlobalPolicyEffected(exception?.tags)); - - const showAssignmentSection = useMemo(() => { - return ( - isPlatinumPlus || - (isEditMode && - (!selection.isGlobal || (wasByPolicy && selection.isGlobal && hasFormChanged))) - ); - }, [isEditMode, selection.isGlobal, hasFormChanged, isPlatinumPlus, wasByPolicy]); - - // set current policies if not previously selected - useEffect(() => { - if (selection.selected.length === 0 && exception?.tags) { - setSelection(getEffectedPolicySelectionByTags(exception.tags, policies)); - } - }, [exception?.tags, policies, selection.selected.length]); - - // set initial state of `wasByPolicy` that checks if the initial state of the exception was by policy or not - useEffect(() => { - if (!hasFormChanged && exception?.tags) { - setWasByPolicy(!isGlobalPolicyEffected(exception?.tags)); - } - }, [exception?.tags, hasFormChanged]); - - const osOptions: Array> = useMemo( - () => OPERATING_SYSTEMS.map((os) => ({ value: os, inputDisplay: OS_TITLES[os] })), - [] - ); - - const handleOnBuilderChange = useCallback( - (arg: OnChangeProps) => { - if ( - (!hasFormChanged && arg.exceptionItems[0] === undefined) || - isEqual(arg.exceptionItems[0]?.entries, exception?.entries) - ) { - const addedFields = arg.exceptionItems[0]?.entries.map((e) => e.field) || ['']; - setHasDuplicateFields(computeHasDuplicateFields(getAddedFieldsCounts(addedFields))); - setHasFormChanged(true); - return; - } - setHasFormChanged(true); - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - ...(arg.exceptionItems[0] !== undefined - ? { - entry: { - ...arg.exceptionItems[0], - name: exception?.name ?? '', - description: exception?.description ?? '', - comments: exception?.comments ?? [], - os_types: exception?.os_types ?? [OperatingSystem.WINDOWS], - tags: exception?.tags ?? [], - }, - hasItemsError: arg.errorExists || !arg.exceptionItems[0]?.entries?.length, - } - : { - hasItemsError: true, - }), - }, - }); - }, - [dispatch, exception, hasFormChanged] - ); - - const handleOnChangeName = useCallback( - (e: React.ChangeEvent) => { - if (!exception) return; - setHasFormChanged(true); - const name = e.target.value.toString().trim(); - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: { ...exception, name }, - hasNameError: !name, - }, - }); - }, - [dispatch, exception] - ); - - const handleOnDescriptionChange = useCallback( - (e: React.ChangeEvent) => { - if (!exception) return; - setHasFormChanged(true); - const description = e.target.value.toString().trim(); - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: { ...exception, description }, - }, - }); - }, - [dispatch, exception] - ); - - const handleOnChangeComment = useCallback( - (value: string) => { - if (!exception) return; - setHasFormChanged(true); - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: exception, - newComment: value, - }, - }); - }, - [dispatch, exception] - ); - - const exceptionBuilderComponentMemo = useMemo( - () => - getExceptionBuilderComponentLazy({ - allowLargeValueLists: false, - httpService: http, - autocompleteService: unifiedSearch.autocomplete, - exceptionListItems: [exception as ExceptionListItemSchema], - listType: EVENT_FILTER_LIST_TYPE, - listId: ENDPOINT_EVENT_FILTERS_LIST_ID, - listNamespaceType: 'agnostic', - ruleName: RULE_NAME, - indexPatterns, - isOrDisabled: true, - isOrHidden: true, - isAndDisabled: false, - isNestedDisabled: false, - dataTestSubj: 'alert-exception-builder', - idAria: 'alert-exception-builder', - onChange: handleOnBuilderChange, - listTypeSpecificIndexPatternFilter: filterIndexPatterns, - operatorsList: EVENT_FILTERS_OPERATORS, - osTypes: exception?.os_types, - }), - [unifiedSearch, handleOnBuilderChange, http, indexPatterns, exception] - ); - - const nameInputMemo = useMemo( - () => ( - - !hasBeenInputNameVisited && setHasBeenInputNameVisited(true)} - /> - - ), - [hasNameError, exception?.name, handleOnChangeName, hasBeenInputNameVisited] - ); - - const descriptionInputMemo = useMemo( - () => ( - - - - ), - [exception?.description, handleOnDescriptionChange] - ); - - const osInputMemo = useMemo( - () => ( - - { - if (!exception) return; - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: { - ...exception, - os_types: [value as 'windows' | 'linux' | 'macos'], - }, - }, - }); - }} - /> - - ), - [dispatch, exception, osOptions] - ); - - const commentsInputMemo = useMemo( - () => ( - - ), - [exception, handleOnChangeComment, newComment] - ); - - const detailsSection = useMemo( - () => ( - <> - -

- -

-
- - -

{ABOUT_EVENT_FILTERS}

-
- - {nameInputMemo} - {descriptionInputMemo} - - ), - [nameInputMemo, descriptionInputMemo] - ); - - const criteriaSection = useMemo( - () => ( - <> - -

- -

-
- - -

- -

-
- - {allowSelectOs ? ( - <> - {osInputMemo} - - - ) : null} - {exceptionBuilderComponentMemo} - - ), - [allowSelectOs, exceptionBuilderComponentMemo, osInputMemo] - ); - - const handleOnChangeEffectScope: EffectedPolicySelectProps['onChange'] = useCallback( - (currentSelection) => { - if (currentSelection.isGlobal) { - // Preserve last selection inputs - setSelection({ ...selection, isGlobal: true }); - } else { - setSelection(currentSelection); - } - - if (!exception) return; - setHasFormChanged(true); - - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: { - ...exception, - tags: getArtifactTagsByEffectedPolicySelection( - currentSelection, - getArtifactTagsWithoutPolicies(exception?.tags ?? []) - ), - }, - }, - }); - }, - [dispatch, exception, selection] - ); - const policiesSection = useMemo( - () => ( - - ), - [policies, selection, isPlatinumPlus, handleOnChangeEffectScope, arePoliciesLoading] - ); - - const commentsSection = useMemo( - () => ( - <> - -

- -

-
- - -

- -

-
- - {commentsInputMemo} - - ), - [commentsInputMemo] - ); - - if (isIndexPatternLoading || !exception) { - return ; - } - - return ( - - {detailsSection} - - {criteriaSection} - {hasDuplicateFields && ( - <> - - - - - - )} - {showAssignmentSection && ( - <> - {policiesSection} - - )} - - {commentsSection} - - ); - } -); - -EventFiltersForm.displayName = 'EventFiltersForm'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts deleted file mode 100644 index 20bdde0364e2c..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const NAME_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.eventFilter.form.name.placeholder', - { - defaultMessage: 'Event filter name', - } -); - -export const NAME_LABEL = i18n.translate('xpack.securitySolution.eventFilter.form.name.label', { - defaultMessage: 'Name your event filter', -}); -export const DESCRIPTION_LABEL = i18n.translate( - 'xpack.securitySolution.eventFilter.form.description.placeholder', - { - defaultMessage: 'Description', - } -); - -export const DESCRIPTION_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.eventFilter.form.description.label', - { - defaultMessage: 'Describe your event filter', - } -); - -export const NAME_ERROR = i18n.translate('xpack.securitySolution.eventFilter.form.name.error', { - defaultMessage: "The name can't be empty", -}); - -export const OS_LABEL = i18n.translate('xpack.securitySolution.eventFilter.form.os.label', { - defaultMessage: 'Select operating system', -}); - -export const RULE_NAME = i18n.translate('xpack.securitySolution.eventFilter.form.rule.name', { - defaultMessage: 'Endpoint Event Filtering', -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.test.tsx new file mode 100644 index 0000000000000..79afbce97caf6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { EVENT_FILTERS_PATH } from '../../../../../common/constants'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; +import { EventFiltersList } from './event_filters_list'; +import { exceptionsListAllHttpMocks } from '../../mocks/exceptions_list_http_mocks'; +import { SEARCHABLE_FIELDS } from '../constants'; +import { parseQueryFilterToKQL } from '../../../common/utils'; + +describe('When on the Event Filters list page', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let history: AppContextTestRender['history']; + let mockedContext: AppContextTestRender; + let apiMocks: ReturnType; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + ({ history } = mockedContext); + render = () => (renderResult = mockedContext.render()); + apiMocks = exceptionsListAllHttpMocks(mockedContext.coreStart.http); + act(() => { + history.push(EVENT_FILTERS_PATH); + }); + }); + + it('should search using expected exception item fields', async () => { + const expectedFilterString = parseQueryFilterToKQL('fooFooFoo', SEARCHABLE_FIELDS); + const { findAllByTestId } = render(); + await waitFor(async () => { + await expect(findAllByTestId('EventFiltersListPage-card')).resolves.toHaveLength(10); + }); + + apiMocks.responseProvider.exceptionsFind.mockClear(); + userEvent.type(renderResult.getByTestId('searchField'), 'fooFooFoo'); + userEvent.click(renderResult.getByTestId('searchButton')); + await waitFor(() => { + expect(apiMocks.responseProvider.exceptionsFind).toHaveBeenCalled(); + }); + + expect(apiMocks.responseProvider.exceptionsFind).toHaveBeenLastCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + filter: expectedFilterString, + }), + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx new file mode 100644 index 0000000000000..f303987e1acab --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx @@ -0,0 +1,150 @@ +/* + * Copyright 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, { memo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { DocLinks } from '@kbn/doc-links'; +import { EuiLink } from '@elastic/eui'; + +import { useHttp } from '../../../../common/lib/kibana'; +import { ArtifactListPage, ArtifactListPageProps } from '../../../components/artifact_list_page'; +import { EventFiltersApiClient } from '../service/api_client'; +import { EventFiltersForm } from './components/form'; +import { SEARCHABLE_FIELDS } from '../constants'; + +export const ABOUT_EVENT_FILTERS = i18n.translate('xpack.securitySolution.eventFilters.aboutInfo', { + defaultMessage: + 'Event filters exclude high volume or unwanted events from being written to Elasticsearch.', +}); + +export const NAME_LABEL = i18n.translate('xpack.securitySolution.eventFilter.form.name.label', { + defaultMessage: 'Name', +}); +export const DESCRIPTION_LABEL = i18n.translate( + 'xpack.securitySolution.eventFilter.form.description.placeholder', + { + defaultMessage: 'Description', + } +); + +export const NAME_ERROR = i18n.translate('xpack.securitySolution.eventFilter.form.name.error', { + defaultMessage: "The name can't be empty", +}); + +export const OS_LABEL = i18n.translate('xpack.securitySolution.eventFilter.form.os.label', { + defaultMessage: 'Select operating system', +}); + +export const RULE_NAME = i18n.translate('xpack.securitySolution.eventFilter.form.rule.name', { + defaultMessage: 'Endpoint Event Filtering', +}); + +const EVENT_FILTERS_PAGE_LABELS: ArtifactListPageProps['labels'] = { + pageTitle: i18n.translate('xpack.securitySolution.eventFilters.pageTitle', { + defaultMessage: 'Event Filters', + }), + pageAboutInfo: i18n.translate('xpack.securitySolution.eventFilters.pageAboutInfo', { + defaultMessage: + 'Event filters exclude high volume or unwanted events from being written to Elasticsearch.', + }), + pageAddButtonTitle: i18n.translate('xpack.securitySolution.eventFilters.pageAddButtonTitle', { + defaultMessage: 'Add event filter', + }), + getShowingCountLabel: (total) => + i18n.translate('xpack.securitySolution.eventFilters.showingTotal', { + defaultMessage: 'Showing {total} {total, plural, one {event filter} other {event filters}}', + values: { total }, + }), + cardActionEditLabel: i18n.translate('xpack.securitySolution.eventFilters.cardActionEditLabel', { + defaultMessage: 'Edit event filter', + }), + cardActionDeleteLabel: i18n.translate( + 'xpack.securitySolution.eventFilters.cardActionDeleteLabel', + { + defaultMessage: 'Delete event filter', + } + ), + flyoutCreateTitle: i18n.translate('xpack.securitySolution.eventFilters.flyoutCreateTitle', { + defaultMessage: 'Add event filter', + }), + flyoutEditTitle: i18n.translate('xpack.securitySolution.eventFilters.flyoutEditTitle', { + defaultMessage: 'Edit event filter', + }), + flyoutCreateSubmitButtonLabel: i18n.translate( + 'xpack.securitySolution.eventFilters.flyoutCreateSubmitButtonLabel', + { defaultMessage: 'Add event filter' } + ), + flyoutCreateSubmitSuccess: ({ name }) => + i18n.translate('xpack.securitySolution.eventFilters.flyoutCreateSubmitSuccess', { + defaultMessage: '"{name}" has been added to the event filters list.', + values: { name }, + }), + flyoutEditSubmitSuccess: ({ name }) => + i18n.translate('xpack.securitySolution.eventFilters.flyoutEditSubmitSuccess', { + defaultMessage: '"{name}" has been updated.', + values: { name }, + }), + flyoutDowngradedLicenseDocsInfo: ( + securitySolutionDocsLinks: DocLinks['securitySolution'] + ): React.ReactNode => { + return ( + <> + + + + + + ); + }, + deleteActionSuccess: (itemName) => + i18n.translate('xpack.securitySolution.eventFilters.deleteSuccess', { + defaultMessage: '"{itemName}" has been removed from event filters list.', + values: { itemName }, + }), + emptyStateTitle: i18n.translate('xpack.securitySolution.eventFilters.emptyStateTitle', { + defaultMessage: 'Add your first event filter', + }), + emptyStateInfo: i18n.translate('xpack.securitySolution.eventFilters.emptyStateInfo', { + defaultMessage: + 'Add an event filter to exclude high volume or unwanted events from being written to Elasticsearch.', + }), + emptyStatePrimaryButtonLabel: i18n.translate( + 'xpack.securitySolution.eventFilters.emptyStatePrimaryButtonLabel', + { defaultMessage: 'Add event filter' } + ), + searchPlaceholderInfo: i18n.translate( + 'xpack.securitySolution.eventFilters.searchPlaceholderInfo', + { + defaultMessage: 'Search on the fields below: name, description, comments, value', + } + ), +}; + +export const EventFiltersList = memo(() => { + const http = useHttp(); + const eventFiltersApiClient = EventFiltersApiClient.getInstance(http); + + return ( + + ); +}); + +EventFiltersList.displayName = 'EventFiltersList'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx deleted file mode 100644 index ec0adf0c10a23..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx +++ /dev/null @@ -1,247 +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 { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; -import React from 'react'; -import { fireEvent, act, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { EventFiltersListPage } from './event_filters_list_page'; -import { eventFiltersListQueryHttpMock } from '../test_utils'; -import { isFailedResourceState, isLoadedResourceState } from '../../../state'; -import { sendGetEndpointSpecificPackagePolicies } from '../../../services/policies/policies'; -import { sendGetEndpointSpecificPackagePoliciesMock } from '../../../services/policies/test_mock_utils'; - -// Needed to mock the data services used by the ExceptionItem component -jest.mock('../../../../common/lib/kibana'); -jest.mock('../../../../common/components/user_privileges'); -jest.mock('../../../services/policies/policies'); - -(sendGetEndpointSpecificPackagePolicies as jest.Mock).mockImplementation( - sendGetEndpointSpecificPackagePoliciesMock -); - -describe('When on the Event Filters List Page', () => { - let render: () => ReturnType; - let renderResult: ReturnType; - let history: AppContextTestRender['history']; - let coreStart: AppContextTestRender['coreStart']; - let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; - let mockedApi: ReturnType; - - const dataReceived = () => - act(async () => { - await waitForAction('eventFiltersListPageDataChanged', { - validate(action) { - return isLoadedResourceState(action.payload); - }, - }); - }); - - beforeEach(() => { - const mockedContext = createAppRootMockRenderer(); - - ({ history, coreStart } = mockedContext); - render = () => (renderResult = mockedContext.render()); - mockedApi = eventFiltersListQueryHttpMock(coreStart.http); - waitForAction = mockedContext.middlewareSpy.waitForAction; - - act(() => { - history.push('/administration/event_filters'); - }); - }); - - describe('And no data exists', () => { - beforeEach(async () => { - mockedApi.responseProvider.eventFiltersList.mockReturnValue({ - data: [], - page: 1, - per_page: 10, - total: 0, - }); - - render(); - - await act(async () => { - await waitForAction('eventFiltersListPageDataExistsChanged', { - validate(action) { - return isLoadedResourceState(action.payload); - }, - }); - }); - }); - - it('should show the Empty message', () => { - expect(renderResult.getByTestId('eventFiltersEmpty')).toBeTruthy(); - expect(renderResult.getByTestId('eventFiltersListEmptyStateAddButton')).toBeTruthy(); - }); - - it('should open create flyout when add button in empty state is clicked', async () => { - act(() => { - fireEvent.click(renderResult.getByTestId('eventFiltersListEmptyStateAddButton')); - }); - - expect(renderResult.getByTestId('eventFiltersCreateEditFlyout')).toBeTruthy(); - expect(history.location.search).toEqual('?show=create'); - }); - }); - - describe('And data exists', () => { - it('should show loading indicator while retrieving data', async () => { - let releaseApiResponse: () => void; - - mockedApi.responseProvider.eventFiltersList.mockDelay.mockReturnValue( - new Promise((r) => (releaseApiResponse = r)) - ); - render(); - - expect(renderResult.getByTestId('eventFilterListLoader')).toBeTruthy(); - - const wasReceived = dataReceived(); - releaseApiResponse!(); - await wasReceived; - - expect(renderResult.container.querySelector('.euiProgress')).toBeNull(); - }); - - it('should show items on the list', async () => { - render(); - await dataReceived(); - - expect(renderResult.getByTestId('eventFilterCard')).toBeTruthy(); - }); - - it('should render expected fields on card', async () => { - render(); - await dataReceived(); - - [ - ['subHeader-touchedBy-createdBy-value', 'some user'], - ['subHeader-touchedBy-updatedBy-value', 'some user'], - ['header-created-value', '4/20/2020'], - ['header-updated-value', '4/20/2020'], - ].forEach(([suffix, value]) => - expect(renderResult.getByTestId(`eventFilterCard-${suffix}`).textContent).toEqual(value) - ); - }); - - it('should show API error if one is encountered', async () => { - mockedApi.responseProvider.eventFiltersList.mockImplementation(() => { - throw new Error('oh no'); - }); - render(); - await act(async () => { - await waitForAction('eventFiltersListPageDataChanged', { - validate(action) { - return isFailedResourceState(action.payload); - }, - }); - }); - - expect(renderResult.getByTestId('eventFiltersContent-error').textContent).toEqual(' oh no'); - }); - - it('should show modal when delete is clicked on a card', async () => { - render(); - await dataReceived(); - - await act(async () => { - (await renderResult.findAllByTestId('eventFilterCard-header-actions-button'))[0].click(); - }); - - await act(async () => { - (await renderResult.findByTestId('deleteEventFilterAction')).click(); - }); - - expect( - renderResult.baseElement.querySelector('[data-test-subj="eventFilterDeleteModalHeader"]') - ).not.toBeNull(); - }); - }); - - describe('And search is dispatched', () => { - beforeEach(async () => { - act(() => { - history.push('/administration/event_filters?filter=test'); - }); - renderResult = render(); - await act(async () => { - await waitForAction('eventFiltersListPageDataChanged'); - }); - }); - - it('search bar is filled with query params', () => { - expect(renderResult.getByDisplayValue('test')).not.toBeNull(); - }); - - it('search action is dispatched', async () => { - await act(async () => { - fireEvent.click(renderResult.getByTestId('searchButton')); - expect(await waitForAction('userChangedUrl')).not.toBeNull(); - }); - }); - }); - - describe('And policies select is dispatched', () => { - it('should apply policy filter', async () => { - const policies = await sendGetEndpointSpecificPackagePoliciesMock(); - (sendGetEndpointSpecificPackagePolicies as jest.Mock).mockResolvedValue(policies); - - renderResult = render(); - await waitFor(() => { - expect(sendGetEndpointSpecificPackagePolicies).toHaveBeenCalled(); - }); - - const firstPolicy = policies.items[0]; - - userEvent.click(renderResult.getByTestId('policiesSelectorButton')); - userEvent.click(renderResult.getByTestId(`policiesSelector-popover-items-${firstPolicy.id}`)); - await waitFor(() => expect(waitForAction('userChangedUrl')).not.toBeNull()); - }); - }); - - describe('and the back button is present', () => { - beforeEach(async () => { - renderResult = render(); - act(() => { - history.push('/administration/event_filters', { - onBackButtonNavigateTo: [{ appId: 'appId' }], - backButtonLabel: 'back to fleet', - backButtonUrl: '/fleet', - }); - }); - }); - - it('back button is present', () => { - const button = renderResult.queryByTestId('backToOrigin'); - expect(button).not.toBeNull(); - expect(button).toHaveAttribute('href', '/fleet'); - }); - - it('back button is still present after push history', () => { - act(() => { - history.push('/administration/event_filters'); - }); - const button = renderResult.queryByTestId('backToOrigin'); - expect(button).not.toBeNull(); - expect(button).toHaveAttribute('href', '/fleet'); - }); - }); - - describe('and the back button is not present', () => { - beforeEach(async () => { - renderResult = render(); - act(() => { - history.push('/administration/event_filters'); - }); - }); - - it('back button is not present when missing history params', () => { - const button = renderResult.queryByTestId('backToOrigin'); - expect(button).toBeNull(); - }); - }); -}); 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 deleted file mode 100644 index b982c260f9ca8..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx +++ /dev/null @@ -1,339 +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, { memo, useCallback, useMemo, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { Dispatch } from 'redux'; -import { useHistory, useLocation } from 'react-router-dom'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiButton, EuiSpacer, EuiText } from '@elastic/eui'; -import styled from 'styled-components'; - -import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { AppAction } from '../../../../common/store/actions'; -import { getEventFiltersListPath } from '../../../common/routing'; -import { AdministrationListPage as _AdministrationListPage } from '../../../components/administration_list_page'; - -import { EventFiltersListEmptyState } from './components/empty'; -import { useEventFiltersNavigateCallback, useEventFiltersSelector } from './hooks'; -import { EventFiltersFlyout } from './components/flyout'; -import { - getListFetchError, - getListIsLoading, - getListItems, - getListPagination, - getCurrentLocation, - getListPageDoesDataExist, - getActionError, - getFormEntry, - showDeleteModal, - getTotalCountListItems, -} from '../store/selector'; -import { PaginatedContent, PaginatedContentProps } from '../../../components/paginated_content'; -import { Immutable, ListPageRouteState } from '../../../../../common/endpoint/types'; -import { ExceptionItem } from '../../../../common/components/exceptions/viewer/exception_item'; -import { - AnyArtifact, - ArtifactEntryCard, - ArtifactEntryCardProps, -} from '../../../components/artifact_entry_card'; -import { EventFilterDeleteModal } from './components/event_filter_delete_modal'; - -import { SearchExceptions } from '../../../components/search_exceptions'; -import { BackToExternalAppSecondaryButton } from '../../../components/back_to_external_app_secondary_button'; -import { BackToExternalAppButton } from '../../../components/back_to_external_app_button'; -import { ABOUT_EVENT_FILTERS } from './translations'; -import { useGetEndpointSpecificPolicies } from '../../../services/policies/hooks'; -import { useToasts } from '../../../../common/lib/kibana'; -import { getLoadPoliciesError } from '../../../common/translations'; -import { useEndpointPoliciesToArtifactPolicies } from '../../../components/artifact_entry_card/hooks/use_endpoint_policies_to_artifact_policies'; -import { ManagementPageLoader } from '../../../components/management_page_loader'; -import { useMemoizedRouteState } from '../../../common/hooks'; - -type ArtifactEntryCardType = typeof ArtifactEntryCard; - -type EventListPaginatedContent = PaginatedContentProps< - Immutable, - typeof ExceptionItem ->; - -const AdministrationListPage = styled(_AdministrationListPage)` - .event-filter-container > * { - margin-bottom: ${({ theme }) => theme.eui.euiSizeL}; - - &:last-child { - margin-bottom: 0; - } - } -`; - -const EDIT_EVENT_FILTER_ACTION_LABEL = i18n.translate( - 'xpack.securitySolution.eventFilters.list.cardAction.edit', - { - defaultMessage: 'Edit event filter', - } -); - -const DELETE_EVENT_FILTER_ACTION_LABEL = i18n.translate( - 'xpack.securitySolution.eventFilters.list.cardAction.delete', - { - defaultMessage: 'Delete event filter', - } -); - -export const EventFiltersListPage = memo(() => { - const { state: routeState } = useLocation(); - const history = useHistory(); - const dispatch = useDispatch>(); - const toasts = useToasts(); - const isActionError = useEventFiltersSelector(getActionError); - const formEntry = useEventFiltersSelector(getFormEntry); - const listItems = useEventFiltersSelector(getListItems); - const totalCountListItems = useEventFiltersSelector(getTotalCountListItems); - const pagination = useEventFiltersSelector(getListPagination); - const isLoading = useEventFiltersSelector(getListIsLoading); - const fetchError = useEventFiltersSelector(getListFetchError); - const location = useEventFiltersSelector(getCurrentLocation); - const doesDataExist = useEventFiltersSelector(getListPageDoesDataExist); - const showDelete = useEventFiltersSelector(showDeleteModal); - - const navigateCallback = useEventFiltersNavigateCallback(); - const showFlyout = !!location.show; - - const memoizedRouteState = useMemoizedRouteState(routeState); - - const backButtonEmptyComponent = useMemo(() => { - if (memoizedRouteState && memoizedRouteState.onBackButtonNavigateTo) { - return ; - } - }, [memoizedRouteState]); - - const backButtonHeaderComponent = useMemo(() => { - if (memoizedRouteState && memoizedRouteState.onBackButtonNavigateTo) { - return ; - } - }, [memoizedRouteState]); - - // load the list of policies - const policiesRequest = useGetEndpointSpecificPolicies({ - perPage: 1000, - onError: (err) => { - toasts.addDanger(getLoadPoliciesError(err)); - }, - }); - - const artifactCardPolicies = useEndpointPoliciesToArtifactPolicies(policiesRequest.data?.items); - - // Clean url params if wrong - useEffect(() => { - if ((location.show === 'edit' && !location.id) || (location.show === 'create' && !!location.id)) - navigateCallback({ - show: 'create', - id: undefined, - }); - }, [location, navigateCallback]); - - // Catch fetch error -> actionError + empty entry in form - useEffect(() => { - if (isActionError && !formEntry) { - // Replace the current URL route so that user does not keep hitting this page via browser back/fwd buttons - history.replace( - getEventFiltersListPath({ - ...location, - show: undefined, - id: undefined, - }) - ); - dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'UninitialisedResourceState', - }, - }); - } - }, [dispatch, formEntry, history, isActionError, location, navigateCallback]); - - const handleAddButtonClick = useCallback( - () => - navigateCallback({ - show: 'create', - id: undefined, - }), - [navigateCallback] - ); - - const handleCancelButtonClick = useCallback( - () => - navigateCallback({ - show: undefined, - id: undefined, - }), - [navigateCallback] - ); - - const handlePaginatedContentChange: EventListPaginatedContent['onChange'] = useCallback( - ({ pageIndex, pageSize }) => { - navigateCallback({ - page_index: pageIndex, - page_size: pageSize, - }); - }, - [navigateCallback] - ); - - const handleOnSearch = useCallback( - (query: string, includedPolicies?: string) => { - dispatch({ type: 'eventFiltersForceRefresh', payload: { forceRefresh: true } }); - navigateCallback({ filter: query, included_policies: includedPolicies }); - }, - [navigateCallback, dispatch] - ); - - const artifactCardPropsPerItem = useMemo(() => { - const cachedCardProps: Record = {}; - - // Casting `listItems` below to remove the `Immutable<>` from it in order to prevent errors - // with common component's props - for (const eventFilter of listItems as ExceptionListItemSchema[]) { - cachedCardProps[eventFilter.id] = { - item: eventFilter as AnyArtifact, - policies: artifactCardPolicies, - 'data-test-subj': 'eventFilterCard', - actions: [ - { - icon: 'controlsHorizontal', - onClick: () => { - history.push( - getEventFiltersListPath({ - ...location, - show: 'edit', - id: eventFilter.id, - }) - ); - }, - 'data-test-subj': 'editEventFilterAction', - children: EDIT_EVENT_FILTER_ACTION_LABEL, - }, - { - icon: 'trash', - onClick: () => { - dispatch({ - type: 'eventFilterForDeletion', - payload: eventFilter, - }); - }, - 'data-test-subj': 'deleteEventFilterAction', - children: DELETE_EVENT_FILTER_ACTION_LABEL, - }, - ], - hideDescription: !eventFilter.description, - hideComments: !eventFilter.comments.length, - }; - } - - return cachedCardProps; - }, [artifactCardPolicies, dispatch, history, listItems, location]); - - const handleArtifactCardProps = useCallback( - (eventFilter: ExceptionListItemSchema) => { - return artifactCardPropsPerItem[eventFilter.id]; - }, - [artifactCardPropsPerItem] - ); - - if (isLoading && !doesDataExist) { - return ; - } - - return ( - - } - subtitle={ABOUT_EVENT_FILTERS} - actions={ - doesDataExist && ( - - - - ) - } - hideHeader={!doesDataExist} - > - {showFlyout && ( - - )} - - {showDelete && } - - {doesDataExist && ( - <> - - - - - - - - )} - - - items={listItems} - ItemComponent={ArtifactEntryCard} - itemComponentProps={handleArtifactCardProps} - onChange={handlePaginatedContentChange} - error={fetchError?.message} - loading={isLoading} - pagination={pagination} - contentClassName="event-filter-container" - data-test-subj="eventFiltersContent" - noItemsMessage={ - !doesDataExist && ( - - ) - } - /> - - ); -}); - -EventFiltersListPage.displayName = 'EventFiltersListPage'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/hooks.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/hooks.ts deleted file mode 100644 index e48f11c7f8bae..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/hooks.ts +++ /dev/null @@ -1,78 +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 { useState, useCallback } from 'react'; -import { useSelector } from 'react-redux'; -import { useHistory } from 'react-router-dom'; - -import { - isCreationSuccessful, - getFormEntryStateMutable, - getActionError, - getCurrentLocation, -} from '../store/selector'; - -import { useToasts } from '../../../../common/lib/kibana'; -import { - getCreationSuccessMessage, - getUpdateSuccessMessage, - getCreationErrorMessage, - getUpdateErrorMessage, - getGetErrorMessage, -} from './translations'; - -import { State } from '../../../../common/store'; -import { EventFiltersListPageState, EventFiltersPageLocation } from '../types'; -import { getEventFiltersListPath } from '../../../common/routing'; - -import { - MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE as EVENT_FILTER_NS, - MANAGEMENT_STORE_GLOBAL_NAMESPACE as GLOBAL_NS, -} from '../../../common/constants'; - -export function useEventFiltersSelector(selector: (state: EventFiltersListPageState) => R): R { - return useSelector((state: State) => - selector(state[GLOBAL_NS][EVENT_FILTER_NS] as EventFiltersListPageState) - ); -} - -export const useEventFiltersNotification = () => { - const creationSuccessful = useEventFiltersSelector(isCreationSuccessful); - const actionError = useEventFiltersSelector(getActionError); - const formEntry = useEventFiltersSelector(getFormEntryStateMutable); - const toasts = useToasts(); - const [wasAlreadyHandled] = useState(new WeakSet()); - - if (creationSuccessful && formEntry && !wasAlreadyHandled.has(formEntry)) { - wasAlreadyHandled.add(formEntry); - if (formEntry.item_id) { - toasts.addSuccess(getUpdateSuccessMessage(formEntry)); - } else { - toasts.addSuccess(getCreationSuccessMessage(formEntry)); - } - } else if (actionError && !wasAlreadyHandled.has(actionError)) { - wasAlreadyHandled.add(actionError); - if (formEntry && formEntry.item_id) { - toasts.addDanger(getUpdateErrorMessage(actionError)); - } else if (formEntry) { - toasts.addDanger(getCreationErrorMessage(actionError)); - } else { - toasts.addWarning(getGetErrorMessage(actionError)); - } - } -}; - -export function useEventFiltersNavigateCallback() { - const location = useEventFiltersSelector(getCurrentLocation); - const history = useHistory(); - - return useCallback( - (args: Partial) => - history.push(getEventFiltersListPath({ ...location, ...args })), - [history, location] - ); -} diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/translations.ts index 6177fb7822c92..db6908f2baa8d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/translations.ts @@ -5,47 +5,20 @@ * 2.0. */ +import { HttpFetchError } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; +import { ArtifactFormComponentProps } from '../../../components/artifact_list_page'; -import { ServerApiError } from '../../../../common/types'; -import { EventFiltersForm } from '../types'; - -export const getCreationSuccessMessage = (entry: EventFiltersForm['entry']) => { - return i18n.translate('xpack.securitySolution.eventFilter.form.creationSuccessToastTitle', { +export const getCreationSuccessMessage = (item: ArtifactFormComponentProps['item']) => { + return i18n.translate('xpack.securitySolution.eventFilter.flyoutForm.creationSuccessToastTitle', { defaultMessage: '"{name}" has been added to the event filters list.', - values: { name: entry?.name }, - }); -}; - -export const getUpdateSuccessMessage = (entry: EventFiltersForm['entry']) => { - return i18n.translate('xpack.securitySolution.eventFilter.form.updateSuccessToastTitle', { - defaultMessage: '"{name}" has been updated successfully.', - values: { name: entry?.name }, - }); -}; - -export const getCreationErrorMessage = (creationError: ServerApiError) => { - return i18n.translate('xpack.securitySolution.eventFilter.form.failedToastTitle.create', { - defaultMessage: 'There was an error creating the new event filter: "{error}"', - values: { error: creationError.message }, - }); -}; - -export const getUpdateErrorMessage = (updateError: ServerApiError) => { - return i18n.translate('xpack.securitySolution.eventFilter.form.failedToastTitle.update', { - defaultMessage: 'There was an error updating the event filter: "{error}"', - values: { error: updateError.message }, + values: { name: item?.name }, }); }; -export const getGetErrorMessage = (getError: ServerApiError) => { - return i18n.translate('xpack.securitySolution.eventFilter.form.failedToastTitle.get', { - defaultMessage: 'Unable to edit event filter: "{error}"', - values: { error: getError.message }, - }); +export const getCreationErrorMessage = (creationError: HttpFetchError) => { + return { + title: 'There was an error creating the new event filter: "{error}"', + message: { error: creationError.message }, + }; }; - -export const ABOUT_EVENT_FILTERS = i18n.translate('xpack.securitySolution.eventFilters.aboutInfo', { - defaultMessage: - 'Add an event filter to exclude high volume or unwanted events from being written to Elasticsearch.', -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/use_event_filters_notification.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/use_event_filters_notification.test.tsx deleted file mode 100644 index 7643125c587e7..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/use_event_filters_notification.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 React from 'react'; -import { Provider } from 'react-redux'; -import { renderHook, act } from '@testing-library/react-hooks'; - -import { NotificationsStart } from '@kbn/core/public'; -import { coreMock } from '@kbn/core/public/mocks'; -import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public/context'; -import type { - CreateExceptionListItemSchema, - ExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; - -import { - createdEventFilterEntryMock, - createGlobalNoMiddlewareStore, - ecsEventMock, -} from '../test_utils'; -import { useEventFiltersNotification } from './hooks'; -import { - getCreationErrorMessage, - getCreationSuccessMessage, - getGetErrorMessage, - getUpdateSuccessMessage, - getUpdateErrorMessage, -} from './translations'; -import { getInitialExceptionFromEvent } from '../store/utils'; -import { - getLastLoadedResourceState, - FailedResourceState, -} from '../../../state/async_resource_state'; - -const mockNotifications = () => coreMock.createStart({ basePath: '/mock' }).notifications; - -const renderNotifications = ( - store: ReturnType, - notifications: NotificationsStart -) => { - const Wrapper: React.FC = ({ children }) => ( - - {children} - - ); - return renderHook(useEventFiltersNotification, { wrapper: Wrapper }); -}; - -describe('EventFiltersNotification', () => { - it('renders correctly initially', () => { - const notifications = mockNotifications(); - - renderNotifications(createGlobalNoMiddlewareStore(), notifications); - - expect(notifications.toasts.addSuccess).not.toBeCalled(); - expect(notifications.toasts.addDanger).not.toBeCalled(); - }); - - it('shows success notification when creation successful', () => { - const store = createGlobalNoMiddlewareStore(); - const notifications = mockNotifications(); - - renderNotifications(store, notifications); - - act(() => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - }); - - act(() => { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadedResourceState', - data: store.getState().management.eventFilters.form.entry as ExceptionListItemSchema, - }, - }); - }); - - expect(notifications.toasts.addSuccess).toBeCalledWith( - getCreationSuccessMessage( - store.getState().management.eventFilters.form.entry as CreateExceptionListItemSchema - ) - ); - expect(notifications.toasts.addDanger).not.toBeCalled(); - }); - - it('shows success notification when update successful', () => { - const store = createGlobalNoMiddlewareStore(); - const notifications = mockNotifications(); - - renderNotifications(store, notifications); - - act(() => { - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: createdEventFilterEntryMock() }, - }); - }); - - act(() => { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadedResourceState', - data: store.getState().management.eventFilters.form.entry as ExceptionListItemSchema, - }, - }); - }); - - expect(notifications.toasts.addSuccess).toBeCalledWith( - getUpdateSuccessMessage( - store.getState().management.eventFilters.form.entry as CreateExceptionListItemSchema - ) - ); - expect(notifications.toasts.addDanger).not.toBeCalled(); - }); - - it('shows error notification when creation fails', () => { - const store = createGlobalNoMiddlewareStore(); - const notifications = mockNotifications(); - - renderNotifications(store, notifications); - - act(() => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - }); - - act(() => { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'FailedResourceState', - error: { message: 'error message', statusCode: 500, error: 'error' }, - lastLoadedState: getLastLoadedResourceState( - store.getState().management.eventFilters.form.submissionResourceState - ), - }, - }); - }); - - expect(notifications.toasts.addSuccess).not.toBeCalled(); - expect(notifications.toasts.addDanger).toBeCalledWith( - getCreationErrorMessage( - ( - store.getState().management.eventFilters.form - .submissionResourceState as FailedResourceState - ).error - ) - ); - }); - - it('shows error notification when update fails', () => { - const store = createGlobalNoMiddlewareStore(); - const notifications = mockNotifications(); - - renderNotifications(store, notifications); - - act(() => { - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: createdEventFilterEntryMock() }, - }); - }); - - act(() => { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'FailedResourceState', - error: { message: 'error message', statusCode: 500, error: 'error' }, - lastLoadedState: getLastLoadedResourceState( - store.getState().management.eventFilters.form.submissionResourceState - ), - }, - }); - }); - - expect(notifications.toasts.addSuccess).not.toBeCalled(); - expect(notifications.toasts.addDanger).toBeCalledWith( - getUpdateErrorMessage( - ( - store.getState().management.eventFilters.form - .submissionResourceState as FailedResourceState - ).error - ) - ); - }); - - it('shows error notification when get fails', () => { - const store = createGlobalNoMiddlewareStore(); - const notifications = mockNotifications(); - - renderNotifications(store, notifications); - - act(() => { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'FailedResourceState', - error: { message: 'error message', statusCode: 500, error: 'error' }, - lastLoadedState: getLastLoadedResourceState( - store.getState().management.eventFilters.form.submissionResourceState - ), - }, - }); - }); - - expect(notifications.toasts.addSuccess).not.toBeCalled(); - expect(notifications.toasts.addWarning).toBeCalledWith( - getGetErrorMessage( - ( - store.getState().management.eventFilters.form - .submissionResourceState as FailedResourceState - ).error - ) - ); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/utils.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/utils.ts similarity index 100% rename from x-pack/plugins/security_solution/public/management/pages/event_filters/store/utils.ts rename to x-pack/plugins/security_solution/public/management/pages/event_filters/view/utils.ts diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx index c30b5a8887338..11772324ff51c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx @@ -5,7 +5,10 @@ * 2.0. */ -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { + CreateExceptionListSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; import { act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -16,17 +19,26 @@ import { createAppRootMockRenderer, } from '../../../../../../common/mock/endpoint'; import { PolicyArtifactsDeleteModal } from './policy_artifacts_delete_modal'; -import { eventFiltersListQueryHttpMock } from '../../../../event_filters/test_utils'; -import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { exceptionsListAllHttpMocks } from '../../../../mocks/exceptions_list_http_mocks'; +import { ExceptionsListApiClient } from '../../../../../services/exceptions_list/exceptions_list_api_client'; import { POLICY_ARTIFACT_DELETE_MODAL_LABELS } from './translations'; -describe('Policy details artifacts delete modal', () => { +const listType: Array = [ + 'endpoint_events', + 'detection', + 'endpoint', + 'endpoint_trusted_apps', + 'endpoint_host_isolation_exceptions', + 'endpoint_blocklists', +]; + +describe.each(listType)('Policy details %s artifact delete modal', (type) => { let policyId: string; let render: () => Promise>; let renderResult: ReturnType; let mockedContext: AppContextTestRender; let exception: ExceptionListItemSchema; - let mockedApi: ReturnType; + let mockedApi: ReturnType; let onCloseMock: () => jest.Mock; beforeEach(() => { @@ -34,20 +46,30 @@ describe('Policy details artifacts delete modal', () => { mockedContext = createAppRootMockRenderer(); exception = getExceptionListItemSchemaMock(); onCloseMock = jest.fn(); - mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); + mockedApi = exceptionsListAllHttpMocks(mockedContext.coreStart.http); render = async () => { await act(async () => { renderResult = mockedContext.render( ); - await waitFor(mockedApi.responseProvider.eventFiltersList); + + mockedApi.responseProvider.exceptionsFind.mockReturnValue({ + data: [], + total: 0, + page: 1, + per_page: 10, + }); }); return renderResult; }; @@ -75,9 +97,9 @@ describe('Policy details artifacts delete modal', () => { const confirmButton = renderResult.getByTestId('confirmModalConfirmButton'); userEvent.click(confirmButton); await waitFor(() => { - expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenLastCalledWith({ + expect(mockedApi.responseProvider.exceptionUpdate).toHaveBeenLastCalledWith({ body: JSON.stringify( - EventFiltersApiClient.cleanExceptionsBeforeUpdate({ + ExceptionsListApiClient.cleanExceptionsBeforeUpdate({ ...exception, tags: ['policy:1234', 'policy:4321', 'not-a-policy-tag'], }) @@ -93,7 +115,7 @@ describe('Policy details artifacts delete modal', () => { userEvent.click(confirmButton); await waitFor(() => { - expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenCalled(); + expect(mockedApi.responseProvider.exceptionUpdate).toHaveBeenCalled(); }); expect(onCloseMock).toHaveBeenCalled(); @@ -102,7 +124,7 @@ describe('Policy details artifacts delete modal', () => { it('should show an error toast if the operation failed', async () => { const error = new Error('the server is too far away'); - mockedApi.responseProvider.eventFiltersUpdateOne.mockImplementation(() => { + mockedApi.responseProvider.exceptionUpdate.mockImplementation(() => { throw error; }); @@ -111,7 +133,7 @@ describe('Policy details artifacts delete modal', () => { userEvent.click(confirmButton); await waitFor(() => { - expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenCalled(); + expect(mockedApi.responseProvider.exceptionUpdate).toHaveBeenCalled(); }); expect(mockedContext.coreStart.notifications.toasts.addError).toHaveBeenCalledWith(error, { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx index edf9f5b21d8b4..056a8daa92d3a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx @@ -27,7 +27,7 @@ import { UpdateExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { cleanEventFilterToUpdate } from '../../../../event_filters/service/service_actions'; -import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../event_filters/service/api_client'; import { POLICY_ARTIFACT_FLYOUT_LABELS } from './translations'; const getDefaultQueryParameters = (customFilter: string | undefined = '') => ({ diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx index 453c84f63689e..67452fd11df53 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx @@ -24,7 +24,7 @@ import { eventFiltersListQueryHttpMock } from '../../../../event_filters/test_ut import { getFoundExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/found_exception_list_item_schema.mock'; import { getEndpointPrivilegesInitialStateMock } from '../../../../../../common/components/user_privileges/endpoint/mocks'; import { POLICY_ARTIFACT_EVENT_FILTERS_LABELS } from '../../tabs/event_filters_translations'; -import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../event_filters/service/api_client'; import { SEARCHABLE_FIELDS as EVENT_FILTERS_SEARCHABLE_FIELDS } from '../../../../event_filters/constants'; import { FormattedMessage } from '@kbn/i18n-react'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx index de2f245a9c098..b3c104b27977f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx @@ -22,7 +22,7 @@ import { parseQueryFilterToKQL, parsePoliciesAndFilterToKql } from '../../../../ import { SEARCHABLE_FIELDS } from '../../../../event_filters/constants'; import { getEndpointPrivilegesInitialStateMock } from '../../../../../../common/components/user_privileges/endpoint/mocks'; import { POLICY_ARTIFACT_LIST_LABELS } from './translations'; -import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../event_filters/service/api_client'; const endpointGenerator = new EndpointDocGenerator('seed'); const getDefaultQueryParameters = (customFilter: string | undefined = '') => ({ diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx index 87860db1fe69d..16b5e9f975e22 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx @@ -15,7 +15,7 @@ import { getEventFiltersListPath } from '../../../../../../common/routing'; import { eventFiltersListQueryHttpMock } from '../../../../../event_filters/test_utils'; import { getEndpointPrivilegesInitialStateMock } from '../../../../../../../common/components/user_privileges/endpoint/mocks'; import { useToasts } from '../../../../../../../common/lib/kibana'; -import { EventFiltersApiClient } from '../../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../../event_filters/service/api_client'; import { FleetArtifactsCard } from './fleet_artifacts_card'; import { EVENT_FILTERS_LABELS } from '..'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx index c88f54f01fd2b..b8724850e1188 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx @@ -19,7 +19,7 @@ import { EndpointDocGenerator } from '../../../../../../../../common/endpoint/ge import { getPolicyEventFiltersPath } from '../../../../../../common/routing'; import { PolicyData } from '../../../../../../../../common/endpoint/types'; import { getSummaryExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_summary_schema.mock'; -import { EventFiltersApiClient } from '../../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../../event_filters/service/api_client'; import { SEARCHABLE_FIELDS } from '../../../../../event_filters/constants'; import { EVENT_FILTERS_LABELS } from '../../endpoint_policy_edit_extension'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/endpoint_package_custom_extension.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/endpoint_package_custom_extension.tsx index 72cc9852b0e7d..f1af7c3505297 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/endpoint_package_custom_extension.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/endpoint_package_custom_extension.tsx @@ -11,7 +11,7 @@ import { PackageCustomExtensionComponentProps } from '@kbn/fleet-plugin/public'; import { useHttp } from '../../../../../../common/lib/kibana'; import { useCanSeeHostIsolationExceptionsMenu } from '../../../../host_isolation_exceptions/view/hooks'; import { TrustedAppsApiClient } from '../../../../trusted_apps/service/api_client'; -import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../event_filters/service/api_client'; import { HostIsolationExceptionsApiClient } from '../../../../host_isolation_exceptions/host_isolation_exceptions_api_client'; import { BlocklistsApiClient } from '../../../../blocklist/services'; import { FleetArtifactsCard } from './components/fleet_artifacts_card'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx index dfb2677ecb594..9ac612aec05ed 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx @@ -35,7 +35,7 @@ import { useUserPrivileges } from '../../../../../common/components/user_privile import { FleetIntegrationArtifactsCard } from './endpoint_package_custom_extension/components/fleet_integration_artifacts_card'; import { BlocklistsApiClient } from '../../../blocklist/services'; import { HostIsolationExceptionsApiClient } from '../../../host_isolation_exceptions/host_isolation_exceptions_api_client'; -import { EventFiltersApiClient } from '../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../event_filters/service/api_client'; import { TrustedAppsApiClient } from '../../../trusted_apps/service/api_client'; import { SEARCHABLE_FIELDS as BLOCKLIST_SEARCHABLE_FIELDS } from '../../../blocklist/constants'; import { SEARCHABLE_FIELDS as HOST_ISOLATION_EXCEPTIONS_SEARCHABLE_FIELDS } from '../../../host_isolation_exceptions/constants'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx index f3a20a1abfd66..f81b55b5e8a31 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx @@ -42,7 +42,7 @@ import { POLICY_ARTIFACT_TRUSTED_APPS_LABELS } from './trusted_apps_translations import { POLICY_ARTIFACT_HOST_ISOLATION_EXCEPTIONS_LABELS } from './host_isolation_exceptions_translations'; import { POLICY_ARTIFACT_BLOCKLISTS_LABELS } from './blocklists_translations'; import { TrustedAppsApiClient } from '../../../trusted_apps/service/api_client'; -import { EventFiltersApiClient } from '../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../event_filters/service/api_client'; import { BlocklistsApiClient } from '../../../blocklist/services/blocklists_api_client'; import { HostIsolationExceptionsApiClient } from '../../../host_isolation_exceptions/host_isolation_exceptions_api_client'; import { SEARCHABLE_FIELDS as TRUSTED_APPS_SEARCHABLE_FIELDS } from '../../../trusted_apps/constants'; diff --git a/x-pack/plugins/security_solution/public/management/store/middleware.ts b/x-pack/plugins/security_solution/public/management/store/middleware.ts index 86a5ade340058..475fe0bc9bb7c 100644 --- a/x-pack/plugins/security_solution/public/management/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/store/middleware.ts @@ -14,11 +14,9 @@ import { MANAGEMENT_STORE_ENDPOINTS_NAMESPACE, MANAGEMENT_STORE_GLOBAL_NAMESPACE, MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, - MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE, } from '../common/constants'; import { policyDetailsMiddlewareFactory } from '../pages/policy/store/policy_details'; import { endpointMiddlewareFactory } from '../pages/endpoint_hosts/store/middleware'; -import { eventFiltersPageMiddlewareFactory } from '../pages/event_filters/store/middleware'; type ManagementSubStateKey = keyof State[typeof MANAGEMENT_STORE_GLOBAL_NAMESPACE]; @@ -40,10 +38,5 @@ export const managementMiddlewareFactory: SecuritySubPluginMiddlewareFactory = ( createSubStateSelector(MANAGEMENT_STORE_ENDPOINTS_NAMESPACE), endpointMiddlewareFactory(coreStart, depsStart) ), - - substateMiddlewareFactory( - createSubStateSelector(MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE), - eventFiltersPageMiddlewareFactory(coreStart, depsStart) - ), ]; }; diff --git a/x-pack/plugins/security_solution/public/management/store/reducer.ts b/x-pack/plugins/security_solution/public/management/store/reducer.ts index 2fd20129ddca8..678819a51d747 100644 --- a/x-pack/plugins/security_solution/public/management/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/store/reducer.ts @@ -13,14 +13,11 @@ import { import { MANAGEMENT_STORE_ENDPOINTS_NAMESPACE, MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, - MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE, } from '../common/constants'; import { ImmutableCombineReducers } from '../../common/store'; import { Immutable } from '../../../common/endpoint/types'; import { ManagementState } from '../types'; import { endpointListReducer } from '../pages/endpoint_hosts/store/reducer'; -import { initialEventFiltersPageState } from '../pages/event_filters/store/builders'; -import { eventFiltersPageReducer } from '../pages/event_filters/store/reducer'; import { initialEndpointPageState } from '../pages/endpoint_hosts/store/builders'; const immutableCombineReducers: ImmutableCombineReducers = combineReducers; @@ -31,7 +28,6 @@ const immutableCombineReducers: ImmutableCombineReducers = combineReducers; export const mockManagementState: Immutable = { [MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]: initialPolicyDetailsState(), [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: initialEndpointPageState(), - [MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE]: initialEventFiltersPageState(), }; /** @@ -40,5 +36,4 @@ export const mockManagementState: Immutable = { export const managementReducer = immutableCombineReducers({ [MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]: policyDetailsReducer, [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: endpointListReducer, - [MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE]: eventFiltersPageReducer, }); diff --git a/x-pack/plugins/security_solution/public/management/types.ts b/x-pack/plugins/security_solution/public/management/types.ts index 0ad0f2e757c00..f1cb7b2623b39 100644 --- a/x-pack/plugins/security_solution/public/management/types.ts +++ b/x-pack/plugins/security_solution/public/management/types.ts @@ -9,7 +9,6 @@ import { CombinedState } from 'redux'; import { SecurityPageName } from '../app/types'; import { PolicyDetailsState } from './pages/policy/types'; import { EndpointState } from './pages/endpoint_hosts/types'; -import { EventFiltersListPageState } from './pages/event_filters/types'; /** * The type for the management store global namespace. Used mostly internally to reference @@ -20,7 +19,6 @@ export type ManagementStoreGlobalNamespace = 'management'; export type ManagementState = CombinedState<{ policyDetails: PolicyDetailsState; endpoints: EndpointState; - eventFilters: EventFiltersListPageState; }>; /** diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx index 0f3bb6e7177bd..86a8047b3ad76 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx @@ -14,7 +14,7 @@ import type { TimelineEventsDetailsItem } from '../../../../../common/search_str import { TimelineId } from '../../../../../common/types'; import { useExceptionFlyout } from '../../../../detections/components/alerts_table/timeline_actions/use_add_exception_flyout'; import { AddExceptionFlyoutWrapper } from '../../../../detections/components/alerts_table/timeline_actions/alert_context_menu'; -import { EventFiltersFlyout } from '../../../../management/pages/event_filters/view/components/flyout'; +import { EventFiltersFlyout } from '../../../../management/pages/event_filters/view/components/event_filters_flyout'; import { useEventFilterModal } from '../../../../detections/components/alerts_table/timeline_actions/use_event_filter_modal'; import { getFieldValue } from '../../../../detections/components/host_isolation/helpers'; import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index a9d350146c0d9..70d3a81a2f808 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -25469,52 +25469,22 @@ "xpack.securitySolution.eventDetails.viewAllFields": "Afficher tous les champs dans le tableau", "xpack.securitySolution.eventDetails.viewColumnCheckboxAriaLabel": "Afficher la colonne {field}", "xpack.securitySolution.eventDetails.viewRuleDetailPage": "Afficher la page Détails de la règle", - "xpack.securitySolution.eventFilter.form.creationSuccessToastTitle": "\"{name}\" a été ajouté à la liste de filtres d'événements.", - "xpack.securitySolution.eventFilter.form.description.label": "Décrivez votre filtre d'événement", "xpack.securitySolution.eventFilter.form.description.placeholder": "Description", - "xpack.securitySolution.eventFilter.form.failedToastTitle.create": "Une erreur est survenue lors de la création du nouveau filtre d'événement : \"{error}\"", - "xpack.securitySolution.eventFilter.form.failedToastTitle.get": "Impossible de modifier le filtre d'événement : \"{error}\"", - "xpack.securitySolution.eventFilter.form.failedToastTitle.update": "Une erreur est survenue lors de la mise à jour du filtre d'événement : \"{error}\"", "xpack.securitySolution.eventFilter.form.name.error": "Le nom doit être renseigné", "xpack.securitySolution.eventFilter.form.name.label": "Nommer votre filtre d'événement", - "xpack.securitySolution.eventFilter.form.name.placeholder": "Nom du filtre d'événement", "xpack.securitySolution.eventFilter.form.os.label": "Sélectionner un système d'exploitation", "xpack.securitySolution.eventFilter.form.rule.name": "Filtrage d'événement Endpoint", - "xpack.securitySolution.eventFilter.form.updateSuccessToastTitle": "{name} a été mis à jour avec succès.", - "xpack.securitySolution.eventFilter.search.placeholder": "Rechercher sur les champs ci-dessous : nom, description, commentaires, valeur", "xpack.securitySolution.eventFilters.aboutInfo": "Ajouter un filtre d'événement pour exclure les volumes importants ou les événements non souhaités de l'écriture dans Elasticsearch.", "xpack.securitySolution.eventFilters.commentsSectionDescription": "Ajouter un commentaire à votre filtre d'événement.", "xpack.securitySolution.eventFilters.commentsSectionTitle": "Commentaires", - "xpack.securitySolution.eventFilters.criteriaSectionDescription": "Sélectionnez un système d'exploitation et ajoutez des conditions.", "xpack.securitySolution.eventFilters.criteriaSectionTitle": "Conditions", - "xpack.securitySolution.eventFilters.deletionDialog.calloutMessage": "La suppression de cette entrée entraînera son retrait dans {count} {count, plural, one {politique associée} other {politiques associées}}.", - "xpack.securitySolution.eventFilters.deletionDialog.calloutTitle": "Avertissement", - "xpack.securitySolution.eventFilters.deletionDialog.cancelButton": "Annuler", - "xpack.securitySolution.eventFilters.deletionDialog.confirmButton": "Supprimer", - "xpack.securitySolution.eventFilters.deletionDialog.deleteFailure": "Impossible de retirer \"{name}\" de la liste de filtres d'événements. Raison : {message}", - "xpack.securitySolution.eventFilters.deletionDialog.deleteSuccess": "\"{name}\" a été retiré de la liste de filtres d'événements.", - "xpack.securitySolution.eventFilters.deletionDialog.subMessage": "Cette action ne peut pas être annulée. Voulez-vous vraiment continuer ?", - "xpack.securitySolution.eventFilters.deletionDialog.title": "Supprimer \"{name}\"", "xpack.securitySolution.eventFilters.detailsSectionTitle": "Détails", - "xpack.securitySolution.eventFilters.docsLink": "Documentation relative aux filtres d'événements.", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.cancel": "Annuler", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.create": "Ajouter un filtre d'événement", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update": "Enregistrer", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update.withData": "Ajouter un filtre d'événement de point de terminaison", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create": "Ajouter un filtre d'événement", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create.withData": "Endpoint Security", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.update": "Mettre à jour le filtre d'événement", "xpack.securitySolution.eventFilters.eventFiltersFlyout.title.create.withData": "Ajouter un filtre d'événement de point de terminaison", - "xpack.securitySolution.eventFilters.expiredLicenseMessage": "Votre licence Kibana est passée à une version inférieure. Les futures configurations de politiques seront désormais globalement affectées à toutes les politiques. Pour en savoir plus, consultez notre ", - "xpack.securitySolution.eventFilters.expiredLicenseTitle": "Licence expirée", - "xpack.securitySolution.eventFilters.list.cardAction.delete": "Supprimer le filtre d'événement", - "xpack.securitySolution.eventFilters.list.cardAction.edit": "Modifier le filtre d'événement", - "xpack.securitySolution.eventFilters.list.pageAddButton": "Ajouter un filtre d'événement", - "xpack.securitySolution.eventFilters.list.pageTitle": "Filtres d'événements", - "xpack.securitySolution.eventFilters.list.totalCount": "Affichage de {total, plural, one {# filtre d'événement} other {# filtres d'événements}}", - "xpack.securitySolution.eventFilters.listEmpty.addButton": "Ajouter un filtre d'événement", - "xpack.securitySolution.eventFilters.listEmpty.message": "Ajouter un filtre d'événement pour exclure les volumes importants ou les événements non souhaités de l'écriture dans Elasticsearch.", - "xpack.securitySolution.eventFilters.listEmpty.title": "Ajouter votre premier filtre d'événement", "xpack.securitySolution.eventFiltersTab": "Filtres d'événements", "xpack.securitySolution.eventRenderers.alertsDescription": "Les alertes sont affichées lorsqu'un malware ou ransomware est bloqué ou détecté", "xpack.securitySolution.eventRenderers.alertsName": "Alertes", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 89813c1104606..a20feeeccdb1b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -25619,52 +25619,22 @@ "xpack.securitySolution.eventDetails.viewAllFields": "テーブルのすべてのフィールドを表示", "xpack.securitySolution.eventDetails.viewColumnCheckboxAriaLabel": "{field} 列を表示", "xpack.securitySolution.eventDetails.viewRuleDetailPage": "ルール詳細ページを表示", - "xpack.securitySolution.eventFilter.form.creationSuccessToastTitle": "\"{name}\"がイベントフィルターリストに追加されました。", - "xpack.securitySolution.eventFilter.form.description.label": "イベントフィルターの説明", "xpack.securitySolution.eventFilter.form.description.placeholder": "説明", - "xpack.securitySolution.eventFilter.form.failedToastTitle.create": "新しいイベントフィルターの作成中にエラーが発生しました:\"{error}\"", - "xpack.securitySolution.eventFilter.form.failedToastTitle.get": "イベントフィルターを編集できません:\"{error}\"", - "xpack.securitySolution.eventFilter.form.failedToastTitle.update": "イベントフィルターの更新中にエラーが発生しました:\"{error}\"", "xpack.securitySolution.eventFilter.form.name.error": "名前を空にすることはできません", "xpack.securitySolution.eventFilter.form.name.label": "イベントフィルターの名前を付ける", - "xpack.securitySolution.eventFilter.form.name.placeholder": "イベントフィルター名", "xpack.securitySolution.eventFilter.form.os.label": "オペレーティングシステムを選択", "xpack.securitySolution.eventFilter.form.rule.name": "エンドポイントイベントフィルター", - "xpack.securitySolution.eventFilter.form.updateSuccessToastTitle": "\"{name}\"が正常に更新されました", - "xpack.securitySolution.eventFilter.search.placeholder": "次のフィールドで検索:名前、説明、コメント、値", "xpack.securitySolution.eventFilters.aboutInfo": "イベントフィルターを追加して、大量のイベントや不要なイベントがElasticsearchに書き込まれないように除外します。", "xpack.securitySolution.eventFilters.commentsSectionDescription": "イベントフィルターにコメントを追加します。", "xpack.securitySolution.eventFilters.commentsSectionTitle": "コメント", - "xpack.securitySolution.eventFilters.criteriaSectionDescription": "オペレーティングシステムを選択して、条件を追加します。", "xpack.securitySolution.eventFilters.criteriaSectionTitle": "条件", - "xpack.securitySolution.eventFilters.deletionDialog.calloutMessage": "このエントリを削除すると、{count}個の関連付けられた{count, plural, other {ポリシー}}から削除されます。", - "xpack.securitySolution.eventFilters.deletionDialog.calloutTitle": "警告", - "xpack.securitySolution.eventFilters.deletionDialog.cancelButton": "キャンセル", - "xpack.securitySolution.eventFilters.deletionDialog.confirmButton": "削除", - "xpack.securitySolution.eventFilters.deletionDialog.deleteFailure": "イベントフィルターリストから\"{name}\"を削除できません。理由:{message}", - "xpack.securitySolution.eventFilters.deletionDialog.deleteSuccess": "\"{name}\"がイベントフィルターリストから削除されました。", - "xpack.securitySolution.eventFilters.deletionDialog.subMessage": "この操作は元に戻すことができません。続行していいですか?", - "xpack.securitySolution.eventFilters.deletionDialog.title": "\"{name}\"を削除", "xpack.securitySolution.eventFilters.detailsSectionTitle": "詳細", - "xpack.securitySolution.eventFilters.docsLink": "イベントフィルタードキュメント。", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.cancel": "キャンセル", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.create": "イベントフィルターを追加", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update": "保存", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update.withData": "エンドポイントイベントフィルターを追加", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create": "イベントフィルターを追加", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create.withData": "Endpoint Security", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.update": "イベントフィルターを更新", "xpack.securitySolution.eventFilters.eventFiltersFlyout.title.create.withData": "エンドポイントイベントフィルターを追加", - "xpack.securitySolution.eventFilters.expiredLicenseMessage": "Kibanaライセンスがダウングレードされました。今後のポリシー構成はグローバルにすべてのポリシーに割り当てられます。詳細はご覧ください。 ", - "xpack.securitySolution.eventFilters.expiredLicenseTitle": "失効したライセンス", - "xpack.securitySolution.eventFilters.list.cardAction.delete": "イベントフィルターを削除", - "xpack.securitySolution.eventFilters.list.cardAction.edit": "イベントフィルターを編集", - "xpack.securitySolution.eventFilters.list.pageAddButton": "イベントフィルターを追加", - "xpack.securitySolution.eventFilters.list.pageTitle": "イベントフィルター", - "xpack.securitySolution.eventFilters.list.totalCount": "{total, plural, other {# 個のイベントフィルター}}を表示中", - "xpack.securitySolution.eventFilters.listEmpty.addButton": "イベントフィルターを追加", - "xpack.securitySolution.eventFilters.listEmpty.message": "イベントフィルターを追加して、大量のイベントや不要なイベントがElasticsearchに書き込まれないように除外します。", - "xpack.securitySolution.eventFilters.listEmpty.title": "最初のイベントフィルターを追加", "xpack.securitySolution.eventFilters.warningMessage.duplicateFields": "同じフィールド値の乗数を使用すると、エンドポイントパフォーマンスが劣化したり、効果的ではないルールが作成されたりすることがあります", "xpack.securitySolution.eventFiltersTab": "イベントフィルター", "xpack.securitySolution.eventRenderers.alertsDescription": "マルウェアまたはランサムウェアが防御、検出されたときにアラートが表示されます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a9278d13031f4..a2c33d9a1fae7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -25652,52 +25652,22 @@ "xpack.securitySolution.eventDetails.viewAllFields": "查看表中的所有字段", "xpack.securitySolution.eventDetails.viewColumnCheckboxAriaLabel": "查看 {field} 列", "xpack.securitySolution.eventDetails.viewRuleDetailPage": "查看规则详情页面", - "xpack.securitySolution.eventFilter.form.creationSuccessToastTitle": "“{name}”已添加到事件筛选列表。", - "xpack.securitySolution.eventFilter.form.description.label": "描述您的事件筛选", "xpack.securitySolution.eventFilter.form.description.placeholder": "描述", - "xpack.securitySolution.eventFilter.form.failedToastTitle.create": "创建新事件筛选时出错:“{error}”", - "xpack.securitySolution.eventFilter.form.failedToastTitle.get": "无法编辑事件筛选:“{error}”", - "xpack.securitySolution.eventFilter.form.failedToastTitle.update": "更新事件筛选时出错:“{error}”", "xpack.securitySolution.eventFilter.form.name.error": "名称不能为空", "xpack.securitySolution.eventFilter.form.name.label": "命名您的事件筛选", - "xpack.securitySolution.eventFilter.form.name.placeholder": "事件筛选名称", "xpack.securitySolution.eventFilter.form.os.label": "选择操作系统", "xpack.securitySolution.eventFilter.form.rule.name": "终端事件筛选", - "xpack.securitySolution.eventFilter.form.updateSuccessToastTitle": "“{name}”已成功更新。", - "xpack.securitySolution.eventFilter.search.placeholder": "搜索下面的字段:name、description、comments、value", "xpack.securitySolution.eventFilters.aboutInfo": "添加事件筛选以阻止高数目或非预期事件写入到 Elasticsearch。", "xpack.securitySolution.eventFilters.commentsSectionDescription": "将注释添加到事件筛选。", "xpack.securitySolution.eventFilters.commentsSectionTitle": "注释", - "xpack.securitySolution.eventFilters.criteriaSectionDescription": "选择操作系统,然后添加条件。", "xpack.securitySolution.eventFilters.criteriaSectionTitle": "条件", - "xpack.securitySolution.eventFilters.deletionDialog.calloutMessage": "删除此条目会将其从 {count} 个关联{count, plural, other {策略}}中移除。", - "xpack.securitySolution.eventFilters.deletionDialog.calloutTitle": "警告", - "xpack.securitySolution.eventFilters.deletionDialog.cancelButton": "取消", - "xpack.securitySolution.eventFilters.deletionDialog.confirmButton": "删除", - "xpack.securitySolution.eventFilters.deletionDialog.deleteFailure": "无法从事件筛选列表中移除“{name}”。原因:{message}", - "xpack.securitySolution.eventFilters.deletionDialog.deleteSuccess": "“{name}”已从事件筛选列表中移除。", - "xpack.securitySolution.eventFilters.deletionDialog.subMessage": "此操作无法撤消。是否确定要继续?", - "xpack.securitySolution.eventFilters.deletionDialog.title": "删除“{name}”", "xpack.securitySolution.eventFilters.detailsSectionTitle": "详情", - "xpack.securitySolution.eventFilters.docsLink": "事件筛选文档。", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.cancel": "取消", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.create": "添加事件筛选", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update": "保存", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update.withData": "添加终端事件筛选", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create": "添加事件筛选", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create.withData": "Endpoint Security", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.update": "更新事件筛选", "xpack.securitySolution.eventFilters.eventFiltersFlyout.title.create.withData": "添加终端事件筛选", - "xpack.securitySolution.eventFilters.expiredLicenseMessage": "您的 Kibana 许可证已降级。现在会将未来的策略配置全局分配给所有策略。有关更多信息,请参见 ", - "xpack.securitySolution.eventFilters.expiredLicenseTitle": "已过期许可证", - "xpack.securitySolution.eventFilters.list.cardAction.delete": "删除事件筛选", - "xpack.securitySolution.eventFilters.list.cardAction.edit": "编辑事件筛选", - "xpack.securitySolution.eventFilters.list.pageAddButton": "添加事件筛选", - "xpack.securitySolution.eventFilters.list.pageTitle": "事件筛选", - "xpack.securitySolution.eventFilters.list.totalCount": "正在显示 {total, plural, other {# 个事件筛选}}", - "xpack.securitySolution.eventFilters.listEmpty.addButton": "添加事件筛选", - "xpack.securitySolution.eventFilters.listEmpty.message": "添加事件筛选以阻止高数目或非预期事件写入到 Elasticsearch。", - "xpack.securitySolution.eventFilters.listEmpty.title": "添加您的首个事件筛选", "xpack.securitySolution.eventFilters.warningMessage.duplicateFields": "使用相同提交值的倍数可能会降低终端性能和/或创建低效规则", "xpack.securitySolution.eventFiltersTab": "事件筛选", "xpack.securitySolution.eventRenderers.alertsDescription": "阻止或检测到恶意软件或勒索软件时,显示告警", From 8de3401dffbe2954b24fd749c34a2c92145a528f Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 19 May 2022 10:12:00 -0600 Subject: [PATCH 36/37] [Controls] Field first control creation (#131461) * Field first *creation* * Field first *editing* * Add support for custom control options * Add i18n * Make field picker accept predicate again + clean up imports * Fix functional tests * Attempt 1 at case sensitivity * Works both ways * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * Clean up code * Use React useMemo to calculate field registry * Fix functional tests * Fix default state + control settings label * Fix functional tests Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../control_types/options_list/types.ts | 1 - src/plugins/controls/common/types.ts | 2 + .../control_group/control_group_strings.ts | 20 + .../control_group/editor/control_editor.tsx | 345 +++++++++++------- .../control_group/editor/create_control.tsx | 28 +- .../control_group/editor/edit_control.tsx | 104 +++--- .../options_list/options_list_editor.tsx | 182 --------- .../options_list_editor_options.tsx | 54 +++ .../options_list/options_list_embeddable.tsx | 14 +- .../options_list_embeddable_factory.tsx | 15 +- .../range_slider/range_slider_editor.tsx | 111 ------ .../range_slider_embeddable_factory.tsx | 9 +- .../time_slider/time_slider_editor.tsx | 110 ------ .../time_slider_embeddable_factory.tsx | 9 +- src/plugins/controls/public/plugin.ts | 5 +- src/plugins/controls/public/types.ts | 29 +- .../controls/control_group_settings.ts | 4 +- .../controls/options_list.ts | 4 +- .../controls/range_slider.ts | 4 +- .../controls/replace_controls.ts | 22 +- .../page_objects/dashboard_page_controls.ts | 43 ++- 21 files changed, 479 insertions(+), 636 deletions(-) delete mode 100644 src/plugins/controls/public/control_types/options_list/options_list_editor.tsx create mode 100644 src/plugins/controls/public/control_types/options_list/options_list_editor_options.tsx delete mode 100644 src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx delete mode 100644 src/plugins/controls/public/control_types/time_slider/time_slider_editor.tsx diff --git a/src/plugins/controls/common/control_types/options_list/types.ts b/src/plugins/controls/common/control_types/options_list/types.ts index 7dfdfab742d1a..7ab1c3c4f67a0 100644 --- a/src/plugins/controls/common/control_types/options_list/types.ts +++ b/src/plugins/controls/common/control_types/options_list/types.ts @@ -17,7 +17,6 @@ export const OPTIONS_LIST_CONTROL = 'optionsListControl'; export interface OptionsListEmbeddableInput extends DataControlInput { selectedOptions?: string[]; runPastTimeout?: boolean; - textFieldName?: string; singleSelect?: boolean; loading?: boolean; } diff --git a/src/plugins/controls/common/types.ts b/src/plugins/controls/common/types.ts index 4108e886e757d..7d70f53c32933 100644 --- a/src/plugins/controls/common/types.ts +++ b/src/plugins/controls/common/types.ts @@ -30,5 +30,7 @@ export type ControlInput = EmbeddableInput & { export type DataControlInput = ControlInput & { fieldName: string; + parentFieldName?: string; + childFieldName?: string; dataViewId: string; }; diff --git a/src/plugins/controls/public/control_group/control_group_strings.ts b/src/plugins/controls/public/control_group/control_group_strings.ts index 58ef91ed28173..23be81f3585d3 100644 --- a/src/plugins/controls/public/control_group/control_group_strings.ts +++ b/src/plugins/controls/public/control_group/control_group_strings.ts @@ -44,6 +44,14 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.manageControl.editFlyoutTitle', { defaultMessage: 'Edit control', }), + getDataViewTitle: () => + i18n.translate('controls.controlGroup.manageControl.dataViewTitle', { + defaultMessage: 'Data view', + }), + getFieldTitle: () => + i18n.translate('controls.controlGroup.manageControl.fielditle', { + defaultMessage: 'Field', + }), getTitleInputTitle: () => i18n.translate('controls.controlGroup.manageControl.titleInputTitle', { defaultMessage: 'Label', @@ -56,6 +64,10 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.manageControl.widthInputTitle', { defaultMessage: 'Minimum width', }), + getControlSettingsTitle: () => + i18n.translate('controls.controlGroup.manageControl.controlSettingsTitle', { + defaultMessage: 'Additional settings', + }), getSaveChangesTitle: () => i18n.translate('controls.controlGroup.manageControl.saveChangesTitle', { defaultMessage: 'Save and close', @@ -64,6 +76,14 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.manageControl.cancelTitle', { defaultMessage: 'Cancel', }), + getSelectFieldMessage: () => + i18n.translate('controls.controlGroup.manageControl.selectFieldMessage', { + defaultMessage: 'Please select a field', + }), + getSelectDataViewMessage: () => + i18n.translate('controls.controlGroup.manageControl.selectDataViewMessage', { + defaultMessage: 'Please select a data view', + }), getGrowSwitchTitle: () => i18n.translate('controls.controlGroup.manageControl.growSwitchTitle', { defaultMessage: 'Expand width to fit available space', diff --git a/src/plugins/controls/public/control_group/editor/control_editor.tsx b/src/plugins/controls/public/control_group/editor/control_editor.tsx index fdf99dc0f9c48..4f52ef67ed7b1 100644 --- a/src/plugins/controls/public/control_group/editor/control_editor.tsx +++ b/src/plugins/controls/public/control_group/editor/control_editor.tsx @@ -14,7 +14,9 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import useMount from 'react-use/lib/useMount'; + import { EuiFlyoutHeader, EuiButtonGroup, @@ -29,32 +31,35 @@ import { EuiForm, EuiButtonEmpty, EuiSpacer, - EuiKeyPadMenu, - EuiKeyPadMenuItem, EuiIcon, - EuiToolTip, EuiSwitch, + EuiTextColor, } from '@elastic/eui'; +import { DataViewListItem, DataView, DataViewField } from '@kbn/data-views-plugin/common'; +import { IFieldSubTypeMulti } from '@kbn/es-query'; +import { + LazyDataViewPicker, + LazyFieldPicker, + withSuspense, +} from '@kbn/presentation-util-plugin/public'; -import { EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public'; import { ControlGroupStrings } from '../control_group_strings'; import { ControlEmbeddable, - ControlInput, ControlWidth, + DataControlFieldRegistry, DataControlInput, IEditableControlFactory, } from '../../types'; import { CONTROL_WIDTH_OPTIONS } from './editor_constants'; import { pluginServices } from '../../services'; - interface EditControlProps { - embeddable?: ControlEmbeddable; + embeddable?: ControlEmbeddable; isCreate: boolean; title?: string; width: ControlWidth; + onSave: (type?: string) => void; grow: boolean; - onSave: (type: string) => void; onCancel: () => void; removeControl?: () => void; updateGrow?: (grow: boolean) => void; @@ -62,9 +67,18 @@ interface EditControlProps { updateWidth: (newWidth: ControlWidth) => void; getRelevantDataViewId?: () => string | undefined; setLastUsedDataViewId?: (newDataViewId: string) => void; - onTypeEditorChange: (partial: Partial) => void; + onTypeEditorChange: (partial: Partial) => void; } +interface ControlEditorState { + dataViewListItems: DataViewListItem[]; + selectedDataView?: DataView; + selectedField?: DataViewField; +} + +const FieldPicker = withSuspense(LazyFieldPicker, null); +const DataViewPicker = withSuspense(LazyDataViewPicker, null); + export const ControlEditor = ({ embeddable, isCreate, @@ -81,81 +95,104 @@ export const ControlEditor = ({ getRelevantDataViewId, setLastUsedDataViewId, }: EditControlProps) => { + const { dataViews } = pluginServices.getHooks(); + const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); + const { controls } = pluginServices.getServices(); const { getControlTypes, getControlFactory } = controls; + const [state, setState] = useState({ + dataViewListItems: [], + }); - const [selectedType, setSelectedType] = useState( - !isCreate && embeddable ? embeddable.type : getControlTypes()[0] - ); const [defaultTitle, setDefaultTitle] = useState(); const [currentTitle, setCurrentTitle] = useState(title); const [currentWidth, setCurrentWidth] = useState(width); const [currentGrow, setCurrentGrow] = useState(grow); const [controlEditorValid, setControlEditorValid] = useState(false); const [selectedField, setSelectedField] = useState( - embeddable - ? (embeddable.getInput() as DataControlInput).fieldName // CLEAN THIS ONCE OTHER PR GETS IN - : undefined + embeddable ? embeddable.getInput().fieldName : undefined ); - const getControlTypeEditor = (type: string) => { - const factory = getControlFactory(type); - const ControlTypeEditor = (factory as IEditableControlFactory).controlEditorComponent; - return ControlTypeEditor ? ( - { - if (!currentTitle || currentTitle === defaultTitle) { - setCurrentTitle(newDefaultTitle); - updateTitle(newDefaultTitle); - } - setDefaultTitle(newDefaultTitle); - }} - /> - ) : null; + const doubleLinkFields = (dataView: DataView) => { + // double link the parent-child relationship specifically for case-sensitivity support for options lists + const fieldRegistry: DataControlFieldRegistry = {}; + + for (const field of dataView.fields.getAll()) { + if (!fieldRegistry[field.name]) { + fieldRegistry[field.name] = { field, compatibleControlTypes: [] }; + } + const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent; + if (parentFieldName) { + fieldRegistry[field.name].parentFieldName = parentFieldName; + + const parentField = dataView.getFieldByName(parentFieldName); + if (!fieldRegistry[parentFieldName] && parentField) { + fieldRegistry[parentFieldName] = { field: parentField, compatibleControlTypes: [] }; + } + fieldRegistry[parentFieldName].childFieldName = field.name; + } + } + return fieldRegistry; }; - const getTypeButtons = () => { - return getControlTypes().map((type) => { - const factory = getControlFactory(type); - const icon = (factory as EmbeddableFactoryDefinition).getIconType?.(); - const tooltip = (factory as EmbeddableFactoryDefinition).getDescription?.(); - const menuPadItem = ( - { - setSelectedType(type); - if (!isCreate) - setSelectedField( - embeddable && type === embeddable.type - ? (embeddable.getInput() as DataControlInput).fieldName - : undefined - ); - }} - > - - - ); + const fieldRegistry = useMemo(() => { + if (!state.selectedDataView) return; + const newFieldRegistry: DataControlFieldRegistry = doubleLinkFields(state.selectedDataView); - return tooltip ? ( - - {menuPadItem} - - ) : ( - menuPadItem - ); + const controlFactories = getControlTypes().map( + (controlType) => getControlFactory(controlType) as IEditableControlFactory + ); + state.selectedDataView.fields.map((dataViewField) => { + for (const factory of controlFactories) { + if (factory.isFieldCompatible) { + factory.isFieldCompatible(newFieldRegistry[dataViewField.name]); + } + } + + if (newFieldRegistry[dataViewField.name]?.compatibleControlTypes.length === 0) { + delete newFieldRegistry[dataViewField.name]; + } }); - }; + return newFieldRegistry; + }, [state.selectedDataView, getControlFactory, getControlTypes]); + + useMount(() => { + let mounted = true; + if (selectedField) setDefaultTitle(selectedField); + + (async () => { + const dataViewListItems = await getIdsWithTitle(); + const initialId = + embeddable?.getInput().dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId()); + let dataView: DataView | undefined; + if (initialId) { + onTypeEditorChange({ dataViewId: initialId }); + dataView = await get(initialId); + } + if (!mounted) return; + setState((s) => ({ + ...s, + selectedDataView: dataView, + dataViewListItems, + })); + })(); + return () => { + mounted = false; + }; + }); + + useEffect( + () => setControlEditorValid(Boolean(selectedField) && Boolean(state.selectedDataView)), + [selectedField, setControlEditorValid, state.selectedDataView] + ); + + const { selectedDataView: dataView } = state; + const controlType = + selectedField && fieldRegistry && fieldRegistry[selectedField].compatibleControlTypes[0]; + const factory = controlType && getControlFactory(controlType); + const CustomSettings = + factory && (factory as IEditableControlFactory).controlEditorOptionsComponent; return ( <> @@ -169,64 +206,124 @@ export const ControlEditor = ({ + + { + setLastUsedDataViewId?.(dataViewId); + if (dataViewId === dataView?.id) return; + + onTypeEditorChange({ dataViewId }); + setSelectedField(undefined); + get(dataViewId).then((newDataView) => { + setState((s) => ({ ...s, selectedDataView: newDataView })); + }); + }} + trigger={{ + label: + state.selectedDataView?.title ?? + ControlGroupStrings.manageControl.getSelectDataViewMessage(), + }} + /> + + + { + return Boolean(fieldRegistry?.[field.name]); + }} + selectedFieldName={selectedField} + dataView={dataView} + onSelectField={(field) => { + onTypeEditorChange({ + fieldName: field.name, + parentFieldName: fieldRegistry?.[field.name].parentFieldName, + childFieldName: fieldRegistry?.[field.name].childFieldName, + }); + + const newDefaultTitle = field.displayName ?? field.name; + setDefaultTitle(newDefaultTitle); + setSelectedField(field.name); + if (!currentTitle || currentTitle === defaultTitle) { + setCurrentTitle(newDefaultTitle); + updateTitle(newDefaultTitle); + } + }} + /> + - {getTypeButtons()} + {factory ? ( + + + + + + {factory.getDisplayName()} + + + ) : ( + + {ControlGroupStrings.manageControl.getSelectFieldMessage()} + + )} + + + { + updateTitle(e.target.value || defaultTitle); + setCurrentTitle(e.target.value); + }} + /> - {selectedType && ( + + { + setCurrentWidth(newWidth as ControlWidth); + updateWidth(newWidth as ControlWidth); + }} + /> + + {updateGrow ? ( + + { + setCurrentGrow(!currentGrow); + updateGrow(!currentGrow); + }} + data-test-subj="control-editor-grow-switch" + /> + + ) : null} + {CustomSettings && (factory as IEditableControlFactory).controlEditorOptionsComponent && ( + + + + )} + {removeControl && ( <> - {getControlTypeEditor(selectedType)} - - { - updateTitle(e.target.value || defaultTitle); - setCurrentTitle(e.target.value); - }} - /> - - - { - setCurrentWidth(newWidth as ControlWidth); - updateWidth(newWidth as ControlWidth); - }} - /> - - {updateGrow ? ( - - { - setCurrentGrow(!currentGrow); - updateGrow(!currentGrow); - }} - data-test-subj="control-editor-grow-switch" - /> - - ) : null} - {removeControl && ( - { - onCancel(); - removeControl(); - }} - > - {ControlGroupStrings.management.getDeleteButtonTitle()} - - )} + { + onCancel(); + removeControl(); + }} + > + {ControlGroupStrings.management.getDeleteButtonTitle()} + )} @@ -250,7 +347,7 @@ export const ControlEditor = ({ iconType="check" color="primary" disabled={!controlEditorValid} - onClick={() => onSave(selectedType)} + onClick={() => onSave(controlType)} > {ControlGroupStrings.manageControl.getSaveChangesTitle()} diff --git a/src/plugins/controls/public/control_group/editor/create_control.tsx b/src/plugins/controls/public/control_group/editor/create_control.tsx index 2f791ac74d3ae..a3da7071d7ceb 100644 --- a/src/plugins/controls/public/control_group/editor/create_control.tsx +++ b/src/plugins/controls/public/control_group/editor/create_control.tsx @@ -14,7 +14,7 @@ import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { pluginServices } from '../../services'; import { ControlEditor } from './control_editor'; import { ControlGroupStrings } from '../control_group_strings'; -import { ControlWidth, ControlInput, IEditableControlFactory } from '../../types'; +import { ControlWidth, ControlInput, IEditableControlFactory, DataControlInput } from '../../types'; import { DEFAULT_CONTROL_WIDTH, DEFAULT_CONTROL_GROW, @@ -59,7 +59,7 @@ export const CreateControlButton = ({ const PresentationUtilProvider = pluginServices.getContextProvider(); const initialInputPromise = new Promise((resolve, reject) => { - let inputToReturn: Partial = {}; + let inputToReturn: Partial = {}; const onCancel = (ref: OverlayRef) => { if (Object.keys(inputToReturn).length === 0) { @@ -80,6 +80,21 @@ export const CreateControlButton = ({ }); }; + const onSave = (ref: OverlayRef, type?: string) => { + if (!type) { + reject(); + ref.close(); + return; + } + + const factory = getControlFactory(type) as IEditableControlFactory; + if (factory.presaveTransformFunction) { + inputToReturn = factory.presaveTransformFunction(inputToReturn); + } + resolve({ type, controlInput: inputToReturn }); + ref.close(); + }; + const flyoutInstance = openFlyout( toMountPoint( @@ -92,14 +107,7 @@ export const CreateControlButton = ({ updateTitle={(newTitle) => (inputToReturn.title = newTitle)} updateWidth={updateDefaultWidth} updateGrow={updateDefaultGrow} - onSave={(type: string) => { - const factory = getControlFactory(type) as IEditableControlFactory; - if (factory.presaveTransformFunction) { - inputToReturn = factory.presaveTransformFunction(inputToReturn); - } - resolve({ type, controlInput: inputToReturn }); - flyoutInstance.close(); - }} + onSave={(type) => onSave(flyoutInstance, type)} onCancel={() => onCancel(flyoutInstance)} onTypeEditorChange={(partialInput) => (inputToReturn = { ...inputToReturn, ...partialInput }) diff --git a/src/plugins/controls/public/control_group/editor/edit_control.tsx b/src/plugins/controls/public/control_group/editor/edit_control.tsx index b3fa8834da5e0..370b4f7caa011 100644 --- a/src/plugins/controls/public/control_group/editor/edit_control.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control.tsx @@ -11,14 +11,19 @@ import { EuiButtonIcon } from '@elastic/eui'; import React, { useEffect, useRef } from 'react'; import { OverlayRef } from '@kbn/core/public'; +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { EmbeddableFactoryNotFoundError } from '@kbn/embeddable-plugin/public'; import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public'; import { ControlGroupInput } from '../types'; import { ControlEditor } from './control_editor'; import { pluginServices } from '../../services'; -import { forwardAllContext } from './forward_all_context'; import { ControlGroupStrings } from '../control_group_strings'; -import { IEditableControlFactory, ControlInput } from '../../types'; +import { + IEditableControlFactory, + ControlInput, + DataControlInput, + ControlEmbeddable, +} from '../../types'; import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlGroupContainer, setFlyoutRef } from '../embeddable/control_group_container'; @@ -56,15 +61,19 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => }, [panels, embeddableId]); const editControl = async () => { - const panel = panels[embeddableId]; - let factory = getControlFactory(panel.type); - if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); - - const embeddable = await untilEmbeddableLoaded(embeddableId); - const controlGroup = embeddable.getRoot() as ControlGroupContainer; + const PresentationUtilProvider = pluginServices.getContextProvider(); + const embeddable = (await untilEmbeddableLoaded( + embeddableId + )) as ControlEmbeddable; const initialInputPromise = new Promise((resolve, reject) => { - let inputToReturn: Partial = {}; + const panel = panels[embeddableId]; + let factory = getControlFactory(panel.type); + if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); + + const controlGroup = embeddable.getRoot() as ControlGroupContainer; + + let inputToReturn: Partial = {}; let removed = false; const onCancel = (ref: OverlayRef) => { @@ -94,7 +103,13 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => }); }; - const onSave = (type: string, ref: OverlayRef) => { + const onSave = (ref: OverlayRef, type?: string) => { + if (!type) { + reject(); + ref.close(); + return; + } + // if the control now has a new type, need to replace the old factory with // one of the correct new type if (latestPanelState.current.type !== type) { @@ -110,44 +125,47 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => }; const flyoutInstance = openFlyout( - forwardAllContext( - onCancel(flyoutInstance)} - updateTitle={(newTitle) => (inputToReturn.title = newTitle)} - setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)} - updateWidth={(newWidth) => dispatch(setControlWidth({ width: newWidth, embeddableId }))} - updateGrow={(grow) => dispatch(setControlGrow({ grow, embeddableId }))} - onTypeEditorChange={(partialInput) => { - inputToReturn = { ...inputToReturn, ...partialInput }; - }} - onSave={(type) => onSave(type, flyoutInstance)} - removeControl={() => { - openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { - confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), - cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), - title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), - buttonColor: 'danger', - }).then((confirmed) => { - if (confirmed) { - removeEmbeddable(embeddableId); - removed = true; - flyoutInstance.close(); - } - }); - }} - />, - reduxContainerContext + toMountPoint( + + onCancel(flyoutInstance)} + updateTitle={(newTitle) => (inputToReturn.title = newTitle)} + setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)} + updateWidth={(newWidth) => + dispatch(setControlWidth({ width: newWidth, embeddableId })) + } + updateGrow={(grow) => dispatch(setControlGrow({ grow, embeddableId }))} + onTypeEditorChange={(partialInput) => { + inputToReturn = { ...inputToReturn, ...partialInput }; + }} + onSave={(type) => onSave(flyoutInstance, type)} + removeControl={() => { + openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), + cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), + title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) { + removeEmbeddable(embeddableId); + removed = true; + flyoutInstance.close(); + } + }); + }} + /> + ), { outsideClickCloses: false, onClose: (flyout) => { - setFlyoutRef(undefined); onCancel(flyout); + setFlyoutRef(undefined); }, } ); diff --git a/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx b/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx deleted file mode 100644 index b6d5a0877d7ce..0000000000000 --- a/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 useMount from 'react-use/lib/useMount'; -import React, { useEffect, useState } from 'react'; - -import { - LazyDataViewPicker, - LazyFieldPicker, - withSuspense, -} from '@kbn/presentation-util-plugin/public'; -import { IFieldSubTypeMulti } from '@kbn/es-query'; -import { EuiFormRow, EuiSwitch } from '@elastic/eui'; -import { DataViewListItem, DataView } from '@kbn/data-views-plugin/common'; - -import { pluginServices } from '../../services'; -import { ControlEditorProps } from '../../types'; -import { OptionsListStrings } from './options_list_strings'; -import { OptionsListEmbeddableInput, OptionsListField } from './types'; -interface OptionsListEditorState { - singleSelect?: boolean; - runPastTimeout?: boolean; - dataViewListItems: DataViewListItem[]; - fieldsMap?: { [key: string]: OptionsListField }; - dataView?: DataView; -} - -const FieldPicker = withSuspense(LazyFieldPicker, null); -const DataViewPicker = withSuspense(LazyDataViewPicker, null); - -export const OptionsListEditor = ({ - onChange, - initialInput, - setValidState, - setDefaultTitle, - getRelevantDataViewId, - setLastUsedDataViewId, - selectedField, - setSelectedField, -}: ControlEditorProps) => { - // Controls Services Context - const { dataViews } = pluginServices.getHooks(); - const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); - - const [state, setState] = useState({ - singleSelect: initialInput?.singleSelect, - runPastTimeout: initialInput?.runPastTimeout, - dataViewListItems: [], - }); - - useMount(() => { - let mounted = true; - if (selectedField) setDefaultTitle(selectedField); - (async () => { - const dataViewListItems = await getIdsWithTitle(); - const initialId = - initialInput?.dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId()); - let dataView: DataView | undefined; - if (initialId) { - onChange({ dataViewId: initialId }); - dataView = await get(initialId); - } - if (!mounted) return; - setState((s) => ({ ...s, dataView, dataViewListItems, fieldsMap: {} })); - })(); - return () => { - mounted = false; - }; - }); - - useEffect(() => { - if (!state.dataView) return; - - // double link the parent-child relationship so that we can filter in fields which are multi-typed to text / keyword - const doubleLinkedFields: OptionsListField[] = state.dataView?.fields.getAll(); - for (const field of doubleLinkedFields) { - const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent; - if (parentFieldName) { - (field as OptionsListField).parentFieldName = parentFieldName; - const parentField = state.dataView?.getFieldByName(parentFieldName); - (parentField as OptionsListField).childFieldName = field.name; - } - } - - const newFieldsMap: OptionsListEditorState['fieldsMap'] = {}; - for (const field of doubleLinkedFields) { - if (field.type === 'boolean') { - newFieldsMap[field.name] = field; - } - - // field type is keyword, check if this field is related to a text mapped field and include it. - else if (field.aggregatable && field.type === 'string') { - const childField = - (field.childFieldName && state.dataView?.fields.getByName(field.childFieldName)) || - undefined; - const parentField = - (field.parentFieldName && state.dataView?.fields.getByName(field.parentFieldName)) || - undefined; - - const textFieldName = childField?.esTypes?.includes('text') - ? childField.name - : parentField?.esTypes?.includes('text') - ? parentField.name - : undefined; - - newFieldsMap[field.name] = { ...field, textFieldName } as OptionsListField; - } - } - setState((s) => ({ ...s, fieldsMap: newFieldsMap })); - }, [state.dataView]); - - useEffect( - () => setValidState(Boolean(selectedField) && Boolean(state.dataView)), - [selectedField, setValidState, state.dataView] - ); - - const { dataView } = state; - return ( - <> - - { - setLastUsedDataViewId?.(dataViewId); - if (dataViewId === dataView?.id) return; - - onChange({ dataViewId }); - setSelectedField(undefined); - get(dataViewId).then((newDataView) => { - setState((s) => ({ ...s, dataView: newDataView })); - }); - }} - trigger={{ - label: state.dataView?.title ?? OptionsListStrings.editor.getNoDataViewTitle(), - }} - /> - - - Boolean(state.fieldsMap?.[field.name])} - selectedFieldName={selectedField} - dataView={dataView} - onSelectField={(field) => { - setDefaultTitle(field.displayName ?? field.name); - const textFieldName = state.fieldsMap?.[field.name].textFieldName; - onChange({ - fieldName: field.name, - textFieldName, - }); - setSelectedField(field.name); - }} - /> - - - { - onChange({ singleSelect: !state.singleSelect }); - setState((s) => ({ ...s, singleSelect: !s.singleSelect })); - }} - /> - - - { - onChange({ runPastTimeout: !state.runPastTimeout }); - setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout })); - }} - /> - - - ); -}; diff --git a/src/plugins/controls/public/control_types/options_list/options_list_editor_options.tsx b/src/plugins/controls/public/control_types/options_list/options_list_editor_options.tsx new file mode 100644 index 0000000000000..e09d1887aac1f --- /dev/null +++ b/src/plugins/controls/public/control_types/options_list/options_list_editor_options.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; + +import { OptionsListEmbeddableInput } from './types'; +import { OptionsListStrings } from './options_list_strings'; +import { ControlEditorProps } from '../..'; + +interface OptionsListEditorState { + singleSelect?: boolean; + runPastTimeout?: boolean; +} + +export const OptionsListEditorOptions = ({ + initialInput, + onChange, +}: ControlEditorProps) => { + const [state, setState] = useState({ + singleSelect: initialInput?.singleSelect, + runPastTimeout: initialInput?.runPastTimeout, + }); + + return ( + <> + + { + onChange({ singleSelect: !state.singleSelect }); + setState((s) => ({ ...s, singleSelect: !s.singleSelect })); + }} + /> + + + { + onChange({ runPastTimeout: !state.runPastTimeout }); + setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout })); + }} + /> + + + ); +}; diff --git a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx index edf4cb6ddaff1..0376776121eea 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx @@ -179,7 +179,8 @@ export class OptionsListEmbeddable extends Embeddable => { - const { dataViewId, fieldName, textFieldName } = this.getInput(); + const { dataViewId, fieldName, parentFieldName, childFieldName } = this.getInput(); + if (!this.dataView || this.dataView.id !== dataViewId) { this.dataView = await this.dataViewsService.get(dataViewId); if (this.dataView === undefined) { @@ -192,6 +193,16 @@ export class OptionsListEmbeddable extends Embeddable { + if ( + (dataControlField.field.aggregatable && dataControlField.field.type === 'string') || + dataControlField.field.type === 'boolean' + ) { + dataControlField.compatibleControlTypes.push(this.type); + } + }; + + public controlEditorOptionsComponent = OptionsListEditorOptions; public isEditable = () => Promise.resolve(false); diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx deleted file mode 100644 index 13f688c5dd318..0000000000000 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import useMount from 'react-use/lib/useMount'; -import React, { useEffect, useState } from 'react'; -import { EuiFormRow } from '@elastic/eui'; - -import { DataViewListItem, DataView } from '@kbn/data-views-plugin/common'; -import { - LazyDataViewPicker, - LazyFieldPicker, - withSuspense, -} from '@kbn/presentation-util-plugin/public'; -import { pluginServices } from '../../services'; -import { ControlEditorProps } from '../../types'; -import { RangeSliderEmbeddableInput } from './types'; -import { RangeSliderStrings } from './range_slider_strings'; - -interface RangeSliderEditorState { - dataViewListItems: DataViewListItem[]; - dataView?: DataView; -} - -const FieldPicker = withSuspense(LazyFieldPicker, null); -const DataViewPicker = withSuspense(LazyDataViewPicker, null); - -export const RangeSliderEditor = ({ - onChange, - initialInput, - setValidState, - setDefaultTitle, - getRelevantDataViewId, - setLastUsedDataViewId, - selectedField, - setSelectedField, -}: ControlEditorProps) => { - // Controls Services Context - const { dataViews } = pluginServices.getHooks(); - const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); - - const [state, setState] = useState({ - dataViewListItems: [], - }); - - useMount(() => { - let mounted = true; - if (selectedField) setDefaultTitle(selectedField); - (async () => { - const dataViewListItems = await getIdsWithTitle(); - const initialId = - initialInput?.dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId()); - let dataView: DataView | undefined; - if (initialId) { - onChange({ dataViewId: initialId }); - dataView = await get(initialId); - } - if (!mounted) return; - setState((s) => ({ ...s, dataView, dataViewListItems })); - })(); - return () => { - mounted = false; - }; - }); - - useEffect( - () => setValidState(Boolean(selectedField) && Boolean(state.dataView)), - [selectedField, setValidState, state.dataView] - ); - - const { dataView } = state; - return ( - <> - - { - setLastUsedDataViewId?.(dataViewId); - if (dataViewId === dataView?.id) return; - - onChange({ dataViewId }); - setSelectedField(undefined); - get(dataViewId).then((newDataView) => { - setState((s) => ({ ...s, dataView: newDataView })); - }); - }} - trigger={{ - label: state.dataView?.title ?? RangeSliderStrings.editor.getNoDataViewTitle(), - }} - /> - - - field.aggregatable && field.type === 'number'} - selectedFieldName={selectedField} - dataView={dataView} - onSelectField={(field) => { - setDefaultTitle(field.displayName ?? field.name); - onChange({ fieldName: field.name }); - setSelectedField(field.name); - }} - /> - - - ); -}; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx index bd8b8a394988b..962937a8dc500 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx @@ -9,8 +9,7 @@ import deepEqual from 'fast-deep-equal'; import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public'; -import { RangeSliderEditor } from './range_slider_editor'; -import { ControlEmbeddable, IEditableControlFactory } from '../../types'; +import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types'; import { RangeSliderEmbeddableInput, RANGE_SLIDER_CONTROL } from './types'; import { createRangeSliderExtract, @@ -46,7 +45,11 @@ export class RangeSliderEmbeddableFactory return newInput; }; - public controlEditorComponent = RangeSliderEditor; + public isFieldCompatible = (dataControlField: DataControlField) => { + if (dataControlField.field.aggregatable && dataControlField.field.type === 'number') { + dataControlField.compatibleControlTypes.push(this.type); + } + }; public isEditable = () => Promise.resolve(false); diff --git a/src/plugins/controls/public/control_types/time_slider/time_slider_editor.tsx b/src/plugins/controls/public/control_types/time_slider/time_slider_editor.tsx deleted file mode 100644 index d8f130661983f..0000000000000 --- a/src/plugins/controls/public/control_types/time_slider/time_slider_editor.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import useMount from 'react-use/lib/useMount'; -import React, { useEffect, useState } from 'react'; -import { EuiFormRow } from '@elastic/eui'; - -import { DataViewListItem, DataView } from '@kbn/data-views-plugin/common'; -import { - LazyDataViewPicker, - LazyFieldPicker, - withSuspense, -} from '@kbn/presentation-util-plugin/public'; -import { pluginServices } from '../../services'; -import { ControlEditorProps } from '../../types'; -import { TimeSliderStrings } from './time_slider_strings'; - -interface TimeSliderEditorState { - dataViewListItems: DataViewListItem[]; - dataView?: DataView; -} - -const FieldPicker = withSuspense(LazyFieldPicker, null); -const DataViewPicker = withSuspense(LazyDataViewPicker, null); - -export const TimeSliderEditor = ({ - onChange, - initialInput, - setValidState, - setDefaultTitle, - getRelevantDataViewId, - setLastUsedDataViewId, - selectedField, - setSelectedField, -}: ControlEditorProps) => { - // Controls Services Context - const { dataViews } = pluginServices.getHooks(); - const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); - - const [state, setState] = useState({ - dataViewListItems: [], - }); - - useMount(() => { - let mounted = true; - if (selectedField) setDefaultTitle(selectedField); - (async () => { - const dataViewListItems = await getIdsWithTitle(); - const initialId = - initialInput?.dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId()); - let dataView: DataView | undefined; - if (initialId) { - onChange({ dataViewId: initialId }); - dataView = await get(initialId); - } - if (!mounted) return; - setState((s) => ({ ...s, dataView, dataViewListItems })); - })(); - return () => { - mounted = false; - }; - }); - - useEffect( - () => setValidState(Boolean(selectedField) && Boolean(state.dataView)), - [selectedField, setValidState, state.dataView] - ); - - const { dataView } = state; - return ( - <> - - { - setLastUsedDataViewId?.(dataViewId); - if (dataViewId === dataView?.id) return; - - onChange({ dataViewId }); - setSelectedField(undefined); - get(dataViewId).then((newDataView) => { - setState((s) => ({ ...s, dataView: newDataView })); - }); - }} - trigger={{ - label: state.dataView?.title ?? TimeSliderStrings.editor.getNoDataViewTitle(), - }} - /> - - - field.type === 'date'} - selectedFieldName={selectedField} - dataView={dataView} - onSelectField={(field) => { - setDefaultTitle(field.displayName ?? field.name); - onChange({ fieldName: field.name }); - setSelectedField(field.name); - }} - /> - - - ); -}; diff --git a/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable_factory.tsx b/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable_factory.tsx index a49a0b85818f2..6fad0139b98e2 100644 --- a/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable_factory.tsx +++ b/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable_factory.tsx @@ -10,12 +10,11 @@ import deepEqual from 'fast-deep-equal'; import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public'; import { TIME_SLIDER_CONTROL } from '../..'; -import { ControlEmbeddable, IEditableControlFactory } from '../../types'; +import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types'; import { createOptionsListExtract, createOptionsListInject, } from '../../../common/control_types/options_list/options_list_persistable_state'; -import { TimeSliderEditor } from './time_slider_editor'; import { TimeSliderControlEmbeddableInput } from '../../../common/control_types/time_slider/types'; import { TimeSliderStrings } from './time_slider_strings'; @@ -48,7 +47,11 @@ export class TimesliderEmbeddableFactory return newInput; }; - public controlEditorComponent = TimeSliderEditor; + public isFieldCompatible = (dataControlField: DataControlField) => { + if (dataControlField.field.type === 'date') { + dataControlField.compatibleControlTypes.push(this.type); + } + }; public isEditable = () => Promise.resolve(false); diff --git a/src/plugins/controls/public/plugin.ts b/src/plugins/controls/public/plugin.ts index 9b0d754b3f150..352ed60b554a2 100644 --- a/src/plugins/controls/public/plugin.ts +++ b/src/plugins/controls/public/plugin.ts @@ -61,10 +61,11 @@ export class ControlsPlugin factoryDef: IEditableControlFactory, factory: EmbeddableFactory ) { - (factory as IEditableControlFactory).controlEditorComponent = - factoryDef.controlEditorComponent; + (factory as IEditableControlFactory).controlEditorOptionsComponent = + factoryDef.controlEditorOptionsComponent ?? undefined; (factory as IEditableControlFactory).presaveTransformFunction = factoryDef.presaveTransformFunction; + (factory as IEditableControlFactory).isFieldCompatible = factoryDef.isFieldCompatible; } public setup( diff --git a/src/plugins/controls/public/types.ts b/src/plugins/controls/public/types.ts index 4ab4db2eec037..71436fa9926e0 100644 --- a/src/plugins/controls/public/types.ts +++ b/src/plugins/controls/public/types.ts @@ -16,7 +16,7 @@ import { IEmbeddable, } from '@kbn/embeddable-plugin/public'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { DataView, DataViewField, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import { ControlInput } from '../common/types'; import { ControlsService } from './services/controls'; @@ -28,7 +28,11 @@ export interface CommonControlOutput { export type ControlOutput = EmbeddableOutput & CommonControlOutput; -export type ControlFactory = EmbeddableFactory; +export type ControlFactory = EmbeddableFactory< + ControlInput, + ControlOutput, + ControlEmbeddable +>; export type ControlEmbeddable< TControlEmbeddableInput extends ControlInput = ControlInput, @@ -39,21 +43,28 @@ export type ControlEmbeddable< * Control embeddable editor types */ export interface IEditableControlFactory { - controlEditorComponent?: (props: ControlEditorProps) => JSX.Element; + controlEditorOptionsComponent?: (props: ControlEditorProps) => JSX.Element; presaveTransformFunction?: ( newState: Partial, embeddable?: ControlEmbeddable ) => Partial; + isFieldCompatible?: (dataControlField: DataControlField) => void; // reducer } + export interface ControlEditorProps { initialInput?: Partial; - getRelevantDataViewId?: () => string | undefined; - setLastUsedDataViewId?: (newId: string) => void; onChange: (partial: Partial) => void; - setValidState: (valid: boolean) => void; - setDefaultTitle: (defaultTitle: string) => void; - selectedField: string | undefined; - setSelectedField: (newField: string | undefined) => void; +} + +export interface DataControlField { + field: DataViewField; + parentFieldName?: string; + childFieldName?: string; + compatibleControlTypes: string[]; +} + +export interface DataControlFieldRegistry { + [fieldName: string]: DataControlField; } /** diff --git a/test/functional/apps/dashboard_elements/controls/control_group_settings.ts b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts index 23f44575ff45e..4648698ec0b5f 100644 --- a/test/functional/apps/dashboard_elements/controls/control_group_settings.ts +++ b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts @@ -42,7 +42,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('apply new default width and grow', async () => { it('defaults to medium width and grow enabled', async () => { - await dashboardControls.openCreateControlFlyout(OPTIONS_LIST_CONTROL); + await dashboardControls.openCreateControlFlyout(); const mediumWidthButton = await testSubjects.find('control-editor-width-medium'); expect(await mediumWidthButton.elementHasClass('euiButtonGroupButton-isSelected')).to.be( true @@ -70,7 +70,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await secondControl.elementHasClass('controlFrameWrapper--small')).to.be(true); expect(await secondControl.elementHasClass('euiFlexItem--flexGrowZero')).to.be(true); - await dashboardControls.openCreateControlFlyout(OPTIONS_LIST_CONTROL); + await dashboardControls.openCreateControlFlyout(); const smallWidthButton = await testSubjects.find('control-editor-width-small'); expect(await smallWidthButton.elementHasClass('euiButtonGroupButton-isSelected')).to.be( true diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts index 17a028a39464e..162444883873a 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -110,7 +110,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await saveButton.isEnabled()).to.be(true); await dashboardControls.controlsEditorSetDataView('animals-*'); expect(await saveButton.isEnabled()).to.be(false); - await dashboardControls.controlsEditorSetfield('animal.keyword'); + await dashboardControls.controlsEditorSetfield('animal.keyword', OPTIONS_LIST_CONTROL); await dashboardControls.controlEditorSave(); // when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view @@ -129,7 +129,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.optionsListEnsurePopoverIsClosed(secondId); await dashboardControls.editExistingControl(secondId); - await dashboardControls.controlsEditorSetfield('animal.keyword'); + await dashboardControls.controlsEditorSetfield('animal.keyword', OPTIONS_LIST_CONTROL); await dashboardControls.controlEditorSave(); const selectionString = await dashboardControls.optionsListGetSelectionsString(secondId); diff --git a/test/functional/apps/dashboard_elements/controls/range_slider.ts b/test/functional/apps/dashboard_elements/controls/range_slider.ts index a4b84206bde84..9cc390fbe405a 100644 --- a/test/functional/apps/dashboard_elements/controls/range_slider.ts +++ b/test/functional/apps/dashboard_elements/controls/range_slider.ts @@ -121,7 +121,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await saveButton.isEnabled()).to.be(true); await dashboardControls.controlsEditorSetDataView('kibana_sample_data_flights'); expect(await saveButton.isEnabled()).to.be(false); - await dashboardControls.controlsEditorSetfield('dayOfWeek'); + await dashboardControls.controlsEditorSetfield('dayOfWeek', RANGE_SLIDER_CONTROL); await dashboardControls.controlEditorSave(); await dashboardControls.rangeSliderWaitForLoading(); validateRange('placeholder', firstId, '0', '6'); @@ -164,7 +164,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('editing field clears selections', async () => { const secondId = (await dashboardControls.getAllControlIds())[1]; await dashboardControls.editExistingControl(secondId); - await dashboardControls.controlsEditorSetfield('FlightDelayMin'); + await dashboardControls.controlsEditorSetfield('FlightDelayMin', RANGE_SLIDER_CONTROL); await dashboardControls.controlEditorSave(); await dashboardControls.rangeSliderWaitForLoading(); diff --git a/test/functional/apps/dashboard_elements/controls/replace_controls.ts b/test/functional/apps/dashboard_elements/controls/replace_controls.ts index f6af399905077..3697300e1b7d3 100644 --- a/test/functional/apps/dashboard_elements/controls/replace_controls.ts +++ b/test/functional/apps/dashboard_elements/controls/replace_controls.ts @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect'; - import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL, @@ -28,24 +26,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'header', ]); - const changeFieldType = async (newField: string) => { - const saveButton = await testSubjects.find('control-editor-save'); - expect(await saveButton.isEnabled()).to.be(false); - await dashboardControls.controlsEditorSetfield(newField); - expect(await saveButton.isEnabled()).to.be(true); + const changeFieldType = async (controlId: string, newField: string, expectedType?: string) => { + await dashboardControls.editExistingControl(controlId); + await dashboardControls.controlsEditorSetfield(newField, expectedType); await dashboardControls.controlEditorSave(); }; const replaceWithOptionsList = async (controlId: string) => { - await dashboardControls.controlEditorSetType(OPTIONS_LIST_CONTROL); - await changeFieldType('sound.keyword'); + await changeFieldType(controlId, 'sound.keyword', OPTIONS_LIST_CONTROL); await testSubjects.waitForEnabled(`optionsList-control-${controlId}`); await dashboardControls.verifyControlType(controlId, 'optionsList-control'); }; const replaceWithRangeSlider = async (controlId: string) => { - await dashboardControls.controlEditorSetType(RANGE_SLIDER_CONTROL); - await changeFieldType('weightLbs'); + await changeFieldType(controlId, 'weightLbs', RANGE_SLIDER_CONTROL); await retry.try(async () => { await dashboardControls.rangeSliderWaitForLoading(); await dashboardControls.verifyControlType(controlId, 'range-slider-control'); @@ -53,8 +47,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }; const replaceWithTimeSlider = async (controlId: string) => { - await dashboardControls.controlEditorSetType(TIME_SLIDER_CONTROL); - await changeFieldType('@timestamp'); + await changeFieldType(controlId, '@timestamp', TIME_SLIDER_CONTROL); await testSubjects.waitForDeleted('timeSlider-loading-spinner'); await dashboardControls.verifyControlType(controlId, 'timeSlider'); }; @@ -78,7 +71,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { fieldName: 'sound.keyword', }); controlId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.editExistingControl(controlId); }); it('with range slider', async () => { @@ -102,7 +94,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); await dashboardControls.rangeSliderWaitForLoading(); controlId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.editExistingControl(controlId); }); it('with options list', async () => { @@ -124,7 +115,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); await testSubjects.waitForDeleted('timeSlider-loading-spinner'); controlId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.editExistingControl(controlId); }); it('with options list', async () => { diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index f0438b391ac93..2f8f21c73692e 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -7,12 +7,22 @@ */ import expect from '@kbn/expect'; -import { OPTIONS_LIST_CONTROL, ControlWidth } from '@kbn/controls-plugin/common'; +import { + OPTIONS_LIST_CONTROL, + RANGE_SLIDER_CONTROL, + ControlWidth, +} from '@kbn/controls-plugin/common'; import { ControlGroupChainingSystem } from '@kbn/controls-plugin/common/control_group/types'; import { WebElementWrapper } from '../services/lib/web_element_wrapper'; import { FtrService } from '../ftr_provider_context'; +const CONTROL_DISPLAY_NAMES: { [key: string]: string } = { + default: 'Please select a field', + [OPTIONS_LIST_CONTROL]: 'Options list', + [RANGE_SLIDER_CONTROL]: 'Range slider', +}; + export class DashboardPageControls extends FtrService { private readonly log = this.ctx.getService('log'); private readonly find = this.ctx.getService('find'); @@ -78,14 +88,14 @@ export class DashboardPageControls extends FtrService { } } - public async openCreateControlFlyout(type: string) { - this.log.debug(`Opening flyout for ${type} control`); + public async openCreateControlFlyout() { + this.log.debug(`Opening flyout for creating a control`); await this.testSubjects.click('dashboard-controls-menu-button'); await this.testSubjects.click('controls-create-button'); await this.retry.try(async () => { await this.testSubjects.existOrFail('control-editor-flyout'); }); - await this.controlEditorSetType(type); + await this.controlEditorVerifyType('default'); } /* ----------------------------------------------------------- @@ -238,10 +248,12 @@ export class DashboardPageControls extends FtrService { grow?: boolean; }) { this.log.debug(`Creating ${controlType} control ${title ?? fieldName}`); - await this.openCreateControlFlyout(controlType); + await this.openCreateControlFlyout(); if (dataViewTitle) await this.controlsEditorSetDataView(dataViewTitle); - if (fieldName) await this.controlsEditorSetfield(fieldName); + + if (fieldName) await this.controlsEditorSetfield(fieldName, controlType); + if (title) await this.controlEditorSetTitle(title); if (width) await this.controlEditorSetWidth(width); if (grow !== undefined) await this.controlEditorSetGrow(grow); @@ -377,6 +389,9 @@ export class DashboardPageControls extends FtrService { public async controlEditorSave() { this.log.debug(`Saving changes in control editor`); await this.testSubjects.click(`control-editor-save`); + await this.retry.waitFor('flyout to close', async () => { + return !(await this.testSubjects.exists('control-editor-flyout')); + }); } public async controlEditorCancel(confirm?: boolean) { @@ -396,7 +411,11 @@ export class DashboardPageControls extends FtrService { await this.testSubjects.click(`data-view-picker-${dataViewTitle}`); } - public async controlsEditorSetfield(fieldName: string, shouldSearch: boolean = false) { + public async controlsEditorSetfield( + fieldName: string, + expectedType?: string, + shouldSearch: boolean = false + ) { this.log.debug(`Setting control field to ${fieldName}`); if (shouldSearch) { await this.testSubjects.setValue('field-search-input', fieldName); @@ -405,17 +424,19 @@ export class DashboardPageControls extends FtrService { await this.testSubjects.existOrFail(`field-picker-select-${fieldName}`); }); await this.testSubjects.click(`field-picker-select-${fieldName}`); + if (expectedType) await this.controlEditorVerifyType(expectedType); } - public async controlEditorSetType(type: string) { - this.log.debug(`Setting control type to ${type}`); - await this.testSubjects.click(`create-${type}-control`); + public async controlEditorVerifyType(type: string) { + this.log.debug(`Verifying that the control editor picked the type ${type}`); + const autoSelectedType = await this.testSubjects.getVisibleText('control-editor-type'); + expect(autoSelectedType).to.equal(CONTROL_DISPLAY_NAMES[type]); } // Options List editor functions public async optionsListEditorGetCurrentDataView(openAndCloseFlyout?: boolean) { if (openAndCloseFlyout) { - await this.openCreateControlFlyout(OPTIONS_LIST_CONTROL); + await this.openCreateControlFlyout(); } const dataViewName = (await this.testSubjects.find('open-data-view-picker')).getVisibleText(); if (openAndCloseFlyout) { From a80bfb7283ea8a648514c248a8047b16f46bded6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Thu, 19 May 2022 17:18:21 +0100 Subject: [PATCH 37/37] [Content management] Add "Last updated" metadata to TableListView (#132321) --- .../public/services/saved_object_loader.ts | 20 ++- .../table_list_view.test.tsx.snap | 7 +- .../table_list_view/table_list_view.test.tsx | 170 +++++++++++++++++- .../table_list_view/table_list_view.tsx | 158 ++++++++++++---- .../public/utils/saved_visualize_utils.ts | 4 + .../vis_types/vis_type_alias_registry.ts | 4 +- .../public/helpers/saved_workspace_utils.ts | 1 + x-pack/plugins/lens/public/vis_type_alias.ts | 3 +- .../maps/common/map_saved_object_type.ts | 4 - .../maps/public/maps_vis_type_alias.ts | 8 +- .../routes/list_page/maps_list_view.tsx | 1 + .../maps/server/maps_telemetry/find_maps.ts | 6 +- .../index_pattern_stats_collector.ts | 5 +- 13 files changed, 334 insertions(+), 57 deletions(-) diff --git a/src/plugins/dashboard/public/services/saved_object_loader.ts b/src/plugins/dashboard/public/services/saved_object_loader.ts index 3c406357c0294..780daa2939aa4 100644 --- a/src/plugins/dashboard/public/services/saved_object_loader.ts +++ b/src/plugins/dashboard/public/services/saved_object_loader.ts @@ -98,12 +98,16 @@ export class SavedObjectLoader { mapHitSource( source: Record, id: string, - references: SavedObjectReference[] = [] - ) { - source.id = id; - source.url = this.urlFor(id); - source.references = references; - return source; + references: SavedObjectReference[] = [], + updatedAt?: string + ): Record { + return { + ...source, + id, + url: this.urlFor(id), + references, + updatedAt, + }; } /** @@ -116,12 +120,14 @@ export class SavedObjectLoader { attributes, id, references = [], + updatedAt, }: { attributes: Record; id: string; references?: SavedObjectReference[]; + updatedAt?: string; }) { - return this.mapHitSource(attributes, id, references); + return this.mapHitSource(attributes, id, references, updatedAt); } /** diff --git a/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap b/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap index a0c34cfdfee07..2ad9af679e8c6 100644 --- a/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap +++ b/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap @@ -129,6 +129,7 @@ exports[`TableListView render list view 1`] = ` } /> } + onChange={[Function]} pagination={ Object { "initialPageIndex": 0, @@ -155,7 +156,11 @@ exports[`TableListView render list view 1`] = ` "toolsLeft": undefined, } } - sorting={true} + sorting={ + Object { + "sort": undefined, + } + } tableCaption="test caption" tableLayout="fixed" /> diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx index 13423047bc3f0..ba76a6b879e61 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx @@ -7,13 +7,24 @@ */ import { EuiEmptyPrompt } from '@elastic/eui'; -import { shallowWithIntl } from '@kbn/test-jest-helpers'; +import { shallowWithIntl, registerTestBed, TestBed } from '@kbn/test-jest-helpers'; import { ToastsStart } from '@kbn/core/public'; import React from 'react'; +import moment, { Moment } from 'moment'; +import { act } from 'react-dom/test-utils'; import { themeServiceMock, applicationServiceMock } from '@kbn/core/public/mocks'; -import { TableListView } from './table_list_view'; +import { TableListView, TableListViewProps } from './table_list_view'; -const requiredProps = { +jest.mock('lodash', () => { + const original = jest.requireActual('lodash'); + + return { + ...original, + debounce: (handler: () => void) => handler, + }; +}); + +const requiredProps: TableListViewProps> = { entityName: 'test', entityNamePlural: 'tests', listingLimit: 5, @@ -30,6 +41,14 @@ const requiredProps = { }; describe('TableListView', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + test('render default empty prompt', async () => { const component = shallowWithIntl(); @@ -81,4 +100,149 @@ describe('TableListView', () => { expect(component).toMatchSnapshot(); }); + + describe('default columns', () => { + let testBed: TestBed; + + const tableColumns = [ + { + field: 'title', + name: 'Title', + sortable: true, + }, + { + field: 'description', + name: 'Description', + sortable: true, + }, + ]; + + const twoDaysAgo = new Date(new Date().setDate(new Date().getDate() - 2)); + const yesterday = new Date(new Date().setDate(new Date().getDate() - 1)); + + const hits = [ + { + title: 'Item 1', + description: 'Item 1 description', + updatedAt: twoDaysAgo, + }, + { + title: 'Item 2', + description: 'Item 2 description', + // This is the latest updated and should come first in the table + updatedAt: yesterday, + }, + ]; + + const findItems = jest.fn(() => Promise.resolve({ total: hits.length, hits })); + + const defaultProps: TableListViewProps> = { + ...requiredProps, + tableColumns, + findItems, + createItem: () => undefined, + }; + + const setup = registerTestBed(TableListView, { defaultProps }); + + test('should add a "Last updated" column if "updatedAt" is provided', async () => { + await act(async () => { + testBed = await setup(); + }); + + const { component, table } = testBed!; + component.update(); + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + + expect(tableCellsValues).toEqual([ + ['Item 2', 'Item 2 description', 'yesterday'], // Comes first as it is the latest updated + ['Item 1', 'Item 1 description', '2 days ago'], + ]); + }); + + test('should not display relative time for items updated more than 7 days ago', async () => { + const updatedAtValues: Moment[] = []; + + const updatedHits = hits.map(({ title, description }, i) => { + const updatedAt = new Date(new Date().setDate(new Date().getDate() - (7 + i))); + updatedAtValues[i] = moment(updatedAt); + + return { + title, + description, + updatedAt, + }; + }); + + await act(async () => { + testBed = await setup({ + findItems: jest.fn(() => + Promise.resolve({ + total: updatedHits.length, + hits: updatedHits, + }) + ), + }); + }); + + const { component, table } = testBed!; + component.update(); + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + + expect(tableCellsValues).toEqual([ + // Renders the datetime with this format: "05/10/2022 @ 2:34 PM" + ['Item 1', 'Item 1 description', updatedAtValues[0].format('LL')], + ['Item 2', 'Item 2 description', updatedAtValues[1].format('LL')], + ]); + }); + + test('should not add a "Last updated" column if no "updatedAt" is provided', async () => { + await act(async () => { + testBed = await setup({ + findItems: jest.fn(() => + Promise.resolve({ + total: hits.length, + hits: hits.map(({ title, description }) => ({ title, description })), + }) + ), + }); + }); + + const { component, table } = testBed!; + component.update(); + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + + expect(tableCellsValues).toEqual([ + ['Item 1', 'Item 1 description'], // Sorted by title + ['Item 2', 'Item 2 description'], + ]); + }); + + test('should not display anything if there is no updatedAt metadata for an item', async () => { + await act(async () => { + testBed = await setup({ + findItems: jest.fn(() => + Promise.resolve({ + total: hits.length + 1, + hits: [...hits, { title: 'Item 3', description: 'Item 3 description' }], + }) + ), + }); + }); + + const { component, table } = testBed!; + component.update(); + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + + expect(tableCellsValues).toEqual([ + ['Item 2', 'Item 2 description', 'yesterday'], + ['Item 1', 'Item 1 description', '2 days ago'], + ['Item 3', 'Item 3 description', '-'], // Empty column as no updatedAt provided + ]); + }); + }); }); diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx index ece2fa37cc832..5baaaa78b76ec 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx @@ -13,16 +13,21 @@ import { EuiConfirmModal, EuiEmptyPrompt, EuiInMemoryTable, + Criteria, + PropertySort, + Direction, EuiLink, EuiSpacer, EuiTableActionsColumnType, SearchFilterConfig, + EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { FormattedMessage, FormattedRelative } from '@kbn/i18n-react'; import { ThemeServiceStart, HttpFetchError, ToastsStart, ApplicationStart } from '@kbn/core/public'; import { debounce, keyBy, sortBy, uniq } from 'lodash'; import React from 'react'; +import moment from 'moment'; import { KibanaPageTemplate } from '../page_template'; import { toMountPoint } from '../util'; @@ -64,6 +69,7 @@ export interface TableListViewProps { export interface TableListViewState { items: V[]; hasInitialFetchReturned: boolean; + hasUpdatedAtMetadata: boolean | null; isFetchingItems: boolean; isDeletingItems: boolean; showDeleteModal: boolean; @@ -72,6 +78,10 @@ export interface TableListViewState { filter: string; selectedIds: string[]; totalItems: number; + tableSort?: { + field: keyof V; + direction: Direction; + }; } // saved object client does not support sorting by title because title is only mapped as analyzed @@ -94,10 +104,12 @@ class TableListView extends React.Component< initialPageSize: props.initialPageSize, pageSizeOptions: uniq([10, 20, 50, props.initialPageSize]).sort(), }; + this.state = { items: [], totalItems: 0, hasInitialFetchReturned: false, + hasUpdatedAtMetadata: null, isFetchingItems: false, isDeletingItems: false, showDeleteModal: false, @@ -120,6 +132,28 @@ class TableListView extends React.Component< this.fetchItems(); } + componentDidUpdate(prevProps: TableListViewProps, prevState: TableListViewState) { + if (this.state.hasUpdatedAtMetadata === null && prevState.items !== this.state.items) { + // We check if the saved object have the "updatedAt" metadata + // to render or not that column in the table + const hasUpdatedAtMetadata = Boolean( + this.state.items.find((item: { updatedAt?: string }) => Boolean(item.updatedAt)) + ); + + this.setState((prev) => { + return { + hasUpdatedAtMetadata, + tableSort: hasUpdatedAtMetadata + ? { + field: 'updatedAt' as keyof V, + direction: 'desc' as const, + } + : prev.tableSort, + }; + }); + } + } + debouncedFetch = debounce(async (filter: string) => { try { const response = await this.props.findItems(filter); @@ -420,6 +454,12 @@ class TableListView extends React.Component< ); } + onTableChange(criteria: Criteria) { + if (criteria.sort) { + this.setState({ tableSort: criteria.sort }); + } + } + renderTable() { const { searchFilters } = this.props; @@ -435,24 +475,6 @@ class TableListView extends React.Component< } : undefined; - const actions: EuiTableActionsColumnType['actions'] = [ - { - name: i18n.translate('kibana-react.tableListView.listing.table.editActionName', { - defaultMessage: 'Edit', - }), - description: i18n.translate( - 'kibana-react.tableListView.listing.table.editActionDescription', - { - defaultMessage: 'Edit', - } - ), - icon: 'pencil', - type: 'icon', - enabled: (v) => !(v as unknown as { error: string })?.error, - onClick: this.props.editItem, - }, - ]; - const search = { onChange: this.setFilter.bind(this), toolsLeft: this.renderToolsLeft(), @@ -464,17 +486,6 @@ class TableListView extends React.Component< filters: searchFilters ?? [], }; - const columns = this.props.tableColumns.slice(); - if (this.props.editItem) { - columns.push({ - name: i18n.translate('kibana-react.tableListView.listing.table.actionTitle', { - defaultMessage: 'Actions', - }), - width: '100px', - actions, - }); - } - const noItemsMessage = ( extends React.Component< values={{ entityNamePlural: this.props.entityNamePlural }} /> ); + return ( extends React.Component< ); } + getTableColumns() { + const columns = this.props.tableColumns.slice(); + + // Add "Last update" column + if (this.state.hasUpdatedAtMetadata) { + const renderUpdatedAt = (dateTime?: string) => { + if (!dateTime) { + return ( + + - + + ); + } + const updatedAt = moment(dateTime); + + if (updatedAt.diff(moment(), 'days') > -7) { + return ( + + {(formattedDate: string) => ( + + {formattedDate} + + )} + + ); + } + return ( + + {updatedAt.format('LL')} + + ); + }; + + columns.push({ + field: 'updatedAt', + name: i18n.translate('kibana-react.tableListView.lastUpdatedColumnTitle', { + defaultMessage: 'Last updated', + }), + render: (field: string, record: { updatedAt?: string }) => + renderUpdatedAt(record.updatedAt), + sortable: true, + width: '150px', + }); + } + + // Add "Actions" column + if (this.props.editItem) { + const actions: EuiTableActionsColumnType['actions'] = [ + { + name: i18n.translate('kibana-react.tableListView.listing.table.editActionName', { + defaultMessage: 'Edit', + }), + description: i18n.translate( + 'kibana-react.tableListView.listing.table.editActionDescription', + { + defaultMessage: 'Edit', + } + ), + icon: 'pencil', + type: 'icon', + enabled: (v) => !(v as unknown as { error: string })?.error, + onClick: this.props.editItem, + }, + ]; + + columns.push({ + name: i18n.translate('kibana-react.tableListView.listing.table.actionTitle', { + defaultMessage: 'Actions', + }), + width: '100px', + actions, + }); + } + + return columns; + } + renderCreateButton() { if (this.props.createItem) { return ( diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts index 5b8ba8ce04cb4..f5444b6269e22 100644 --- a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts +++ b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts @@ -64,10 +64,12 @@ export function mapHitSource( attributes, id, references, + updatedAt, }: { attributes: SavedObjectAttributes; id: string; references: SavedObjectReference[]; + updatedAt?: string; } ) { const newAttributes: { @@ -76,6 +78,7 @@ export function mapHitSource( url: string; savedObjectType?: string; editUrl?: string; + updatedAt?: string; type?: BaseVisType; icon?: BaseVisType['icon']; image?: BaseVisType['image']; @@ -85,6 +88,7 @@ export function mapHitSource( id, references, url: urlFor(id), + updatedAt, ...attributes, }; diff --git a/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts b/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts index 2945aaa1a0cc8..f113a0a212fe6 100644 --- a/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts +++ b/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { SavedObject } from '@kbn/core/types/saved_objects'; +import type { SimpleSavedObject } from '@kbn/core/public'; import { BaseVisType } from './base_vis_type'; export type VisualizationStage = 'experimental' | 'beta' | 'production'; @@ -30,7 +30,7 @@ export interface VisualizationListItem { export interface VisualizationsAppExtension { docTypes: string[]; searchFields?: string[]; - toListItem: (savedObject: SavedObject) => VisualizationListItem; + toListItem: (savedObject: SimpleSavedObject) => VisualizationListItem; } export interface VisTypeAlias { diff --git a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts index 72cca61832ca0..202d13f9cd539 100644 --- a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts +++ b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts @@ -53,6 +53,7 @@ function mapHits(hit: any, url: string): GraphWorkspaceSavedObject { const source = hit.attributes; source.id = hit.id; source.url = url; + source.updatedAt = hit.updatedAt; source.icon = 'fa-share-alt'; // looks like a graph return source; } diff --git a/x-pack/plugins/lens/public/vis_type_alias.ts b/x-pack/plugins/lens/public/vis_type_alias.ts index be8a5620ce614..11a97ae82470f 100644 --- a/x-pack/plugins/lens/public/vis_type_alias.ts +++ b/x-pack/plugins/lens/public/vis_type_alias.ts @@ -31,12 +31,13 @@ export const getLensAliasConfig = (): VisTypeAlias => ({ docTypes: ['lens'], searchFields: ['title^3'], toListItem(savedObject) { - const { id, type, attributes } = savedObject; + const { id, type, updatedAt, attributes } = savedObject; const { title, description } = attributes as { title: string; description?: string }; return { id, title, description, + updatedAt, editUrl: getEditPath(id), editApp: 'lens', icon: 'lensApp', diff --git a/x-pack/plugins/maps/common/map_saved_object_type.ts b/x-pack/plugins/maps/common/map_saved_object_type.ts index b37c1af5949c1..f16683f56ef6d 100644 --- a/x-pack/plugins/maps/common/map_saved_object_type.ts +++ b/x-pack/plugins/maps/common/map_saved_object_type.ts @@ -7,8 +7,6 @@ /* eslint-disable @typescript-eslint/consistent-type-definitions */ -import { SavedObject } from '@kbn/core/types/saved_objects'; - export type MapSavedObjectAttributes = { title: string; description?: string; @@ -16,5 +14,3 @@ export type MapSavedObjectAttributes = { layerListJSON?: string; uiStateJSON?: string; }; - -export type MapSavedObject = SavedObject; diff --git a/x-pack/plugins/maps/public/maps_vis_type_alias.ts b/x-pack/plugins/maps/public/maps_vis_type_alias.ts index e6dad590b037a..911e886a8199e 100644 --- a/x-pack/plugins/maps/public/maps_vis_type_alias.ts +++ b/x-pack/plugins/maps/public/maps_vis_type_alias.ts @@ -7,8 +7,9 @@ import { i18n } from '@kbn/i18n'; import type { VisualizationsSetup, VisualizationStage } from '@kbn/visualizations-plugin/public'; +import type { SimpleSavedObject } from '@kbn/core/public'; import type { SavedObject } from '@kbn/core/types/saved_objects'; -import type { MapSavedObject } from '../common/map_saved_object_type'; +import type { MapSavedObjectAttributes } from '../common/map_saved_object_type'; import { APP_ID, APP_ICON, @@ -38,12 +39,15 @@ export function getMapsVisTypeAlias(visualizations: VisualizationsSetup) { docTypes: [MAP_SAVED_OBJECT_TYPE], searchFields: ['title^3'], toListItem(savedObject: SavedObject) { - const { id, type, attributes } = savedObject as MapSavedObject; + const { id, type, updatedAt, attributes } = + savedObject as SimpleSavedObject; const { title, description } = attributes; + return { id, title, description, + updatedAt, editUrl: getEditPath(id), editApp: APP_ID, icon: APP_ICON, diff --git a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx index 5aa8e7877628a..9278f08bd4d2d 100644 --- a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx +++ b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx @@ -113,6 +113,7 @@ async function findMaps(searchQuery: string) { title: savedObject.attributes.title, description: savedObject.attributes.description, references: savedObject.references, + updatedAt: savedObject.updatedAt, }; }), }; diff --git a/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts b/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts index cab4b98ffd784..213c1a6cde3ee 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts @@ -6,13 +6,13 @@ */ import { asyncForEach } from '@kbn/std'; -import { ISavedObjectsRepository } from '@kbn/core/server'; +import type { ISavedObjectsRepository, SavedObject } from '@kbn/core/server'; import { MAP_SAVED_OBJECT_TYPE } from '../../common/constants'; -import { MapSavedObject, MapSavedObjectAttributes } from '../../common/map_saved_object_type'; +import type { MapSavedObjectAttributes } from '../../common/map_saved_object_type'; export async function findMaps( savedObjectsClient: Pick, - callback: (savedObject: MapSavedObject) => Promise + callback: (savedObject: SavedObject) => Promise ) { let nextPage = 1; let hasMorePages = false; diff --git a/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts b/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts index ad1c0239963b4..dcbc9c884275d 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { SavedObject } from '@kbn/core/server'; import { asyncForEach } from '@kbn/std'; import { KBN_FIELD_TYPES } from '@kbn/field-types'; import { DataViewsService } from '@kbn/data-views-plugin/common'; @@ -15,7 +16,7 @@ import { ESSearchSourceDescriptor, LayerDescriptor, } from '../../../common/descriptor_types'; -import { MapSavedObject } from '../../../common/map_saved_object_type'; +import type { MapSavedObjectAttributes } from '../../../common/map_saved_object_type'; import { IndexPatternStats } from './types'; /* @@ -29,7 +30,7 @@ export class IndexPatternStatsCollector { this._indexPatternsService = indexPatternService; } - async push(savedObject: MapSavedObject) { + async push(savedObject: SavedObject) { let layerList: LayerDescriptor[] = []; try { const { attributes } = injectReferences(savedObject);