From 12aa46fad92220eb1ff5ae7e9d121bf38dfee0f3 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Wed, 23 Jun 2021 09:58:10 +0200 Subject: [PATCH 01/61] [Discover] Unskip Discover large field number test (#100692) --- test/functional/apps/discover/_huge_fields.ts | 13 ++++------ .../es_archiver/huge_fields/data.json.gz | Bin 0 -> 49227 bytes .../es_archiver/huge_fields/mappings.json | 24 ++++++++++++++++++ 3 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 test/functional/fixtures/es_archiver/huge_fields/data.json.gz create mode 100644 test/functional/fixtures/es_archiver/huge_fields/mappings.json diff --git a/test/functional/apps/discover/_huge_fields.ts b/test/functional/apps/discover/_huge_fields.ts index c7fe0a94b6019b..24b10e1df04956 100644 --- a/test/functional/apps/discover/_huge_fields.ts +++ b/test/functional/apps/discover/_huge_fields.ts @@ -15,21 +15,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const testSubjects = getService('testSubjects'); - // FLAKY: https://github.com/elastic/kibana/issues/96113 - describe.skip('test large number of fields in sidebar', function () { + describe('test large number of fields in sidebar', function () { before(async function () { + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/huge_fields'); await security.testUser.setRoles(['kibana_admin', 'test_testhuge_reader'], false); - await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/large_fields'); - await PageObjects.settings.navigateTo(); await kibanaServer.uiSettings.update({ 'timepicker:timeDefaults': `{ "from": "2016-10-05T00:00:00", "to": "2016-10-06T00:00:00"}`, }); - await PageObjects.settings.createIndexPattern('*huge*', 'date', true); await PageObjects.common.navigateToApp('discover'); }); it('test_huge data should have expected number of fields', async function () { - await PageObjects.discover.selectIndexPattern('*huge*'); + await PageObjects.discover.selectIndexPattern('testhuge*'); // initially this field should not be rendered const fieldExistsBeforeScrolling = await testSubjects.exists('field-myvar1050'); expect(fieldExistsBeforeScrolling).to.be(false); @@ -41,8 +38,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await security.testUser.restoreDefaults(); - await esArchiver.unload('test/functional/fixtures/es_archiver/large_fields'); - await kibanaServer.uiSettings.replace({}); + await esArchiver.unload('test/functional/fixtures/es_archiver/huge_fields'); + await kibanaServer.uiSettings.unset('timepicker:timeDefaults'); }); }); } diff --git a/test/functional/fixtures/es_archiver/huge_fields/data.json.gz b/test/functional/fixtures/es_archiver/huge_fields/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..1ce42c64c53a34db899fedd40bc012fa2e6769ba GIT binary patch literal 49227 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 BgdP9@ literal 0 HcmV?d00001 diff --git a/test/functional/fixtures/es_archiver/huge_fields/mappings.json b/test/functional/fixtures/es_archiver/huge_fields/mappings.json new file mode 100644 index 00000000000000..49a677a42f2ba6 --- /dev/null +++ b/test/functional/fixtures/es_archiver/huge_fields/mappings.json @@ -0,0 +1,24 @@ +{ + "type": "index", + "value": { + "index": "testhuge", + "mappings": { + "properties": { + "date": { + "type": "date" + } + } + }, + "settings": { + "index": { + "mapping": { + "total_fields": { + "limit": "50000" + } + }, + "number_of_replicas": "1", + "number_of_shards": "5" + } + } + } +} \ No newline at end of file From 131552176062243415ddd7beb54237b30ac4904d Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 23 Jun 2021 10:23:28 +0200 Subject: [PATCH 02/61] Ingest pipeline locator (#102878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 implement ingest pipeline locator * feat: 🎸 improve ingest pipeline locator * feat: 🎸 register ingest pipeline locator * refactor: 💡 use locator in expand_row component * chore: 🤖 remove ingest pipelines URL generator * fix: 🐛 correct TypeScript errors Co-authored-by: Vadim Kibana --- src/plugins/share/public/index.ts | 3 +- .../plugins/ingest_pipelines/public/index.ts | 7 -- .../ingest_pipelines/public/locator.test.ts | 100 ++++++++++++++++ .../ingest_pipelines/public/locator.ts | 102 +++++++++++++++++ .../plugins/ingest_pipelines/public/plugin.ts | 8 +- .../public/url_generator.test.ts | 108 ------------------ .../ingest_pipelines/public/url_generator.ts | 99 ---------------- .../models_management/expanded_row.tsx | 24 ++-- 8 files changed, 220 insertions(+), 231 deletions(-) create mode 100644 x-pack/plugins/ingest_pipelines/public/locator.test.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/locator.ts delete mode 100644 x-pack/plugins/ingest_pipelines/public/url_generator.test.ts delete mode 100644 x-pack/plugins/ingest_pipelines/public/url_generator.ts diff --git a/src/plugins/share/public/index.ts b/src/plugins/share/public/index.ts index 8f5356f6a22012..5ee3156534c5ef 100644 --- a/src/plugins/share/public/index.ts +++ b/src/plugins/share/public/index.ts @@ -7,7 +7,8 @@ */ export { CSV_QUOTE_VALUES_SETTING, CSV_SEPARATOR_SETTING } from '../common/constants'; -export { LocatorDefinition } from '../common/url_service'; + +export { LocatorDefinition, LocatorPublic, KibanaLocation } from '../common/url_service'; export { UrlGeneratorStateMapping } from './url_generators/url_generator_definition'; diff --git a/x-pack/plugins/ingest_pipelines/public/index.ts b/x-pack/plugins/ingest_pipelines/public/index.ts index 8948a3e8d56bef..d120f60ef8a2d1 100644 --- a/x-pack/plugins/ingest_pipelines/public/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/index.ts @@ -10,10 +10,3 @@ import { IngestPipelinesPlugin } from './plugin'; export function plugin() { return new IngestPipelinesPlugin(); } - -export { - INGEST_PIPELINES_APP_ULR_GENERATOR, - IngestPipelinesUrlGenerator, - IngestPipelinesUrlGeneratorState, - INGEST_PIPELINES_PAGES, -} from './url_generator'; diff --git a/x-pack/plugins/ingest_pipelines/public/locator.test.ts b/x-pack/plugins/ingest_pipelines/public/locator.test.ts new file mode 100644 index 00000000000000..0b1246b2bed59f --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/locator.test.ts @@ -0,0 +1,100 @@ +/* + * Copyright 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 { ManagementAppLocatorDefinition } from 'src/plugins/management/common/locator'; +import { IngestPipelinesLocatorDefinition, INGEST_PIPELINES_PAGES } from './locator'; + +describe('Ingest pipeline locator', () => { + const setup = () => { + const managementDefinition = new ManagementAppLocatorDefinition(); + const definition = new IngestPipelinesLocatorDefinition({ + managementAppLocator: { + getLocation: (params) => managementDefinition.getLocation(params), + getUrl: async () => { + throw new Error('not implemented'); + }, + navigate: async () => { + throw new Error('not implemented'); + }, + useUrl: () => '', + }, + }); + return { definition }; + }; + + describe('Pipelines List', () => { + it('generates relative url for list without pipelineId', async () => { + const { definition } = setup(); + const location = await definition.getLocation({ + page: INGEST_PIPELINES_PAGES.LIST, + }); + + expect(location).toMatchObject({ + app: 'management', + path: '/ingest/ingest_pipelines', + }); + }); + + it('generates relative url for list with a pipelineId', async () => { + const { definition } = setup(); + const location = await definition.getLocation({ + page: INGEST_PIPELINES_PAGES.LIST, + pipelineId: 'pipeline_name', + }); + + expect(location).toMatchObject({ + app: 'management', + path: '/ingest/ingest_pipelines/?pipeline=pipeline_name', + }); + }); + }); + + describe('Pipeline Edit', () => { + it('generates relative url for pipeline edit', async () => { + const { definition } = setup(); + const location = await definition.getLocation({ + page: INGEST_PIPELINES_PAGES.EDIT, + pipelineId: 'pipeline_name', + }); + + expect(location).toMatchObject({ + app: 'management', + path: '/ingest/ingest_pipelines/edit/pipeline_name', + }); + }); + }); + + describe('Pipeline Clone', () => { + it('generates relative url for pipeline clone', async () => { + const { definition } = setup(); + const location = await definition.getLocation({ + page: INGEST_PIPELINES_PAGES.CLONE, + pipelineId: 'pipeline_name', + }); + + expect(location).toMatchObject({ + app: 'management', + path: '/ingest/ingest_pipelines/create/pipeline_name', + }); + }); + }); + + describe('Pipeline Create', () => { + it('generates relative url for pipeline create', async () => { + const { definition } = setup(); + const location = await definition.getLocation({ + page: INGEST_PIPELINES_PAGES.CREATE, + pipelineId: 'pipeline_name', + }); + + expect(location).toMatchObject({ + app: 'management', + path: '/ingest/ingest_pipelines/create', + }); + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/locator.ts b/x-pack/plugins/ingest_pipelines/public/locator.ts new file mode 100644 index 00000000000000..d819011f14f470 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/locator.ts @@ -0,0 +1,102 @@ +/* + * Copyright 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 { SerializableState } from 'src/plugins/kibana_utils/common'; +import { ManagementAppLocator } from 'src/plugins/management/common'; +import { + LocatorPublic, + LocatorDefinition, + KibanaLocation, +} from '../../../../src/plugins/share/public'; +import { + getClonePath, + getCreatePath, + getEditPath, + getListPath, +} from './application/services/navigation'; +import { PLUGIN_ID } from '../common/constants'; + +export enum INGEST_PIPELINES_PAGES { + LIST = 'pipelines_list', + EDIT = 'pipeline_edit', + CREATE = 'pipeline_create', + CLONE = 'pipeline_clone', +} + +interface IngestPipelinesBaseParams extends SerializableState { + pipelineId: string; +} +export interface IngestPipelinesListParams extends Partial { + page: INGEST_PIPELINES_PAGES.LIST; +} + +export interface IngestPipelinesEditParams extends IngestPipelinesBaseParams { + page: INGEST_PIPELINES_PAGES.EDIT; +} + +export interface IngestPipelinesCloneParams extends IngestPipelinesBaseParams { + page: INGEST_PIPELINES_PAGES.CLONE; +} + +export interface IngestPipelinesCreateParams extends IngestPipelinesBaseParams { + page: INGEST_PIPELINES_PAGES.CREATE; +} + +export type IngestPipelinesParams = + | IngestPipelinesListParams + | IngestPipelinesEditParams + | IngestPipelinesCloneParams + | IngestPipelinesCreateParams; + +export type IngestPipelinesLocator = LocatorPublic; + +export const INGEST_PIPELINES_APP_LOCATOR = 'INGEST_PIPELINES_APP_LOCATOR'; + +export interface IngestPipelinesLocatorDependencies { + managementAppLocator: ManagementAppLocator; +} + +export class IngestPipelinesLocatorDefinition implements LocatorDefinition { + public readonly id = INGEST_PIPELINES_APP_LOCATOR; + + constructor(protected readonly deps: IngestPipelinesLocatorDependencies) {} + + public readonly getLocation = async (params: IngestPipelinesParams): Promise => { + const location = await this.deps.managementAppLocator.getLocation({ + sectionId: 'ingest', + appId: PLUGIN_ID, + }); + + let path: string = ''; + + switch (params.page) { + case INGEST_PIPELINES_PAGES.EDIT: + path = getEditPath({ + pipelineName: params.pipelineId, + }); + break; + case INGEST_PIPELINES_PAGES.CREATE: + path = getCreatePath(); + break; + case INGEST_PIPELINES_PAGES.LIST: + path = getListPath({ + inspectedPipelineName: params.pipelineId, + }); + break; + case INGEST_PIPELINES_PAGES.CLONE: + path = getClonePath({ + clonedPipelineName: params.pipelineId, + }); + break; + } + + return { + ...location, + path: path === '/' ? location.path : location.path + path, + }; + }; +} diff --git a/x-pack/plugins/ingest_pipelines/public/plugin.ts b/x-pack/plugins/ingest_pipelines/public/plugin.ts index 4a138a12d6819f..b4eb33162a1f4c 100644 --- a/x-pack/plugins/ingest_pipelines/public/plugin.ts +++ b/x-pack/plugins/ingest_pipelines/public/plugin.ts @@ -11,7 +11,7 @@ import { CoreSetup, Plugin } from 'src/core/public'; import { PLUGIN_ID } from '../common/constants'; import { uiMetricService, apiService } from './application/services'; import { SetupDependencies, StartDependencies } from './types'; -import { registerUrlGenerator } from './url_generator'; +import { IngestPipelinesLocatorDefinition } from './locator'; export class IngestPipelinesPlugin implements Plugin { @@ -50,7 +50,11 @@ export class IngestPipelinesPlugin }, }); - registerUrlGenerator(coreSetup, management, share); + share.url.locators.create( + new IngestPipelinesLocatorDefinition({ + managementAppLocator: management.locator, + }) + ); } public start() {} diff --git a/x-pack/plugins/ingest_pipelines/public/url_generator.test.ts b/x-pack/plugins/ingest_pipelines/public/url_generator.test.ts deleted file mode 100644 index dc45f9bc39088e..00000000000000 --- a/x-pack/plugins/ingest_pipelines/public/url_generator.test.ts +++ /dev/null @@ -1,108 +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 { IngestPipelinesUrlGenerator, INGEST_PIPELINES_PAGES } from './url_generator'; - -describe('IngestPipelinesUrlGenerator', () => { - const getAppBasePath = (absolute: boolean = false) => { - if (absolute) { - return Promise.resolve('http://localhost/app/test_app'); - } - return Promise.resolve('/app/test_app'); - }; - const urlGenerator = new IngestPipelinesUrlGenerator(getAppBasePath); - - describe('Pipelines List', () => { - it('generates relative url for list without pipelineId', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.LIST, - }); - expect(url).toBe('/app/test_app/'); - }); - - it('generates absolute url for list without pipelineId', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.LIST, - absolute: true, - }); - expect(url).toBe('http://localhost/app/test_app/'); - }); - it('generates relative url for list with a pipelineId', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.LIST, - pipelineId: 'pipeline_name', - }); - expect(url).toBe('/app/test_app/?pipeline=pipeline_name'); - }); - - it('generates absolute url for list with a pipelineId', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.LIST, - pipelineId: 'pipeline_name', - absolute: true, - }); - expect(url).toBe('http://localhost/app/test_app/?pipeline=pipeline_name'); - }); - }); - - describe('Pipeline Edit', () => { - it('generates relative url for pipeline edit', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.EDIT, - pipelineId: 'pipeline_name', - }); - expect(url).toBe('/app/test_app/edit/pipeline_name'); - }); - - it('generates absolute url for pipeline edit', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.EDIT, - pipelineId: 'pipeline_name', - absolute: true, - }); - expect(url).toBe('http://localhost/app/test_app/edit/pipeline_name'); - }); - }); - - describe('Pipeline Clone', () => { - it('generates relative url for pipeline clone', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.CLONE, - pipelineId: 'pipeline_name', - }); - expect(url).toBe('/app/test_app/create/pipeline_name'); - }); - - it('generates absolute url for pipeline clone', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.CLONE, - pipelineId: 'pipeline_name', - absolute: true, - }); - expect(url).toBe('http://localhost/app/test_app/create/pipeline_name'); - }); - }); - - describe('Pipeline Create', () => { - it('generates relative url for pipeline create', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.CREATE, - pipelineId: 'pipeline_name', - }); - expect(url).toBe('/app/test_app/create'); - }); - - it('generates absolute url for pipeline create', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.CREATE, - pipelineId: 'pipeline_name', - absolute: true, - }); - expect(url).toBe('http://localhost/app/test_app/create'); - }); - }); -}); diff --git a/x-pack/plugins/ingest_pipelines/public/url_generator.ts b/x-pack/plugins/ingest_pipelines/public/url_generator.ts deleted file mode 100644 index d9a77addcd5fd8..00000000000000 --- a/x-pack/plugins/ingest_pipelines/public/url_generator.ts +++ /dev/null @@ -1,99 +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 { CoreSetup } from 'src/core/public'; -import { MANAGEMENT_APP_ID } from '../../../../src/plugins/management/public'; -import { UrlGeneratorsDefinition } from '../../../../src/plugins/share/public'; -import { - getClonePath, - getCreatePath, - getEditPath, - getListPath, -} from './application/services/navigation'; -import { SetupDependencies } from './types'; -import { PLUGIN_ID } from '../common/constants'; - -export const INGEST_PIPELINES_APP_ULR_GENERATOR = 'INGEST_PIPELINES_APP_URL_GENERATOR'; - -export enum INGEST_PIPELINES_PAGES { - LIST = 'pipelines_list', - EDIT = 'pipeline_edit', - CREATE = 'pipeline_create', - CLONE = 'pipeline_clone', -} - -interface UrlGeneratorState { - pipelineId: string; - absolute?: boolean; -} -export interface PipelinesListUrlGeneratorState extends Partial { - page: INGEST_PIPELINES_PAGES.LIST; -} - -export interface PipelineEditUrlGeneratorState extends UrlGeneratorState { - page: INGEST_PIPELINES_PAGES.EDIT; -} - -export interface PipelineCloneUrlGeneratorState extends UrlGeneratorState { - page: INGEST_PIPELINES_PAGES.CLONE; -} - -export interface PipelineCreateUrlGeneratorState extends UrlGeneratorState { - page: INGEST_PIPELINES_PAGES.CREATE; -} - -export type IngestPipelinesUrlGeneratorState = - | PipelinesListUrlGeneratorState - | PipelineEditUrlGeneratorState - | PipelineCloneUrlGeneratorState - | PipelineCreateUrlGeneratorState; - -export class IngestPipelinesUrlGenerator - implements UrlGeneratorsDefinition { - constructor(private readonly getAppBasePath: (absolute: boolean) => Promise) {} - - public readonly id = INGEST_PIPELINES_APP_ULR_GENERATOR; - - public readonly createUrl = async (state: IngestPipelinesUrlGeneratorState): Promise => { - switch (state.page) { - case INGEST_PIPELINES_PAGES.EDIT: { - return `${await this.getAppBasePath(!!state.absolute)}${getEditPath({ - pipelineName: state.pipelineId, - })}`; - } - case INGEST_PIPELINES_PAGES.CREATE: { - return `${await this.getAppBasePath(!!state.absolute)}${getCreatePath()}`; - } - case INGEST_PIPELINES_PAGES.LIST: { - return `${await this.getAppBasePath(!!state.absolute)}${getListPath({ - inspectedPipelineName: state.pipelineId, - })}`; - } - case INGEST_PIPELINES_PAGES.CLONE: { - return `${await this.getAppBasePath(!!state.absolute)}${getClonePath({ - clonedPipelineName: state.pipelineId, - })}`; - } - } - }; -} - -export const registerUrlGenerator = ( - coreSetup: CoreSetup, - management: SetupDependencies['management'], - share: SetupDependencies['share'] -) => { - const getAppBasePath = async (absolute = false) => { - const [coreStart] = await coreSetup.getStartServices(); - return coreStart.application.getUrlForApp(MANAGEMENT_APP_ID, { - path: management.sections.section.ingest.getApp(PLUGIN_ID)!.basePath, - absolute: !!absolute, - }); - }; - - share.urlGenerators.registerUrlGenerator(new IngestPipelinesUrlGenerator(getAppBasePath)); -}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx index 88ffaa0da7fdcd..93be45bbdaf978 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx @@ -114,10 +114,7 @@ export const ExpandedRow: FC = ({ item }) => { } const { - services: { - share, - application: { navigateToUrl }, - }, + services: { share }, } = useMlKibana(); const tabs = [ @@ -402,17 +399,16 @@ export const ExpandedRow: FC = ({ item }) => { { - const ingestPipelinesAppUrlGenerator = share.urlGenerators.getUrlGenerator( - 'INGEST_PIPELINES_APP_URL_GENERATOR' - ); - await navigateToUrl( - await ingestPipelinesAppUrlGenerator.createUrl({ - page: 'pipeline_edit', - pipelineId: pipelineName, - absolute: true, - }) + onClick={() => { + const locator = share.url.locators.get( + 'INGEST_PIPELINES_APP_LOCATOR' ); + if (!locator) return; + locator.navigate({ + page: 'pipeline_edit', + pipelineId: pipelineName, + absolute: true, + }); }} > Date: Wed, 23 Jun 2021 10:27:27 +0200 Subject: [PATCH 03/61] [Lens] Avoid suggestion rendering and evaluation on fullscreen mode (#102757) --- .../editor_frame/editor_frame.test.tsx | 51 +++++++++++++++++++ .../editor_frame/editor_frame.tsx | 3 +- .../editor_frame/frame_layout.scss | 5 +- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index 52488cb32ae837..0e2ba5ce8ad59f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -1370,6 +1370,57 @@ describe('editor_frame', () => { }) ); }); + + it('should avoid completely to compute suggestion when in fullscreen mode', async () => { + const props = { + ...getDefaultProps(), + initialContext: { + indexPatternId: '1', + fieldName: 'test', + }, + visualizationMap: { + testVis: mockVisualization, + }, + datasourceMap: { + testDatasource: mockDatasource, + testDatasource2: mockDatasource2, + }, + + ExpressionRenderer: expressionRendererMock, + }; + + const { instance: el } = await mountWithProvider( + , + props.plugins.data + ); + instance = el; + + expect( + instance.find(FrameLayout).prop('suggestionsPanel') as ReactElement + ).not.toBeUndefined(); + + await act(async () => { + (instance.find(FrameLayout).prop('dataPanel') as ReactElement)!.props.dispatch({ + type: 'TOGGLE_FULLSCREEN', + }); + }); + + instance.update(); + + expect(instance.find(FrameLayout).prop('suggestionsPanel') as ReactElement).toBe(false); + + await act(async () => { + (instance.find(FrameLayout).prop('dataPanel') as ReactElement)!.props.dispatch({ + type: 'TOGGLE_FULLSCREEN', + }); + }); + + instance.update(); + + expect( + instance.find(FrameLayout).prop('suggestionsPanel') as ReactElement + ).not.toBeUndefined(); + }); }); describe('passing state back to the caller', () => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index cc65bb126d2d9e..bd96682f427fa6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -452,7 +452,8 @@ export function EditorFrame(props: EditorFrameProps) { ) } suggestionsPanel={ - allLoaded && ( + allLoaded && + !state.isFullscreenDatasource && ( Date: Wed, 23 Jun 2021 10:27:43 +0200 Subject: [PATCH 04/61] [Lens] Remove rank direction tooltip (#102886) --- .../operations/definitions/terms/index.tsx | 22 +++---------------- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 3 files changed, 3 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index 7551b88039182b..a650c556c4965d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -424,25 +424,9 @@ export const termsOperation: OperationDefinition - {i18n.translate('xpack.lens.indexPattern.terms.orderDirection', { - defaultMessage: 'Rank direction', - })}{' '} - - - } + label={i18n.translate('xpack.lens.indexPattern.terms.orderDirection', { + defaultMessage: 'Rank direction', + })} display="columnCompressed" fullWidth > diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0925c7c6db35f4..271916b3971f5d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12735,7 +12735,6 @@ "xpack.lens.indexPattern.terms.orderByHelp": "上位の値がランク付けされる条件となるディメンションを指定します。", "xpack.lens.indexPattern.terms.orderDescending": "降順", "xpack.lens.indexPattern.terms.orderDirection": "ランク方向", - "xpack.lens.indexPattern.terms.orderDirectionHelp": "上位の値のランク順序を指定します。", "xpack.lens.indexPattern.terms.otherBucketDescription": "他の値を「その他」としてグループ化", "xpack.lens.indexPattern.terms.otherLabel": "その他", "xpack.lens.indexPattern.terms.size": "値の数", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8dd2dd3ed985c4..945e25fcf962ed 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12906,7 +12906,6 @@ "xpack.lens.indexPattern.terms.orderByHelp": "指定排名靠前值排名所依据的维度。", "xpack.lens.indexPattern.terms.orderDescending": "降序", "xpack.lens.indexPattern.terms.orderDirection": "排名方向", - "xpack.lens.indexPattern.terms.orderDirectionHelp": "指定排名靠前值的排名顺序。", "xpack.lens.indexPattern.terms.otherBucketDescription": "将其他值分组为“其他”", "xpack.lens.indexPattern.terms.otherLabel": "其他", "xpack.lens.indexPattern.terms.size": "值数目", From a6bef93225c18d694fb9bc3bf38c5f1ff76a82d4 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 23 Jun 2021 05:15:12 -0400 Subject: [PATCH 05/61] [OsQuery] fix usage collector when .fleet indices are empty (#102977) --- x-pack/plugins/osquery/server/usage/fetchers.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/x-pack/plugins/osquery/server/usage/fetchers.ts b/x-pack/plugins/osquery/server/usage/fetchers.ts index 6a4236b5adccd3..3d5f3592101fd2 100644 --- a/x-pack/plugins/osquery/server/usage/fetchers.ts +++ b/x-pack/plugins/osquery/server/usage/fetchers.ts @@ -56,6 +56,7 @@ export async function getPolicyLevelUsage( }, }, index: '.fleet-agents', + ignore_unavailable: true, }); const policied = agentResponse.body.aggregations?.policied as AggregationsSingleBucketAggregate; if (policied && typeof policied.doc_count === 'number') { @@ -118,6 +119,7 @@ export async function getLiveQueryUsage( }, }, index: '.fleet-actions', + ignore_unavailable: true, }); const result: LiveQueryUsage = { session: await getRouteMetric(soClient, 'live_query'), @@ -226,6 +228,7 @@ export async function getBeatUsage(esClient: ElasticsearchClient) { }, }, index: METRICS_INDICES, + ignore_unavailable: true, }); return extractBeatUsageMetrics(metricResponse); From 38be1d06bc52ffa5298701eb02ba9f2fc3e09c4d Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Wed, 23 Jun 2021 05:10:57 -0500 Subject: [PATCH 06/61] [cli] Add kibana-encryption-keys.bat (#102070) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../bin/scripts/kibana-encryption-keys.bat | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100755 src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat diff --git a/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat b/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat new file mode 100755 index 00000000000000..9221af3142e613 --- /dev/null +++ b/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat @@ -0,0 +1,35 @@ +@echo off + +SETLOCAL ENABLEDELAYEDEXPANSION + +set SCRIPT_DIR=%~dp0 +for %%I in ("%SCRIPT_DIR%..") do set DIR=%%~dpfI + +set NODE=%DIR%\node\node.exe + +If Not Exist "%NODE%" ( + Echo unable to find usable node.js executable. + Exit /B 1 +) + +set CONFIG_DIR=%KBN_PATH_CONF% +If [%KBN_PATH_CONF%] == [] ( + set "CONFIG_DIR=%DIR%\config" +) + +IF EXIST "%CONFIG_DIR%\node.options" ( + for /F "usebackq eol=# tokens=*" %%i in ("%CONFIG_DIR%\node.options") do ( + If [!NODE_OPTIONS!] == [] ( + set "NODE_OPTIONS=%%i" + ) Else ( + set "NODE_OPTIONS=!NODE_OPTIONS! %%i" + ) + ) +) + +TITLE Kibana Encryption Keys +"%NODE%" "%DIR%\src\cli_encryption_keys\dist" %* + +:finally + +ENDLOCAL From e1ec8b05b63635923c861643d9c552c484b321c7 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 23 Jun 2021 11:11:13 +0100 Subject: [PATCH 07/61] chore(NA): moving @kbn/optimizer into bazel (#102965) * chore(NA): moving @kbn/optimizer into bazel * chore(NA): fix source import from kbn optimizer * chore(NA): update snapshots --- .../monorepo-packages.asciidoc | 1 + package.json | 2 +- packages/BUILD.bazel | 1 + packages/kbn-cli-dev-mode/package.json | 3 - packages/kbn-optimizer/BUILD.bazel | 120 ++++++++++++++++++ packages/kbn-optimizer/package.json | 7 +- .../basic_optimization.test.ts.snap | 2 +- .../src/worker/bundle_metrics_plugin.ts | 2 +- packages/kbn-optimizer/tsconfig.json | 3 +- packages/kbn-plugin-helpers/package.json | 3 - packages/kbn-test/package.json | 3 - .../kbn-test/src/jest/setup/babel_polyfill.js | 2 +- yarn.lock | 2 +- 13 files changed, 130 insertions(+), 21 deletions(-) create mode 100644 packages/kbn-optimizer/BUILD.bazel diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index e8b950a696f55d..5d7ba22841aa16 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -86,6 +86,7 @@ yarn kbn watch-bazel - @kbn/logging - @kbn/mapbox-gl - @kbn/monaco +- @kbn/optimizer - @kbn/rule-data-utils - @kbn/securitysolution-es-utils - @kbn/securitysolution-hook-utils diff --git a/package.json b/package.json index 9fc62dd69f1cfa..26465133569cd9 100644 --- a/package.json +++ b/package.json @@ -465,7 +465,7 @@ "@kbn/eslint-import-resolver-kibana": "link:bazel-bin/packages/kbn-eslint-import-resolver-kibana", "@kbn/eslint-plugin-eslint": "link:bazel-bin/packages/kbn-eslint-plugin-eslint", "@kbn/expect": "link:bazel-bin/packages/kbn-expect", - "@kbn/optimizer": "link:packages/kbn-optimizer", + "@kbn/optimizer": "link:bazel-bin/packages/kbn-optimizer", "@kbn/plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator", "@kbn/plugin-helpers": "link:packages/kbn-plugin-helpers", "@kbn/pm": "link:packages/kbn-pm", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 801f7cdd7f8dcd..d9e2f0e1f99854 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -29,6 +29,7 @@ filegroup( "//packages/kbn-logging:build", "//packages/kbn-mapbox-gl:build", "//packages/kbn-monaco:build", + "//packages/kbn-optimizer:build", "//packages/kbn-plugin-generator:build", "//packages/kbn-rule-data-utils:build", "//packages/kbn-securitysolution-list-constants:build", diff --git a/packages/kbn-cli-dev-mode/package.json b/packages/kbn-cli-dev-mode/package.json index dd491de55c075d..cf6fcfd88a26da 100644 --- a/packages/kbn-cli-dev-mode/package.json +++ b/packages/kbn-cli-dev-mode/package.json @@ -12,8 +12,5 @@ }, "kibana": { "devOnly": true - }, - "dependencies": { - "@kbn/optimizer": "link:../kbn-optimizer" } } \ No newline at end of file diff --git a/packages/kbn-optimizer/BUILD.bazel b/packages/kbn-optimizer/BUILD.bazel new file mode 100644 index 00000000000000..3809c2b33d5009 --- /dev/null +++ b/packages/kbn-optimizer/BUILD.bazel @@ -0,0 +1,120 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-optimizer" +PKG_REQUIRE_NAME = "@kbn/optimizer" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "**/*.test.*", + "**/__fixtures__/**", + "**/__snapshots__/**", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "limits.yml", + "package.json", + "postcss.config.js", + "README.md" +] + +SRC_DEPS = [ + "//packages/kbn-config", + "//packages/kbn-dev-utils", + "//packages/kbn-std", + "//packages/kbn-ui-shared-deps", + "//packages/kbn-utils", + "@npm//chalk", + "@npm//clean-webpack-plugin", + "@npm//compression-webpack-plugin", + "@npm//cpy", + "@npm//del", + "@npm//execa", + "@npm//jest-diff", + "@npm//json-stable-stringify", + "@npm//lmdb-store", + "@npm//loader-utils", + "@npm//node-sass", + "@npm//normalize-path", + "@npm//pirates", + "@npm//resize-observer-polyfill", + "@npm//rxjs", + "@npm//source-map-support", + "@npm//watchpack", + "@npm//webpack", + "@npm//webpack-merge", + "@npm//webpack-sources", + "@npm//zlib" +] + +TYPES_DEPS = [ + "@npm//@types/compression-webpack-plugin", + "@npm//@types/jest", + "@npm//@types/json-stable-stringify", + "@npm//@types/loader-utils", + "@npm//@types/node", + "@npm//@types/normalize-path", + "@npm//@types/source-map-support", + "@npm//@types/watchpack", + "@npm//@types/webpack", + "@npm//@types/webpack-merge", + "@npm//@types/webpack-sources", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = DEPS + [":tsc"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-optimizer/package.json b/packages/kbn-optimizer/package.json index a6c8284ad15f64..d23512f7c418d6 100644 --- a/packages/kbn-optimizer/package.json +++ b/packages/kbn-optimizer/package.json @@ -4,10 +4,5 @@ "private": true, "license": "SSPL-1.0 OR Elastic License 2.0", "main": "./target/index.js", - "types": "./target/index.d.ts", - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" - } + "types": "./target/index.d.ts" } \ No newline at end of file diff --git a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap index c175979f0e820e..1f1e33d3dda7c8 100644 --- a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap +++ b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap @@ -123,7 +123,7 @@ exports[`prepares assets for distribution: metrics.json 1`] = ` \\"group\\": \\"page load bundle size\\", \\"id\\": \\"foo\\", \\"value\\": 4627, - \\"limitConfigPath\\": \\"packages/kbn-optimizer/limits.yml\\" + \\"limitConfigPath\\": \\"node_modules/@kbn/optimizer/limits.yml\\" }, { \\"group\\": \\"async chunks size\\", diff --git a/packages/kbn-optimizer/src/worker/bundle_metrics_plugin.ts b/packages/kbn-optimizer/src/worker/bundle_metrics_plugin.ts index 92875d3f69e465..d9e1bee22557bf 100644 --- a/packages/kbn-optimizer/src/worker/bundle_metrics_plugin.ts +++ b/packages/kbn-optimizer/src/worker/bundle_metrics_plugin.ts @@ -79,7 +79,7 @@ export class BundleMetricsPlugin { id: bundle.id, value: entry.size, limit: bundle.pageLoadAssetSizeLimit, - limitConfigPath: `packages/kbn-optimizer/limits.yml`, + limitConfigPath: `node_modules/@kbn/optimizer/limits.yml`, }, { group: `async chunks size`, diff --git a/packages/kbn-optimizer/tsconfig.json b/packages/kbn-optimizer/tsconfig.json index f2d508cf14a55e..76beaf7689fd41 100644 --- a/packages/kbn-optimizer/tsconfig.json +++ b/packages/kbn-optimizer/tsconfig.json @@ -1,10 +1,11 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "./target", "declaration": true, "declarationMap": true, + "rootDir": "./src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-optimizer/src" }, diff --git a/packages/kbn-plugin-helpers/package.json b/packages/kbn-plugin-helpers/package.json index 2d642d9ede13bc..36a37075191a37 100644 --- a/packages/kbn-plugin-helpers/package.json +++ b/packages/kbn-plugin-helpers/package.json @@ -15,8 +15,5 @@ "scripts": { "kbn:bootstrap": "rm -rf target && ../../node_modules/.bin/tsc", "kbn:watch": "../../node_modules/.bin/tsc --watch" - }, - "dependencies": { - "@kbn/optimizer": "link:../kbn-optimizer" } } \ No newline at end of file diff --git a/packages/kbn-test/package.json b/packages/kbn-test/package.json index 275d9fac73c58d..aaff513f1591f2 100644 --- a/packages/kbn-test/package.json +++ b/packages/kbn-test/package.json @@ -12,8 +12,5 @@ }, "kibana": { "devOnly": true - }, - "dependencies": { - "@kbn/optimizer": "link:../kbn-optimizer" } } \ No newline at end of file diff --git a/packages/kbn-test/src/jest/setup/babel_polyfill.js b/packages/kbn-test/src/jest/setup/babel_polyfill.js index d112e4d4fcb393..7dda4cceec65cd 100644 --- a/packages/kbn-test/src/jest/setup/babel_polyfill.js +++ b/packages/kbn-test/src/jest/setup/babel_polyfill.js @@ -9,4 +9,4 @@ // Note: In theory importing the polyfill should not be needed, as Babel should // include the necessary polyfills when using `@babel/preset-env`, but for some // reason it did not work. See https://github.com/elastic/kibana/issues/14506 -import '@kbn/optimizer/src/node/polyfill'; +import '@kbn/optimizer/target/node/polyfill'; diff --git a/yarn.lock b/yarn.lock index 7a63284d20465d..7b0cd4dfe67acd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2692,7 +2692,7 @@ version "0.0.0" uid "" -"@kbn/optimizer@link:packages/kbn-optimizer": +"@kbn/optimizer@link:bazel-bin/packages/kbn-optimizer": version "0.0.0" uid "" From 0477c4dae41daf75fd2dcfc98ced09458d0f1bbf Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 23 Jun 2021 11:28:08 +0100 Subject: [PATCH 08/61] skip flaky suite (#84440) --- x-pack/test/functional/apps/grok_debugger/grok_debugger.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/grok_debugger/grok_debugger.js b/x-pack/test/functional/apps/grok_debugger/grok_debugger.js index 0162b660a14081..68cd5820e2a32f 100644 --- a/x-pack/test/functional/apps/grok_debugger/grok_debugger.js +++ b/x-pack/test/functional/apps/grok_debugger/grok_debugger.js @@ -11,7 +11,8 @@ export default function ({ getService, getPageObjects }) { const esArchiver = getService('esArchiver'); const PageObjects = getPageObjects(['grokDebugger']); - describe('grok debugger app', function () { + // FLAKY: https://github.com/elastic/kibana/issues/84440 + describe.skip('grok debugger app', function () { this.tags('includeFirefox'); before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/empty_kibana'); From 868ae59c933fd06cb4a8b05319870ea74a9eaf4e Mon Sep 17 00:00:00 2001 From: John Schulz Date: Wed, 23 Jun 2021 06:57:39 -0400 Subject: [PATCH 09/61] [Fleet] Support user overrides in composable templates (#101769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Closes #90454 Closes https://github.com/elastic/kibana/issues/72959 * Rename the component templates which are [installed for some packages](https://github.com/elastic/kibana/blob/master/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts#L197-L213) from `${templateName}-mappings` and `${templateName}-settings` to `${templateName}@mappings` and `${templateName}@settings` * When any package is installed, add a component template named `${templateName}@custom` * Any of above templates also include a `_meta` property with `{ package: { name: packageName } }` * On package installation, add any installed component templates to the `installed_es` property of the `epm-packages` saved object * On package removal, remove any installed component templates from the `installed_es` property of the `epm-packages` saved object
Kibana logs showing component templates added for package ``` │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [logs-endpoint.events.file@mappings] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [logs-endpoint.events.registry@mappings] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [.logs-endpoint.diagnostic.collection@mappings] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [logs-endpoint.events.library@mappings] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [logs-endpoint.events.security@mappings] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [logs-endpoint.events.network@mappings] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [logs-endpoint.alerts@mappings] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [metrics-endpoint.metrics@mappings] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [logs-endpoint.events.process@mappings] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [metrics-endpoint.policy@mappings] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [metrics-endpoint.metadata@mappings] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [logs-endpoint.events.registry@custom] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [.logs-endpoint.diagnostic.collection@custom] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [logs-endpoint.events.security@custom] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [logs-endpoint.events.file@custom] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [logs-endpoint.events.library@custom] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [logs-endpoint.events.network@custom] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [logs-endpoint.alerts@custom] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [metrics-endpoint.metrics@custom] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [metrics-endpoint.policy@custom] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [logs-endpoint.events.process@custom] │ info [o.e.c.m.MetadataIndexTemplateService] [JFSIII.local] adding component template [metrics-endpoint.metadata@custom] ```
screenshot - component templates are editable in the Stack Management UI Screen Shot 2021-06-17 at 4 06 24 PM
### Checklist Delete any items that are not applicable to this PR. - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../plugins/fleet/common/types/models/epm.ts | 7 +- .../epm/elasticsearch/template/install.ts | 235 +++++++++++------- .../epm/elasticsearch/template/template.ts | 8 +- .../services/epm/packages/_install_package.ts | 7 +- .../server/services/epm/packages/install.ts | 16 +- .../server/services/epm/packages/remove.ts | 51 +++- x-pack/plugins/fleet/server/types/index.tsx | 2 +- .../epm/__snapshots__/install_by_upload.snap | 12 + .../apis/epm/install_by_upload.ts | 4 +- .../apis/epm/install_overrides.ts | 157 ++++++++---- .../apis/epm/install_remove_assets.ts | 65 ++++- .../apis/epm/update_assets.ts | 56 ++++- .../0.1.0/img/logo_overrides_64_color.svg | 7 + .../error_handling/0.1.0/manifest.yml | 6 +- .../0.2.0/img/logo_overrides_64_color.svg | 7 + .../error_handling/0.2.0/manifest.yml | 6 +- x-pack/test/fleet_api_integration/config.ts | 10 +- 17 files changed, 452 insertions(+), 204 deletions(-) create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/img/logo_overrides_64_color.svg create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/img/logo_overrides_64_color.svg diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index aece6580831960..c4441fb6e0d95b 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { estypes } from '@elastic/elasticsearch'; // Follow pattern from https://github.com/elastic/kibana/pull/52447 // TODO: Update when https://github.com/elastic/kibana/issues/53021 is closed import type { SavedObject, SavedObjectAttributes, SavedObjectReference } from 'src/core/public'; @@ -299,8 +300,8 @@ export interface RegistryDataStream { } export interface RegistryElasticsearch { - 'index_template.settings'?: object; - 'index_template.mappings'?: object; + 'index_template.settings'?: estypes.IndicesIndexSettings; + 'index_template.mappings'?: estypes.MappingTypeMapping; } export interface RegistryDataStreamPermissions { @@ -425,7 +426,7 @@ export interface IndexTemplate { _meta: object; } -export interface TemplateRef { +export interface IndexTemplateEntry { templateName: string; indexTemplate: IndexTemplate; } diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index d202dab54f5bdc..db1fba1eedccde 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -11,7 +11,7 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/s import { ElasticsearchAssetType } from '../../../../types'; import type { RegistryDataStream, - TemplateRef, + IndexTemplateEntry, RegistryElasticsearch, InstallablePackage, } from '../../../../types'; @@ -19,7 +19,7 @@ import { loadFieldsFromYaml, processFields } from '../../fields/field'; import type { Field } from '../../fields/field'; import { getPipelineNameForInstallation } from '../ingest_pipeline/install'; import { getAsset, getPathParts } from '../../archive'; -import { removeAssetsFromInstalledEsByType, saveInstalledEsRefs } from '../../packages/install'; +import { removeAssetTypesFromInstalledEs, saveInstalledEsRefs } from '../../packages/install'; import { generateMappings, @@ -34,7 +34,7 @@ export const installTemplates = async ( esClient: ElasticsearchClient, paths: string[], savedObjectsClient: SavedObjectsClientContract -): Promise => { +): Promise => { // install any pre-built index template assets, // atm, this is only the base package's global index templates // Install component templates first, as they are used by the index templates @@ -42,44 +42,36 @@ export const installTemplates = async ( await installPreBuiltTemplates(paths, esClient); // remove package installation's references to index templates - await removeAssetsFromInstalledEsByType( - savedObjectsClient, - installablePackage.name, - ElasticsearchAssetType.indexTemplate - ); + await removeAssetTypesFromInstalledEs(savedObjectsClient, installablePackage.name, [ + ElasticsearchAssetType.indexTemplate, + ElasticsearchAssetType.componentTemplate, + ]); // build templates per data stream from yml files const dataStreams = installablePackage.data_streams; if (!dataStreams) return []; + + const installedTemplatesNested = await Promise.all( + dataStreams.map((dataStream) => + installTemplateForDataStream({ + pkg: installablePackage, + esClient, + dataStream, + }) + ) + ); + const installedTemplates = installedTemplatesNested.flat(); + // get template refs to save - const installedTemplateRefs = dataStreams.map((dataStream) => ({ - id: generateTemplateName(dataStream), - type: ElasticsearchAssetType.indexTemplate, - })); + const installedIndexTemplateRefs = getAllTemplateRefs(installedTemplates); // add package installation's references to index templates - await saveInstalledEsRefs(savedObjectsClient, installablePackage.name, installedTemplateRefs); - - if (dataStreams) { - const installTemplatePromises = dataStreams.reduce>>( - (acc, dataStream) => { - acc.push( - installTemplateForDataStream({ - pkg: installablePackage, - esClient, - dataStream, - }) - ); - return acc; - }, - [] - ); - - const res = await Promise.all(installTemplatePromises); - const installedTemplates = res.flat(); + await saveInstalledEsRefs( + savedObjectsClient, + installablePackage.name, + installedIndexTemplateRefs + ); - return installedTemplates; - } - return []; + return installedTemplates; }; const installPreBuiltTemplates = async (paths: string[], esClient: ElasticsearchClient) => { @@ -160,7 +152,7 @@ export async function installTemplateForDataStream({ pkg: InstallablePackage; esClient: ElasticsearchClient; dataStream: RegistryDataStream; -}): Promise { +}): Promise { const fields = await loadFieldsFromYaml(pkg, dataStream.path); return installTemplate({ esClient, @@ -171,84 +163,118 @@ export async function installTemplateForDataStream({ }); } +interface TemplateMapEntry { + _meta: { package: { name: string } }; + template: + | { + mappings: NonNullable; + } + | { + settings: NonNullable | object; + }; +} +type TemplateMap = Record; function putComponentTemplate( - body: object | undefined, - name: string, - esClient: ElasticsearchClient -): { clusterPromise: Promise; name: string } | undefined { - if (body) { - const esClientParams = { - name, - body, - }; - - return { - // @ts-expect-error body expected to be ClusterPutComponentTemplateRequest - clusterPromise: esClient.cluster.putComponentTemplate(esClientParams, { ignore: [404] }), - name, - }; + esClient: ElasticsearchClient, + params: { + body: TemplateMapEntry; + name: string; + create?: boolean; } +): { clusterPromise: Promise; name: string } { + const { name, body, create = false } = params; + return { + clusterPromise: esClient.cluster.putComponentTemplate( + // @ts-expect-error body is missing required key `settings`. TemplateMapEntry has settings *or* mappings + { name, body, create }, + { ignore: [404] } + ), + name, + }; } -function buildComponentTemplates(registryElasticsearch: RegistryElasticsearch | undefined) { - let mappingsTemplate; - let settingsTemplate; +const mappingsSuffix = '@mappings'; +const settingsSuffix = '@settings'; +const userSettingsSuffix = '@custom'; +type TemplateBaseName = string; +type UserSettingsTemplateName = `${TemplateBaseName}${typeof userSettingsSuffix}`; + +const isUserSettingsTemplate = (name: string): name is UserSettingsTemplateName => + name.endsWith(userSettingsSuffix); + +function buildComponentTemplates(params: { + templateName: string; + registryElasticsearch: RegistryElasticsearch | undefined; + packageName: string; +}) { + const { templateName, registryElasticsearch, packageName } = params; + const mappingsTemplateName = `${templateName}${mappingsSuffix}`; + const settingsTemplateName = `${templateName}${settingsSuffix}`; + const userSettingsTemplateName = `${templateName}${userSettingsSuffix}`; + + const templatesMap: TemplateMap = {}; + const _meta = { package: { name: packageName } }; if (registryElasticsearch && registryElasticsearch['index_template.mappings']) { - mappingsTemplate = { + templatesMap[mappingsTemplateName] = { template: { - mappings: { - ...registryElasticsearch['index_template.mappings'], - }, + mappings: registryElasticsearch['index_template.mappings'], }, + _meta, }; } if (registryElasticsearch && registryElasticsearch['index_template.settings']) { - settingsTemplate = { + templatesMap[settingsTemplateName] = { template: { settings: registryElasticsearch['index_template.settings'], }, + _meta, }; } - return { settingsTemplate, mappingsTemplate }; -} -async function installDataStreamComponentTemplates( - templateName: string, - registryElasticsearch: RegistryElasticsearch | undefined, - esClient: ElasticsearchClient -) { - const templates: string[] = []; - const componentPromises: Array> = []; + // return empty/stub template + templatesMap[userSettingsTemplateName] = { + template: { + settings: {}, + }, + _meta, + }; - const compTemplates = buildComponentTemplates(registryElasticsearch); + return templatesMap; +} - const mappings = putComponentTemplate( - compTemplates.mappingsTemplate, - `${templateName}-mappings`, - esClient - ); +async function installDataStreamComponentTemplates(params: { + templateName: string; + registryElasticsearch: RegistryElasticsearch | undefined; + esClient: ElasticsearchClient; + packageName: string; +}) { + const { templateName, registryElasticsearch, esClient, packageName } = params; + const templates = buildComponentTemplates({ templateName, registryElasticsearch, packageName }); + const templateNames = Object.keys(templates); + const templateEntries = Object.entries(templates); - const settings = putComponentTemplate( - compTemplates.settingsTemplate, - `${templateName}-settings`, - esClient + // TODO: Check return values for errors + await Promise.all( + templateEntries.map(async ([name, body]) => { + if (isUserSettingsTemplate(name)) { + // look for existing user_settings template + const result = await esClient.cluster.getComponentTemplate({ name }, { ignore: [404] }); + const hasUserSettingsTemplate = result.body.component_templates?.length === 1; + if (!hasUserSettingsTemplate) { + // only add if one isn't already present + const { clusterPromise } = putComponentTemplate(esClient, { body, name, create: true }); + return clusterPromise; + } + } else { + const { clusterPromise } = putComponentTemplate(esClient, { body, name }); + return clusterPromise; + } + }) ); - if (mappings) { - templates.push(mappings.name); - componentPromises.push(mappings.clusterPromise); - } - - if (settings) { - templates.push(settings.name); - componentPromises.push(settings.clusterPromise); - } - - // TODO: Check return values for errors - await Promise.all(componentPromises); - return templates; + return templateNames; } export async function installTemplate({ @@ -263,7 +289,7 @@ export async function installTemplate({ dataStream: RegistryDataStream; packageVersion: string; packageName: string; -}): Promise { +}): Promise { const validFields = processFields(fields); const mappings = generateMappings(validFields); const templateName = generateTemplateName(dataStream); @@ -310,11 +336,12 @@ export async function installTemplate({ await esClient.indices.putIndexTemplate(updateIndexTemplateParams, { ignore: [404] }); } - const composedOfTemplates = await installDataStreamComponentTemplates( + const composedOfTemplates = await installDataStreamComponentTemplates({ templateName, - dataStream.elasticsearch, - esClient - ); + registryElasticsearch: dataStream.elasticsearch, + esClient, + packageName, + }); const template = getTemplate({ type: dataStream.type, @@ -342,3 +369,21 @@ export async function installTemplate({ indexTemplate: template, }; } + +export function getAllTemplateRefs(installedTemplates: IndexTemplateEntry[]) { + return installedTemplates.flatMap((installedTemplate) => { + const indexTemplates = [ + { + id: installedTemplate.templateName, + type: ElasticsearchAssetType.indexTemplate, + }, + ]; + const componentTemplates = installedTemplate.indexTemplate.composed_of.map( + (componentTemplateId) => ({ + id: componentTemplateId, + type: ElasticsearchAssetType.componentTemplate, + }) + ); + return indexTemplates.concat(componentTemplates); + }); +} diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 07d0df021c827b..158996cc574d7a 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -10,7 +10,7 @@ import type { ElasticsearchClient } from 'kibana/server'; import type { Field, Fields } from '../../fields/field'; import type { RegistryDataStream, - TemplateRef, + IndexTemplateEntry, IndexTemplate, IndexTemplateMappings, } from '../../../../types'; @@ -456,7 +456,7 @@ function getBaseTemplate( export const updateCurrentWriteIndices = async ( esClient: ElasticsearchClient, - templates: TemplateRef[] + templates: IndexTemplateEntry[] ): Promise => { if (!templates.length) return; @@ -471,7 +471,7 @@ function isCurrentDataStream(item: CurrentDataStream[] | undefined): item is Cur const queryDataStreamsFromTemplates = async ( esClient: ElasticsearchClient, - templates: TemplateRef[] + templates: IndexTemplateEntry[] ): Promise => { const dataStreamPromises = templates.map((template) => { return getDataStreams(esClient, template); @@ -482,7 +482,7 @@ const queryDataStreamsFromTemplates = async ( const getDataStreams = async ( esClient: ElasticsearchClient, - template: TemplateRef + template: IndexTemplateEntry ): Promise => { const { templateName, indexTemplate } = template; const { body } = await esClient.indices.getDataStream({ name: `${templateName}-*` }); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts index 65d71ac5fdc179..1bbbb1bb9b6a24 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts @@ -10,10 +10,10 @@ import type { ElasticsearchClient, SavedObject, SavedObjectsClientContract } fro import { MAX_TIME_COMPLETE_INSTALL, ASSETS_SAVED_OBJECT_TYPE } from '../../../../common'; import type { InstallablePackage, InstallSource, PackageAssetReference } from '../../../../common'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; -import { ElasticsearchAssetType } from '../../../types'; import type { AssetReference, Installation, InstallType } from '../../../types'; import { installTemplates } from '../elasticsearch/template/install'; import { installPipelines, deletePreviousPipelines } from '../elasticsearch/ingest_pipeline/'; +import { getAllTemplateRefs } from '../elasticsearch/template/install'; import { installILMPolicy } from '../elasticsearch/ilm/install'; import { installKibanaAssets, getKibanaAssets } from '../kibana/assets/install'; import { updateCurrentWriteIndices } from '../elasticsearch/template/template'; @@ -170,10 +170,7 @@ export async function _installPackage({ installedPkg.attributes.install_version ); } - const installedTemplateRefs = installedTemplates.map((template) => ({ - id: template.templateName, - type: ElasticsearchAssetType.indexTemplate, - })); + const installedTemplateRefs = getAllTemplateRefs(installedTemplates); // make sure the assets are installed (or didn't error) if (installKibanaAssetsError) throw installKibanaAssetsError; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index c6fd9a8f763ab4..e00526cbb4ec46 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -257,8 +257,7 @@ async function installPackageFromRegistry({ const { paths, packageInfo } = await Registry.getRegistryPackage(pkgName, pkgVersion); // try installing the package, if there was an error, call error handler and rethrow - // TODO: without the ts-ignore, TS complains about the type of the value of the returned InstallResult.status - // @ts-ignore + // @ts-expect-error status is string instead of InstallResult.status 'installed' | 'already_installed' return _installPackage({ savedObjectsClient, esClient, @@ -334,8 +333,7 @@ async function installPackageByUpload({ version: packageInfo.version, packageInfo, }); - // TODO: without the ts-ignore, TS complains about the type of the value of the returned InstallResult.status - // @ts-ignore + // @ts-expect-error status is string instead of InstallResult.status 'installed' | 'already_installed' return _installPackage({ savedObjectsClient, esClient, @@ -484,17 +482,17 @@ export const saveInstalledEsRefs = async ( return installedAssets; }; -export const removeAssetsFromInstalledEsByType = async ( +export const removeAssetTypesFromInstalledEs = async ( savedObjectsClient: SavedObjectsClientContract, pkgName: string, - assetType: AssetType + assetTypes: AssetType[] ) => { const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName }); const installedAssets = installedPkg?.attributes.installed_es; if (!installedAssets?.length) return; - const installedAssetsToSave = installedAssets?.filter(({ id, type }) => { - return type !== assetType; - }); + const installedAssetsToSave = installedAssets?.filter( + (asset) => !assetTypes.includes(asset.type) + ); return savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { installed_es: installedAssetsToSave, diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts index 706f1bbbaaf35b..70167d1156a667 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts @@ -89,13 +89,18 @@ function deleteKibanaAssets( }); } -function deleteESAssets(installedObjects: EsAssetReference[], esClient: ElasticsearchClient) { +function deleteESAssets( + installedObjects: EsAssetReference[], + esClient: ElasticsearchClient +): Array> { return installedObjects.map(async ({ id, type }) => { const assetType = type as AssetType; if (assetType === ElasticsearchAssetType.ingestPipeline) { return deletePipeline(esClient, id); } else if (assetType === ElasticsearchAssetType.indexTemplate) { - return deleteTemplate(esClient, id); + return deleteIndexTemplate(esClient, id); + } else if (assetType === ElasticsearchAssetType.componentTemplate) { + return deleteComponentTemplate(esClient, id); } else if (assetType === ElasticsearchAssetType.transform) { return deleteTransforms(esClient, [id]); } else if (assetType === ElasticsearchAssetType.dataStreamIlmPolicy) { @@ -111,13 +116,30 @@ async function deleteAssets( ) { const logger = appContextService.getLogger(); - const deletePromises: Array> = [ - ...deleteESAssets(installedEs, esClient), - ...deleteKibanaAssets(installedKibana, savedObjectsClient), - ]; + // must delete index templates first, or component templates which reference them cannot be deleted + // separate the assets into Index Templates and other assets + type Tuple = [EsAssetReference[], EsAssetReference[]]; + const [indexTemplates, otherAssets] = installedEs.reduce( + ([indexAssetTypes, otherAssetTypes], asset) => { + if (asset.type === ElasticsearchAssetType.indexTemplate) { + indexAssetTypes.push(asset); + } else { + otherAssetTypes.push(asset); + } + + return [indexAssetTypes, otherAssetTypes]; + }, + [[], []] + ); try { - await Promise.all(deletePromises); + // must delete index templates first + await Promise.all(deleteESAssets(indexTemplates, esClient)); + // then the other asset types + await Promise.all([ + ...deleteESAssets(otherAssets, esClient), + ...deleteKibanaAssets(installedKibana, savedObjectsClient), + ]); } catch (err) { // in the rollback case, partial installs are likely, so missing assets are not an error if (!savedObjectsClient.errors.isNotFoundError(err)) { @@ -126,13 +148,24 @@ async function deleteAssets( } } -async function deleteTemplate(esClient: ElasticsearchClient, name: string): Promise { +async function deleteIndexTemplate(esClient: ElasticsearchClient, name: string): Promise { // '*' shouldn't ever appear here, but it still would delete all templates if (name && name !== '*') { try { await esClient.indices.deleteIndexTemplate({ name }, { ignore: [404] }); } catch { - throw new Error(`error deleting template ${name}`); + throw new Error(`error deleting index template ${name}`); + } + } +} + +async function deleteComponentTemplate(esClient: ElasticsearchClient, name: string): Promise { + // '*' shouldn't ever appear here, but it still would delete all templates + if (name && name !== '*') { + try { + await esClient.cluster.deleteComponentTemplate({ name }, { ignore: [404] }); + } catch (error) { + throw new Error(`error deleting component template ${name}`); } } } diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index 8927676976457a..0c08a09e76f4ea 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -63,7 +63,7 @@ export { IndexTemplate, RegistrySearchResults, RegistrySearchResult, - TemplateRef, + IndexTemplateEntry, IndexTemplateMappings, Settings, SettingsSOAttributes, diff --git a/x-pack/test/fleet_api_integration/apis/epm/__snapshots__/install_by_upload.snap b/x-pack/test/fleet_api_integration/apis/epm/__snapshots__/install_by_upload.snap index 7584dfcc8a6c06..13c2dd24f91037 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/__snapshots__/install_by_upload.snap +++ b/x-pack/test/fleet_api_integration/apis/epm/__snapshots__/install_by_upload.snap @@ -341,14 +341,26 @@ Object { "id": "logs-apache.access", "type": "index_template", }, + Object { + "id": "logs-apache.access@custom", + "type": "component_template", + }, Object { "id": "metrics-apache.status", "type": "index_template", }, + Object { + "id": "metrics-apache.status@custom", + "type": "component_template", + }, Object { "id": "logs-apache.error", "type": "index_template", }, + Object { + "id": "logs-apache.error@custom", + "type": "component_template", + }, ], "installed_kibana": Array [ Object { diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_by_upload.ts b/x-pack/test/fleet_api_integration/apis/epm/install_by_upload.ts index 71cf7ed79fa2ba..182838f21dbda4 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_by_upload.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_by_upload.ts @@ -70,7 +70,7 @@ export default function (providerContext: FtrProviderContext) { .type('application/gzip') .send(buf) .expect(200); - expect(res.body.response.length).to.be(23); + expect(res.body.response.length).to.be(26); }); it('should install a zip archive correctly and package info should return correctly after validation', async function () { @@ -81,7 +81,7 @@ export default function (providerContext: FtrProviderContext) { .type('application/zip') .send(buf) .expect(200); - expect(res.body.response.length).to.be(23); + expect(res.body.response.length).to.be(26); const packageInfoRes = await supertest .get(`/api/fleet/epm/packages/${testPkgKey}`) diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_overrides.ts b/x-pack/test/fleet_api_integration/apis/epm/install_overrides.ts index 1b916dff573af9..204ee8508f468c 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_overrides.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_overrides.ts @@ -7,22 +7,22 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; -import { warnAndSkipTest } from '../../helpers'; +import { skipIfNoDockerRegistry } from '../../helpers'; -export default function ({ getService }: FtrProviderContext) { +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; const supertest = getService('supertest'); const es = getService('es'); const dockerServers = getService('dockerServers'); - const log = getService('log'); const mappingsPackage = 'overrides-0.1.0'; const server = dockerServers.get('registry'); - const deletePackage = async (pkgkey: string) => { - await supertest.delete(`/api/fleet/epm/packages/${pkgkey}`).set('kbn-xsrf', 'xxxx'); - }; + const deletePackage = async (pkgkey: string) => + supertest.delete(`/api/fleet/epm/packages/${pkgkey}`).set('kbn-xsrf', 'xxxx'); describe('installs packages that include settings and mappings overrides', async () => { + skipIfNoDockerRegistry(providerContext); after(async () => { if (server.enabled) { // remove the package just in case it being installed will affect other tests @@ -31,50 +31,107 @@ export default function ({ getService }: FtrProviderContext) { }); it('should install the overrides package correctly', async function () { - if (server.enabled) { - let { body } = await supertest - .post(`/api/fleet/epm/packages/${mappingsPackage}`) - .set('kbn-xsrf', 'xxxx') - .expect(200); - - const templateName = body.response[0].id; - - ({ body } = await es.transport.request({ - method: 'GET', - path: `/_index_template/${templateName}`, - })); - - // make sure it has the right composed_of array, the contents should be the component templates - // that were installed - expect(body.index_templates[0].index_template.composed_of).to.contain( - `${templateName}-mappings` - ); - expect(body.index_templates[0].index_template.composed_of).to.contain( - `${templateName}-settings` - ); - - ({ body } = await es.transport.request({ - method: 'GET', - path: `/_component_template/${templateName}-mappings`, - })); - - // Make sure that the `dynamic` field exists and is set to false (as it is in the package) - expect(body.component_templates[0].component_template.template.mappings.dynamic).to.be( - false - ); - - ({ body } = await es.transport.request({ - method: 'GET', - path: `/_component_template/${templateName}-settings`, - })); - - // Make sure that the lifecycle name gets set correct in the settings - expect( - body.component_templates[0].component_template.template.settings.index.lifecycle.name - ).to.be('reference'); - } else { - warnAndSkipTest(this, log); - } + let { body } = await supertest + .post(`/api/fleet/epm/packages/${mappingsPackage}`) + .set('kbn-xsrf', 'xxxx') + .expect(200); + + const templateName = body.response[0].id; + + const { body: indexTemplateResponse } = await es.transport.request({ + method: 'GET', + path: `/_index_template/${templateName}`, + }); + + // the index template composed_of has the correct component templates in the correct order + const indexTemplate = indexTemplateResponse.index_templates[0].index_template; + expect(indexTemplate.composed_of).to.eql([ + `${templateName}@mappings`, + `${templateName}@settings`, + `${templateName}@custom`, + ]); + + ({ body } = await es.transport.request({ + method: 'GET', + path: `/_component_template/${templateName}@mappings`, + })); + + // The mappings override provided in the package is set in the mappings component template + expect(body.component_templates[0].component_template.template.mappings.dynamic).to.be(false); + + ({ body } = await es.transport.request({ + method: 'GET', + path: `/_component_template/${templateName}@settings`, + })); + + // The settings override provided in the package is set in the settings component template + expect( + body.component_templates[0].component_template.template.settings.index.lifecycle.name + ).to.be('reference'); + + ({ body } = await es.transport.request({ + method: 'GET', + path: `/_component_template/${templateName}@custom`, + })); + + // The user_settings component template is an empty/stub template at first + const storedTemplate = body.component_templates[0].component_template.template.settings; + expect(storedTemplate).to.eql({}); + + // Update the user_settings component template + ({ body } = await es.transport.request({ + method: 'PUT', + path: `/_component_template/${templateName}@custom`, + body: { + template: { + settings: { + number_of_shards: 3, + index: { + lifecycle: { name: 'overridden by user' }, + number_of_shards: 123, + }, + }, + }, + }, + })); + + // simulate the result + ({ body } = await es.transport.request({ + method: 'POST', + path: `/_index_template/_simulate/${templateName}`, + // body: indexTemplate, // I *think* this should work, but it doesn't + body: { + index_patterns: [`${templateName}-*`], + composed_of: [ + `${templateName}@mappings`, + `${templateName}@settings`, + `${templateName}@custom`, + ], + }, + })); + + expect(body).to.eql({ + template: { + settings: { + index: { + lifecycle: { + name: 'overridden by user', + }, + number_of_shards: '3', + }, + }, + mappings: { + dynamic: 'false', + }, + aliases: {}, + }, + overlapping: [ + { + name: 'logs', + index_patterns: ['logs-*-*'], + }, + ], + }); }); }); } diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts index 8e09e331bf8678..85573560177eea 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -87,6 +87,40 @@ export default function (providerContext: FtrProviderContext) { ); expect(resMetricsTemplate.statusCode).equal(404); }); + it('should have uninstalled the component templates', async function () { + const resMappings = await es.transport.request( + { + method: 'GET', + path: `/_component_template/${logsTemplateName}@mappings`, + }, + { + ignore: [404], + } + ); + expect(resMappings.statusCode).equal(404); + + const resSettings = await es.transport.request( + { + method: 'GET', + path: `/_component_template/${logsTemplateName}@settings`, + }, + { + ignore: [404], + } + ); + expect(resSettings.statusCode).equal(404); + + const resUserSettings = await es.transport.request( + { + method: 'GET', + path: `/_component_template/${logsTemplateName}@custom`, + }, + { + ignore: [404], + } + ); + expect(resUserSettings.statusCode).equal(404); + }); it('should have uninstalled the pipelines', async function () { const res = await es.transport.request( { @@ -328,17 +362,22 @@ const expectAssetsInstalled = ({ }); expect(resPipeline2.statusCode).equal(200); }); - it('should have installed the template components', async function () { - const res = await es.transport.request({ + it('should have installed the component templates', async function () { + const resMappings = await es.transport.request({ method: 'GET', - path: `/_component_template/${logsTemplateName}-mappings`, + path: `/_component_template/${logsTemplateName}@mappings`, }); - expect(res.statusCode).equal(200); + expect(resMappings.statusCode).equal(200); const resSettings = await es.transport.request({ method: 'GET', - path: `/_component_template/${logsTemplateName}-settings`, + path: `/_component_template/${logsTemplateName}@settings`, }); expect(resSettings.statusCode).equal(200); + const resUserSettings = await es.transport.request({ + method: 'GET', + path: `/_component_template/${logsTemplateName}@custom`, + }); + expect(resUserSettings.statusCode).equal(200); }); it('should have installed the transform components', async function () { const res = await es.transport.request({ @@ -487,6 +526,22 @@ const expectAssetsInstalled = ({ }, ], installed_es: [ + { + id: 'logs-all_assets.test_logs@mappings', + type: 'component_template', + }, + { + id: 'logs-all_assets.test_logs@settings', + type: 'component_template', + }, + { + id: 'logs-all_assets.test_logs@custom', + type: 'component_template', + }, + { + id: 'metrics-all_assets.test_metrics@custom', + type: 'component_template', + }, { id: 'logs-all_assets.test_logs-all_assets', type: 'data_stream_ilm_policy', diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index a6f79414ab8c01..6b4d104423144d 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -199,23 +199,45 @@ export default function (providerContext: FtrProviderContext) { ); expect(resPipeline2.statusCode).equal(404); }); - it('should have updated the template components', async function () { - const res = await es.transport.request({ + it('should have updated the component templates', async function () { + const resMappings = await es.transport.request({ method: 'GET', - path: `/_component_template/${logsTemplateName}-mappings`, + path: `/_component_template/${logsTemplateName}@mappings`, }); - expect(res.statusCode).equal(200); - expect(res.body.component_templates[0].component_template.template.mappings).eql({ + expect(resMappings.statusCode).equal(200); + expect(resMappings.body.component_templates[0].component_template.template.mappings).eql({ dynamic: true, }); const resSettings = await es.transport.request({ method: 'GET', - path: `/_component_template/${logsTemplateName}-settings`, + path: `/_component_template/${logsTemplateName}@settings`, }); - expect(res.statusCode).equal(200); + expect(resSettings.statusCode).equal(200); expect(resSettings.body.component_templates[0].component_template.template.settings).eql({ index: { lifecycle: { name: 'reference2' } }, }); + const resUserSettings = await es.transport.request({ + method: 'GET', + path: `/_component_template/${logsTemplateName}@custom`, + }); + expect(resUserSettings.statusCode).equal(200); + expect(resUserSettings.body).eql({ + component_templates: [ + { + name: 'logs-all_assets.test_logs@custom', + component_template: { + _meta: { + package: { + name: 'all_assets', + }, + }, + template: { + settings: {}, + }, + }, + }, + ], + }); }); it('should have updated the index patterns', async function () { const resIndexPatternLogs = await kibanaServer.savedObjects.get({ @@ -321,14 +343,34 @@ export default function (providerContext: FtrProviderContext) { id: 'logs-all_assets.test_logs', type: 'index_template', }, + { + id: 'logs-all_assets.test_logs@mappings', + type: 'component_template', + }, + { + id: 'logs-all_assets.test_logs@settings', + type: 'component_template', + }, + { + id: 'logs-all_assets.test_logs@custom', + type: 'component_template', + }, { id: 'logs-all_assets.test_logs2', type: 'index_template', }, + { + id: 'logs-all_assets.test_logs2@custom', + type: 'component_template', + }, { id: 'metrics-all_assets.test_metrics', type: 'index_template', }, + { + id: 'metrics-all_assets.test_metrics@custom', + type: 'component_template', + }, ], es_index_patterns: { test_logs: 'logs-all_assets.test_logs-*', diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/img/logo_overrides_64_color.svg b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/img/logo_overrides_64_color.svg new file mode 100644 index 00000000000000..b03007a76ffcc5 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/img/logo_overrides_64_color.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/manifest.yml index bba1a6a4c347d1..312cd2874804ce 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/manifest.yml +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/manifest.yml @@ -1,7 +1,7 @@ format_version: 1.0.0 -name: error_handling +name: error_handling title: Error handling -description: tests error handling and rollback +description: tests error handling and rollback version: 0.1.0 categories: [] release: beta @@ -17,4 +17,4 @@ requirement: icons: - src: '/img/logo_overrides_64_color.svg' size: '16x16' - type: 'image/svg+xml' \ No newline at end of file + type: 'image/svg+xml' diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/img/logo_overrides_64_color.svg b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/img/logo_overrides_64_color.svg new file mode 100644 index 00000000000000..b03007a76ffcc5 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/img/logo_overrides_64_color.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml index 2eb6a41a77ede8..c92f0ab5ae7f3f 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml @@ -1,7 +1,7 @@ format_version: 1.0.0 -name: error_handling +name: error_handling title: Error handling -description: tests error handling and rollback +description: tests error handling and rollback version: 0.2.0 categories: [] release: beta @@ -16,4 +16,4 @@ requirement: icons: - src: '/img/logo_overrides_64_color.svg' - size: '16x16' \ No newline at end of file + size: '16x16' diff --git a/x-pack/test/fleet_api_integration/config.ts b/x-pack/test/fleet_api_integration/config.ts index 52c9760d66c198..d18ba9c55ca968 100644 --- a/x-pack/test/fleet_api_integration/config.ts +++ b/x-pack/test/fleet_api_integration/config.ts @@ -51,17 +51,11 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { waitForLogLine: 'package manifests loaded', }, }), - services: { - ...xPackAPITestsConfig.get('services'), - }, + services: xPackAPITestsConfig.get('services'), junit: { reportName: 'X-Pack EPM API Integration Tests', }, - - esTestCluster: { - ...xPackAPITestsConfig.get('esTestCluster'), - }, - + esTestCluster: xPackAPITestsConfig.get('esTestCluster'), kbnTestServer: { ...xPackAPITestsConfig.get('kbnTestServer'), serverArgs: [ From 1386c330fcffd7f6e50d2095f4c56263751b3882 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 23 Jun 2021 13:25:37 +0200 Subject: [PATCH 10/61] Discover locator (#102712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Discover locator * Add Discover locator tests * Expose locator for Discover app and deprecate URL generator * Use Discover locator in Explore Underlying Data * Fix explore data unit tests after refactor * fix: 🐛 update Discover plugin mock * style: 💄 remove any * test: 💍 fix test mock * fix: 🐛 adjust property name after refactor * test: 💍 fix tests after refactor Co-authored-by: Vadim Kibana --- src/plugins/discover/public/index.ts | 2 + src/plugins/discover/public/locator.test.ts | 270 ++++++++++++++++++ src/plugins/discover/public/locator.ts | 146 ++++++++++ src/plugins/discover/public/mocks.ts | 12 + src/plugins/discover/public/plugin.tsx | 76 ++++- x-pack/plugins/discover_enhanced/kibana.json | 2 +- .../abstract_explore_data_action.ts | 22 +- .../explore_data_chart_action.test.ts | 46 ++- .../explore_data/explore_data_chart_action.ts | 28 +- .../explore_data_context_menu_action.test.ts | 42 ++- .../explore_data_context_menu_action.ts | 28 +- 11 files changed, 586 insertions(+), 88 deletions(-) create mode 100644 src/plugins/discover/public/locator.test.ts create mode 100644 src/plugins/discover/public/locator.ts diff --git a/src/plugins/discover/public/index.ts b/src/plugins/discover/public/index.ts index fbe853ec6deb5b..3840df4353faf8 100644 --- a/src/plugins/discover/public/index.ts +++ b/src/plugins/discover/public/index.ts @@ -17,4 +17,6 @@ export function plugin(initializerContext: PluginInitializerContext) { export { SavedSearch, SavedSearchLoader, createSavedSearchesLoader } from './saved_searches'; export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './application/embeddable'; export { loadSharingDataHelpers } from './shared'; + export { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState } from './url_generator'; +export { DiscoverAppLocator, DiscoverAppLocatorParams } from './locator'; diff --git a/src/plugins/discover/public/locator.test.ts b/src/plugins/discover/public/locator.test.ts new file mode 100644 index 00000000000000..edbb0663d4aa37 --- /dev/null +++ b/src/plugins/discover/public/locator.test.ts @@ -0,0 +1,270 @@ +/* + * Copyright 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 { hashedItemStore, getStatesFromKbnUrl } from '../../kibana_utils/public'; +import { mockStorage } from '../../kibana_utils/public/storage/hashed_item_store/mock'; +import { FilterStateStore } from '../../data/common'; +import { DiscoverAppLocatorDefinition } from './locator'; +import { SerializableState } from 'src/plugins/kibana_utils/common'; + +const indexPatternId: string = 'c367b774-a4c2-11ea-bb37-0242ac130002'; +const savedSearchId: string = '571aaf70-4c88-11e8-b3d7-01146121b73d'; + +interface SetupParams { + useHash?: boolean; +} + +const setup = async ({ useHash = false }: SetupParams = {}) => { + const locator = new DiscoverAppLocatorDefinition({ + useHash, + }); + + return { + locator, + }; +}; + +beforeEach(() => { + // @ts-expect-error + hashedItemStore.storage = mockStorage; +}); + +describe('Discover url generator', () => { + test('can create a link to Discover with no state and no saved search', async () => { + const { locator } = await setup(); + const { app, path } = await locator.getLocation({}); + const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']); + + expect(app).toBe('discover'); + expect(_a).toEqual({}); + expect(_g).toEqual({}); + }); + + test('can create a link to a saved search in Discover', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ savedSearchId }); + const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']); + + expect(path.startsWith(`#/view/${savedSearchId}`)).toBe(true); + expect(_a).toEqual({}); + expect(_g).toEqual({}); + }); + + test('can specify specific index pattern', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + indexPatternId, + }); + const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']); + + expect(_a).toEqual({ + index: indexPatternId, + }); + expect(_g).toEqual({}); + }); + + test('can specify specific time range', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + }); + const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']); + + expect(_a).toEqual({}); + expect(_g).toEqual({ + time: { + from: 'now-15m', + mode: 'relative', + to: 'now', + }, + }); + }); + + test('can specify query', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + query: { + language: 'kuery', + query: 'foo', + }, + }); + const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']); + + expect(_a).toEqual({ + query: { + language: 'kuery', + query: 'foo', + }, + }); + expect(_g).toEqual({}); + }); + + test('can specify local and global filters', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + filters: [ + { + meta: { + alias: 'foo', + disabled: false, + negate: false, + }, + $state: { + store: FilterStateStore.APP_STATE, + }, + }, + { + meta: { + alias: 'bar', + disabled: false, + negate: false, + }, + $state: { + store: FilterStateStore.GLOBAL_STATE, + }, + }, + ], + }); + const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']); + + expect(_a).toEqual({ + filters: [ + { + $state: { + store: 'appState', + }, + meta: { + alias: 'foo', + disabled: false, + negate: false, + }, + }, + ], + }); + expect(_g).toEqual({ + filters: [ + { + $state: { + store: 'globalState', + }, + meta: { + alias: 'bar', + disabled: false, + negate: false, + }, + }, + ], + }); + }); + + test('can set refresh interval', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + refreshInterval: { + pause: false, + value: 666, + }, + }); + const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']); + + expect(_a).toEqual({}); + expect(_g).toEqual({ + refreshInterval: { + pause: false, + value: 666, + }, + }); + }); + + test('can set time range', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + timeRange: { + from: 'now-3h', + to: 'now', + }, + }); + const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']); + + expect(_a).toEqual({}); + expect(_g).toEqual({ + time: { + from: 'now-3h', + to: 'now', + }, + }); + }); + + test('can specify a search session id', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + searchSessionId: '__test__', + }); + + expect(path).toMatchInlineSnapshot(`"#/?_g=()&_a=()&searchSessionId=__test__"`); + expect(path).toContain('__test__'); + }); + + test('can specify columns, interval, sort and savedQuery', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + columns: ['_source'], + interval: 'auto', + sort: [['timestamp, asc']] as string[][] & SerializableState, + savedQuery: '__savedQueryId__', + }); + + expect(path).toMatchInlineSnapshot( + `"#/?_g=()&_a=(columns:!(_source),interval:auto,savedQuery:__savedQueryId__,sort:!(!('timestamp,%20asc')))"` + ); + }); + + describe('useHash property', () => { + describe('when default useHash is set to false', () => { + test('when using default, sets index pattern ID in the generated URL', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + indexPatternId, + }); + + expect(path.indexOf(indexPatternId) > -1).toBe(true); + }); + + test('when enabling useHash, does not set index pattern ID in the generated URL', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + useHash: true, + indexPatternId, + }); + + expect(path.indexOf(indexPatternId) > -1).toBe(false); + }); + }); + + describe('when default useHash is set to true', () => { + test('when using default, does not set index pattern ID in the generated URL', async () => { + const { locator } = await setup({ useHash: true }); + const { path } = await locator.getLocation({ + indexPatternId, + }); + + expect(path.indexOf(indexPatternId) > -1).toBe(false); + }); + + test('when disabling useHash, sets index pattern ID in the generated URL', async () => { + const { locator } = await setup({ useHash: true }); + const { path } = await locator.getLocation({ + useHash: false, + indexPatternId, + }); + + expect(path.indexOf(indexPatternId) > -1).toBe(true); + }); + }); + }); +}); diff --git a/src/plugins/discover/public/locator.ts b/src/plugins/discover/public/locator.ts new file mode 100644 index 00000000000000..fff89903bc4653 --- /dev/null +++ b/src/plugins/discover/public/locator.ts @@ -0,0 +1,146 @@ +/* + * Copyright 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 { SerializableState } from 'src/plugins/kibana_utils/common'; +import type { TimeRange, Filter, Query, QueryState, RefreshInterval } from '../../data/public'; +import type { LocatorDefinition, LocatorPublic } from '../../share/public'; +import { esFilters } from '../../data/public'; +import { setStateToKbnUrl } from '../../kibana_utils/public'; + +export const DISCOVER_APP_LOCATOR = 'DISCOVER_APP_LOCATOR'; + +export interface DiscoverAppLocatorParams extends SerializableState { + /** + * Optionally set saved search ID. + */ + savedSearchId?: string; + + /** + * Optionally set index pattern ID. + */ + indexPatternId?: string; + + /** + * Optionally set the time range in the time picker. + */ + timeRange?: TimeRange; + + /** + * Optionally set the refresh interval. + */ + refreshInterval?: RefreshInterval & SerializableState; + + /** + * Optionally apply filters. + */ + filters?: Filter[]; + + /** + * Optionally set a query. + */ + query?: Query; + + /** + * If not given, will use the uiSettings configuration for `storeInSessionStorage`. useHash determines + * whether to hash the data in the url to avoid url length issues. + */ + useHash?: boolean; + + /** + * Background search session id + */ + searchSessionId?: string; + + /** + * Columns displayed in the table + */ + columns?: string[]; + + /** + * Used interval of the histogram + */ + interval?: string; + + /** + * Array of the used sorting [[field,direction],...] + */ + sort?: string[][] & SerializableState; + + /** + * id of the used saved query + */ + savedQuery?: string; +} + +export type DiscoverAppLocator = LocatorPublic; + +export interface DiscoverAppLocatorDependencies { + useHash: boolean; +} + +export class DiscoverAppLocatorDefinition implements LocatorDefinition { + public readonly id = DISCOVER_APP_LOCATOR; + + constructor(protected readonly deps: DiscoverAppLocatorDependencies) {} + + public readonly getLocation = async (params: DiscoverAppLocatorParams) => { + const { + useHash = this.deps.useHash, + filters, + indexPatternId, + query, + refreshInterval, + savedSearchId, + timeRange, + searchSessionId, + columns, + savedQuery, + sort, + interval, + } = params; + const savedSearchPath = savedSearchId ? `view/${encodeURIComponent(savedSearchId)}` : ''; + const appState: { + query?: Query; + filters?: Filter[]; + index?: string; + columns?: string[]; + interval?: string; + sort?: string[][]; + savedQuery?: string; + } = {}; + const queryState: QueryState = {}; + + if (query) appState.query = query; + if (filters && filters.length) + appState.filters = filters?.filter((f) => !esFilters.isFilterPinned(f)); + if (indexPatternId) appState.index = indexPatternId; + if (columns) appState.columns = columns; + if (savedQuery) appState.savedQuery = savedQuery; + if (sort) appState.sort = sort; + if (interval) appState.interval = interval; + + if (timeRange) queryState.time = timeRange; + if (filters && filters.length) + queryState.filters = filters?.filter((f) => esFilters.isFilterPinned(f)); + if (refreshInterval) queryState.refreshInterval = refreshInterval; + + let path = `#/${savedSearchPath}`; + path = setStateToKbnUrl('_g', queryState, { useHash }, path); + path = setStateToKbnUrl('_a', appState, { useHash }, path); + + if (searchSessionId) { + path = `${path}&searchSessionId=${searchSessionId}`; + } + + return { + app: 'discover', + path, + state: {}, + }; + }; +} diff --git a/src/plugins/discover/public/mocks.ts b/src/plugins/discover/public/mocks.ts index 0f57c5c0fa1381..53160df472a3c7 100644 --- a/src/plugins/discover/public/mocks.ts +++ b/src/plugins/discover/public/mocks.ts @@ -16,6 +16,12 @@ const createSetupContract = (): Setup => { docViews: { addDocView: jest.fn(), }, + locator: { + getLocation: jest.fn(), + getUrl: jest.fn(), + useUrl: jest.fn(), + navigate: jest.fn(), + }, }; return setupContract; }; @@ -26,6 +32,12 @@ const createStartContract = (): Start => { urlGenerator: ({ createUrl: jest.fn(), } as unknown) as DiscoverStart['urlGenerator'], + locator: { + getLocation: jest.fn(), + getUrl: jest.fn(), + useUrl: jest.fn(), + navigate: jest.fn(), + }, }; return startContract; }; diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index 7b4e7bb67c00ec..ec89f7516e92d1 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -59,6 +59,7 @@ import { DiscoverUrlGenerator, SEARCH_SESSION_ID_QUERY_PARAM, } from './url_generator'; +import { DiscoverAppLocatorDefinition, DiscoverAppLocator } from './locator'; import { SearchEmbeddableFactory } from './application/embeddable'; import { UsageCollectionSetup } from '../../usage_collection/public'; import { replaceUrlHashQuery } from '../../kibana_utils/public/'; @@ -83,17 +84,68 @@ export interface DiscoverSetup { */ addDocView(docViewRaw: DocViewInput | DocViewInputFn): void; }; + + /** + * `share` plugin URL locator for Discover app. Use it to generate links into + * Discover application, for example, navigate: + * + * ```ts + * await plugins.discover.locator.navigate({ + * savedSearchId: '571aaf70-4c88-11e8-b3d7-01146121b73d', + * indexPatternId: 'c367b774-a4c2-11ea-bb37-0242ac130002', + * timeRange: { + * to: 'now', + * from: 'now-15m', + * mode: 'relative', + * }, + * }); + * ``` + * + * Generate a location: + * + * ```ts + * const location = await plugins.discover.locator.getLocation({ + * savedSearchId: '571aaf70-4c88-11e8-b3d7-01146121b73d', + * indexPatternId: 'c367b774-a4c2-11ea-bb37-0242ac130002', + * timeRange: { + * to: 'now', + * from: 'now-15m', + * mode: 'relative', + * }, + * }); + * ``` + */ + readonly locator: undefined | DiscoverAppLocator; } export interface DiscoverStart { savedSearchLoader: SavedObjectLoader; /** - * `share` plugin URL generator for Discover app. Use it to generate links into - * Discover application, example: + * @deprecated Use URL locator instead. URL generaotr will be removed. + */ + readonly urlGenerator: undefined | UrlGeneratorContract<'DISCOVER_APP_URL_GENERATOR'>; + + /** + * `share` plugin URL locator for Discover app. Use it to generate links into + * Discover application, for example, navigate: + * + * ```ts + * await plugins.discover.locator.navigate({ + * savedSearchId: '571aaf70-4c88-11e8-b3d7-01146121b73d', + * indexPatternId: 'c367b774-a4c2-11ea-bb37-0242ac130002', + * timeRange: { + * to: 'now', + * from: 'now-15m', + * mode: 'relative', + * }, + * }); + * ``` + * + * Generate a location: * * ```ts - * const url = await plugins.discover.urlGenerator.createUrl({ + * const location = await plugins.discover.locator.getLocation({ * savedSearchId: '571aaf70-4c88-11e8-b3d7-01146121b73d', * indexPatternId: 'c367b774-a4c2-11ea-bb37-0242ac130002', * timeRange: { @@ -104,7 +156,7 @@ export interface DiscoverStart { * }); * ``` */ - readonly urlGenerator: undefined | UrlGeneratorContract<'DISCOVER_APP_URL_GENERATOR'>; + readonly locator: undefined | DiscoverAppLocator; } /** @@ -156,7 +208,12 @@ export class DiscoverPlugin private stopUrlTracking: (() => void) | undefined = undefined; private servicesInitialized: boolean = false; private innerAngularInitialized: boolean = false; + + /** + * @deprecated + */ private urlGenerator?: DiscoverStart['urlGenerator']; + private locator?: DiscoverAppLocator; /** * why are those functions public? they are needed for some mocha tests @@ -179,6 +236,15 @@ export class DiscoverPlugin }) ); } + + if (plugins.share) { + this.locator = plugins.share.url.locators.create( + new DiscoverAppLocatorDefinition({ + useHash: core.uiSettings.get('state:storeInSessionStorage'), + }) + ); + } + this.docViewsRegistry = new DocViewsRegistry(); setDocViewsRegistry(this.docViewsRegistry); this.docViewsRegistry.addDocView({ @@ -323,6 +389,7 @@ export class DiscoverPlugin docViews: { addDocView: this.docViewsRegistry.addDocView.bind(this.docViewsRegistry), }, + locator: this.locator, }; } @@ -367,6 +434,7 @@ export class DiscoverPlugin return { urlGenerator: this.urlGenerator, + locator: this.locator, savedSearchLoader: createSavedSearchesLoader({ savedObjectsClient: core.savedObjects.client, savedObjects: plugins.savedObjects, diff --git a/x-pack/plugins/discover_enhanced/kibana.json b/x-pack/plugins/discover_enhanced/kibana.json index 01a3624d3e3201..da95a0f21a0204 100644 --- a/x-pack/plugins/discover_enhanced/kibana.json +++ b/x-pack/plugins/discover_enhanced/kibana.json @@ -7,5 +7,5 @@ "requiredPlugins": ["uiActions", "embeddable", "discover"], "optionalPlugins": ["share", "kibanaLegacy", "usageCollection"], "configPath": ["xpack", "discoverEnhanced"], - "requiredBundles": ["kibanaUtils", "data", "share"] + "requiredBundles": ["kibanaUtils", "data"] } diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts index 023db127ca6336..44ea53fe0b8706 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts @@ -11,13 +11,13 @@ import { ViewMode, IEmbeddable } from '../../../../../../src/plugins/embeddable/ import { StartServicesGetter } from '../../../../../../src/plugins/kibana_utils/public'; import { KibanaLegacyStart } from '../../../../../../src/plugins/kibana_legacy/public'; import { CoreStart } from '../../../../../../src/core/public'; -import { KibanaURL } from '../../../../../../src/plugins/share/public'; +import { KibanaLocation } from '../../../../../../src/plugins/share/public'; import * as shared from './shared'; export const ACTION_EXPLORE_DATA = 'ACTION_EXPLORE_DATA'; export interface PluginDeps { - discover: Pick; + discover: Pick; kibanaLegacy?: { dashboardConfig: { getHideWriteControls: KibanaLegacyStart['dashboardConfig']['getHideWriteControls']; @@ -26,7 +26,7 @@ export interface PluginDeps { } export interface CoreDeps { - application: Pick; + application: Pick; } export interface Params { @@ -43,7 +43,7 @@ export abstract class AbstractExploreDataAction; + protected abstract getLocation(context: Context): Promise; public async isCompatible({ embeddable }: Context): Promise { if (!embeddable) return false; @@ -52,7 +52,7 @@ export abstract class AbstractExploreDataAction { - type UrlGenerator = UrlGeneratorContract<'DISCOVER_APP_URL_GENERATOR'>; - const core = coreMock.createStart(); - - const urlGenerator: UrlGenerator = ({ - createUrl: jest.fn(() => Promise.resolve('/xyz/app/discover/foo#bar')), - } as unknown) as UrlGenerator; + const locator: DiscoverAppLocator = { + getLocation: jest.fn(() => + Promise.resolve({ + app: 'discover', + path: '/foo#bar', + state: {}, + }) + ), + navigate: jest.fn(async () => {}), + getUrl: jest.fn(), + useUrl: jest.fn(), + }; const plugins: PluginDeps = { discover: { - urlGenerator, + locator, }, kibanaLegacy: { dashboardConfig: { @@ -95,7 +101,7 @@ const setup = ( embeddable, } as ExploreDataChartActionContext; - return { core, plugins, urlGenerator, params, action, input, output, embeddable, context }; + return { core, plugins, locator, params, action, input, output, embeddable, context }; }; describe('"Explore underlying data" panel action', () => { @@ -132,7 +138,7 @@ describe('"Explore underlying data" panel action', () => { test('returns false when URL generator is not present', async () => { const { action, plugins, context } = setup(); - (plugins.discover as any).urlGenerator = undefined; + (plugins.discover as any).locator = undefined; const isCompatible = await action.isCompatible(context); @@ -205,23 +211,15 @@ describe('"Explore underlying data" panel action', () => { }); describe('getHref()', () => { - test('returns URL path generated by URL generator', async () => { - const { action, context } = setup(); - - const href = await action.getHref(context); - - expect(href).toBe('/xyz/app/discover/foo#bar'); - }); - test('calls URL generator with right arguments', async () => { - const { action, urlGenerator, context } = setup(); + const { action, locator, context } = setup(); - expect(urlGenerator.createUrl).toHaveBeenCalledTimes(0); + expect(locator.getLocation).toHaveBeenCalledTimes(0); await action.getHref(context); - expect(urlGenerator.createUrl).toHaveBeenCalledTimes(1); - expect(urlGenerator.createUrl).toHaveBeenCalledWith({ + expect(locator.getLocation).toHaveBeenCalledTimes(1); + expect(locator.getLocation).toHaveBeenCalledWith({ filters: [], indexPatternId: 'index-ptr-foo', timeRange: undefined, @@ -260,11 +258,11 @@ describe('"Explore underlying data" panel action', () => { }, ]; - const { action, context, urlGenerator } = setup({ filters, timeFieldName }); + const { action, context, locator } = setup({ filters, timeFieldName }); await action.getHref(context); - expect(urlGenerator.createUrl).toHaveBeenCalledWith({ + expect(locator.getLocation).toHaveBeenCalledWith({ filters: [ { meta: { diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts index 32264ee1deceb8..7b59a4e51d0428 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts @@ -7,7 +7,7 @@ import { Action } from '../../../../../../src/plugins/ui_actions/public'; import { - DiscoverUrlGeneratorState, + DiscoverAppLocatorParams, SearchInput, } from '../../../../../../src/plugins/discover/public'; import { @@ -15,7 +15,7 @@ import { esFilters, } from '../../../../../../src/plugins/data/public'; import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; -import { KibanaURL } from '../../../../../../src/plugins/share/public'; +import { KibanaLocation } from '../../../../../../src/plugins/share/public'; import * as shared from './shared'; import { AbstractExploreDataAction } from './abstract_explore_data_action'; @@ -43,14 +43,14 @@ export class ExploreDataChartAction return super.isCompatible(context); } - protected readonly getUrl = async ( + protected readonly getLocation = async ( context: ExploreDataChartActionContext - ): Promise => { + ): Promise => { const { plugins } = this.params.start(); - const { urlGenerator } = plugins.discover; + const { locator } = plugins.discover; - if (!urlGenerator) { - throw new Error('Discover URL generator not available.'); + if (!locator) { + throw new Error('Discover URL locator not available.'); } const { embeddable } = context; @@ -59,23 +59,23 @@ export class ExploreDataChartAction context.timeFieldName ); - const state: DiscoverUrlGeneratorState = { + const params: DiscoverAppLocatorParams = { filters, timeRange, }; if (embeddable) { - state.indexPatternId = shared.getIndexPatterns(embeddable)[0] || undefined; + params.indexPatternId = shared.getIndexPatterns(embeddable)[0] || undefined; const input = embeddable.getInput() as Readonly; - if (input.timeRange && !state.timeRange) state.timeRange = input.timeRange; - if (input.query) state.query = input.query; - if (input.filters) state.filters = [...input.filters, ...(state.filters || [])]; + if (input.timeRange && !params.timeRange) params.timeRange = input.timeRange; + if (input.query) params.query = input.query; + if (input.filters) params.filters = [...input.filters, ...(params.filters || [])]; } - const path = await urlGenerator.createUrl(state); + const location = await locator.getLocation(params); - return new KibanaURL(path); + return location; }; } diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.test.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.test.ts index 842c7d6b339b4b..5bdac602ec271d 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.test.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.test.ts @@ -8,13 +8,13 @@ import { ExploreDataContextMenuAction } from './explore_data_context_menu_action'; import { Params, PluginDeps } from './abstract_explore_data_action'; import { coreMock } from '../../../../../../src/core/public/mocks'; -import { UrlGeneratorContract } from '../../../../../../src/plugins/share/public'; import { i18n } from '@kbn/i18n'; import { VisualizeEmbeddableContract, VISUALIZE_EMBEDDABLE_TYPE, } from '../../../../../../src/plugins/visualizations/public'; import { ViewMode } from '../../../../../../src/plugins/embeddable/public'; +import { DiscoverAppLocator } from '../../../../../../src/plugins/discover/public'; const i18nTranslateSpy = (i18n.translate as unknown) as jest.SpyInstance; @@ -29,17 +29,23 @@ afterEach(() => { }); const setup = ({ dashboardOnlyMode = false }: { dashboardOnlyMode?: boolean } = {}) => { - type UrlGenerator = UrlGeneratorContract<'DISCOVER_APP_URL_GENERATOR'>; - const core = coreMock.createStart(); - - const urlGenerator: UrlGenerator = ({ - createUrl: jest.fn(() => Promise.resolve('/xyz/app/discover/foo#bar')), - } as unknown) as UrlGenerator; + const locator: DiscoverAppLocator = { + getLocation: jest.fn(() => + Promise.resolve({ + app: 'discover', + path: '/foo#bar', + state: {}, + }) + ), + navigate: jest.fn(async () => {}), + getUrl: jest.fn(), + useUrl: jest.fn(), + }; const plugins: PluginDeps = { discover: { - urlGenerator, + locator, }, kibanaLegacy: { dashboardConfig: { @@ -79,7 +85,7 @@ const setup = ({ dashboardOnlyMode = false }: { dashboardOnlyMode?: boolean } = embeddable, }; - return { core, plugins, urlGenerator, params, action, input, output, embeddable, context }; + return { core, plugins, locator, params, action, input, output, embeddable, context }; }; describe('"Explore underlying data" panel action', () => { @@ -116,7 +122,7 @@ describe('"Explore underlying data" panel action', () => { test('returns false when URL generator is not present', async () => { const { action, plugins, context } = setup(); - (plugins.discover as any).urlGenerator = undefined; + (plugins.discover as any).locator = undefined; const isCompatible = await action.isCompatible(context); @@ -189,23 +195,15 @@ describe('"Explore underlying data" panel action', () => { }); describe('getHref()', () => { - test('returns URL path generated by URL generator', async () => { - const { action, context } = setup(); - - const href = await action.getHref(context); - - expect(href).toBe('/xyz/app/discover/foo#bar'); - }); - test('calls URL generator with right arguments', async () => { - const { action, urlGenerator, context } = setup(); + const { action, locator, context } = setup(); - expect(urlGenerator.createUrl).toHaveBeenCalledTimes(0); + expect(locator.getLocation).toHaveBeenCalledTimes(0); await action.getHref(context); - expect(urlGenerator.createUrl).toHaveBeenCalledTimes(1); - expect(urlGenerator.createUrl).toHaveBeenCalledWith({ + expect(locator.getLocation).toHaveBeenCalledTimes(1); + expect(locator.getLocation).toHaveBeenCalledWith({ indexPatternId: 'index-ptr-foo', }); }); diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts index 99a2afd239645f..88c093a299cb98 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts @@ -12,8 +12,8 @@ import { IEmbeddable, } from '../../../../../../src/plugins/embeddable/public'; import { Query, Filter, TimeRange } from '../../../../../../src/plugins/data/public'; -import { DiscoverUrlGeneratorState } from '../../../../../../src/plugins/discover/public'; -import { KibanaURL } from '../../../../../../src/plugins/share/public'; +import { DiscoverAppLocatorParams } from '../../../../../../src/plugins/discover/public'; +import { KibanaLocation } from '../../../../../../src/plugins/share/public'; import * as shared from './shared'; import { AbstractExploreDataAction } from './abstract_explore_data_action'; @@ -40,29 +40,31 @@ export class ExploreDataContextMenuAction public readonly order = 200; - protected readonly getUrl = async (context: EmbeddableQueryContext): Promise => { + protected readonly getLocation = async ( + context: EmbeddableQueryContext + ): Promise => { const { plugins } = this.params.start(); - const { urlGenerator } = plugins.discover; + const { locator } = plugins.discover; - if (!urlGenerator) { - throw new Error('Discover URL generator not available.'); + if (!locator) { + throw new Error('Discover URL locator not available.'); } const { embeddable } = context; - const state: DiscoverUrlGeneratorState = {}; + const params: DiscoverAppLocatorParams = {}; if (embeddable) { - state.indexPatternId = shared.getIndexPatterns(embeddable)[0] || undefined; + params.indexPatternId = shared.getIndexPatterns(embeddable)[0] || undefined; const input = embeddable.getInput(); - if (input.timeRange && !state.timeRange) state.timeRange = input.timeRange; - if (input.query) state.query = input.query; - if (input.filters) state.filters = [...input.filters, ...(state.filters || [])]; + if (input.timeRange && !params.timeRange) params.timeRange = input.timeRange; + if (input.query) params.query = input.query; + if (input.filters) params.filters = [...input.filters, ...(params.filters || [])]; } - const path = await urlGenerator.createUrl(state); + const location = await locator.getLocation(params); - return new KibanaURL(path); + return location; }; } From 498df213faf153a1ccbb88fc0a3bceb07bfae6c0 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 23 Jun 2021 13:46:59 +0200 Subject: [PATCH 11/61] fix time shift ux issues (#102709) --- .../search/aggs/utils/parse_time_shift.ts | 2 +- .../dimension_panel/time_shift.tsx | 2 +- .../time_shift_utils.tsx | 22 +++++++++---------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/plugins/data/common/search/aggs/utils/parse_time_shift.ts b/src/plugins/data/common/search/aggs/utils/parse_time_shift.ts index 4d8ee0f8891732..91379ea054de3b 100644 --- a/src/plugins/data/common/search/aggs/utils/parse_time_shift.ts +++ b/src/plugins/data/common/search/aggs/utils/parse_time_shift.ts @@ -20,7 +20,7 @@ export const parseTimeShift = (val: string): moment.Duration | 'previous' | 'inv if (trimmedVal === 'previous') { return 'previous'; } - const [, amount, unit] = trimmedVal.match(/^(\d+)(\w)$/) || []; + const [, amount, unit] = trimmedVal.match(/^(\d+)\s*(\w)$/) || []; const parsedAmount = Number(amount); if (Number.isNaN(parsedAmount) || !allowedUnits.includes(unit as AllowedUnit)) { return 'invalid'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_shift.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_shift.tsx index ba9525ac53fc50..c2415c9c9a75a2 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_shift.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_shift.tsx @@ -157,7 +157,7 @@ export function TimeShift({ isClearable={false} data-test-subj="indexPattern-dimension-time-shift" placeholder={i18n.translate('xpack.lens.indexPattern.timeShiftPlaceholder', { - defaultMessage: 'Time shift (e.g. 1d)', + defaultMessage: 'Type custom values (e.g. 8w)', })} options={timeShiftOptions.filter(({ value }) => { const parsedValue = parseTimeShift(value); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/time_shift_utils.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/time_shift_utils.tsx index 14ba6b9189e6bd..a1bc643c3bd932 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/time_shift_utils.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/time_shift_utils.tsx @@ -23,67 +23,67 @@ import { FramePublicAPI } from '../types'; export const timeShiftOptions = [ { label: i18n.translate('xpack.lens.indexPattern.timeShift.hour', { - defaultMessage: '1 hour (1h)', + defaultMessage: '1 hour ago (1h)', }), value: '1h', }, { label: i18n.translate('xpack.lens.indexPattern.timeShift.3hours', { - defaultMessage: '3 hours (3h)', + defaultMessage: '3 hours ago (3h)', }), value: '3h', }, { label: i18n.translate('xpack.lens.indexPattern.timeShift.6hours', { - defaultMessage: '6 hours (6h)', + defaultMessage: '6 hours ago (6h)', }), value: '6h', }, { label: i18n.translate('xpack.lens.indexPattern.timeShift.12hours', { - defaultMessage: '12 hours (12h)', + defaultMessage: '12 hours ago (12h)', }), value: '12h', }, { label: i18n.translate('xpack.lens.indexPattern.timeShift.day', { - defaultMessage: '1 day (1d)', + defaultMessage: '1 day ago (1d)', }), value: '1d', }, { label: i18n.translate('xpack.lens.indexPattern.timeShift.week', { - defaultMessage: '1 week (1w)', + defaultMessage: '1 week ago (1w)', }), value: '1w', }, { label: i18n.translate('xpack.lens.indexPattern.timeShift.month', { - defaultMessage: '1 month (1M)', + defaultMessage: '1 month ago (1M)', }), value: '1M', }, { label: i18n.translate('xpack.lens.indexPattern.timeShift.3months', { - defaultMessage: '3 months (3M)', + defaultMessage: '3 months ago (3M)', }), value: '3M', }, { label: i18n.translate('xpack.lens.indexPattern.timeShift.6months', { - defaultMessage: '6 months (6M)', + defaultMessage: '6 months ago (6M)', }), value: '6M', }, { label: i18n.translate('xpack.lens.indexPattern.timeShift.year', { - defaultMessage: '1 year (1y)', + defaultMessage: '1 year ago (1y)', }), value: '1y', }, { label: i18n.translate('xpack.lens.indexPattern.timeShift.previous', { - defaultMessage: 'Previous', + defaultMessage: 'Previous time range', }), value: 'previous', }, From 2ab5d6bc46ccc5d57eb0ccc35351052d1d72bdf2 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 23 Jun 2021 13:47:19 +0200 Subject: [PATCH 12/61] disable missing switch for non-string fields (#102865) --- .../operations/definitions/terms/index.tsx | 5 ++- .../definitions/terms/terms.test.tsx | 32 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index a650c556c4965d..a458a1edcfa16d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -497,7 +497,10 @@ export const termsOperation: OperationDefinition diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx index 3b557461546caf..f326f3e3ed5f69 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx @@ -60,7 +60,7 @@ describe('terms', () => { size: 3, orderDirection: 'asc', }, - sourceField: 'category', + sourceField: 'source', }, col2: { label: 'Count', @@ -88,7 +88,7 @@ describe('terms', () => { expect.objectContaining({ arguments: expect.objectContaining({ orderBy: ['_key'], - field: ['category'], + field: ['source'], size: [3], otherBucket: [true], }), @@ -770,6 +770,34 @@ describe('terms', () => { expect(select.prop('disabled')).toEqual(false); }); + it('should disable missing bucket setting if field is not a string', () => { + const updateLayerSpy = jest.fn(); + const instance = shallow( + + ); + + const select = instance + .find('[data-test-subj="indexPattern-terms-missing-bucket"]') + .find(EuiSwitch); + + expect(select.prop('disabled')).toEqual(true); + }); + it('should update state when clicking other bucket toggle', () => { const updateLayerSpy = jest.fn(); const instance = shallow( From b652ef677f08f5244c2066bb032f0f426563df69 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 23 Jun 2021 13:48:48 +0200 Subject: [PATCH 13/61] [Lens] Do not add math columns for pass-through operations (#102656) --- .../definitions/calculations/utils.ts | 23 ++++++- .../definitions/formula/formula.test.tsx | 20 ++---- .../operations/definitions/formula/parse.ts | 52 ++++++++------- .../operations/layer_helpers.test.ts | 63 +++++++++---------- 4 files changed, 85 insertions(+), 73 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts index 03b9d6c07709c5..87116f71919b56 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts @@ -7,11 +7,12 @@ import { i18n } from '@kbn/i18n'; import type { ExpressionFunctionAST } from '@kbn/interpreter/common'; +import memoizeOne from 'memoize-one'; import type { TimeScaleUnit } from '../../../time_scale'; import type { IndexPattern, IndexPatternLayer } from '../../../types'; import { adjustTimeScaleLabelSuffix } from '../../time_scale_utils'; import type { ReferenceBasedIndexPatternColumn } from '../column_types'; -import { isColumnValidAsReference } from '../../layer_helpers'; +import { getManagedColumnsFrom, isColumnValidAsReference } from '../../layer_helpers'; import { operationDefinitionMap } from '..'; export const buildLabelFunction = (ofName: (name?: string) => string) => ( @@ -45,6 +46,23 @@ export function checkForDateHistogram(layer: IndexPatternLayer, name: string) { ]; } +const getFullyManagedColumnIds = memoizeOne((layer: IndexPatternLayer) => { + const managedColumnIds = new Set(); + Object.entries(layer.columns).forEach(([id, column]) => { + if ( + 'references' in column && + operationDefinitionMap[column.operationType].input === 'managedReference' + ) { + managedColumnIds.add(id); + const managedColumns = getManagedColumnsFrom(id, layer.columns); + managedColumns.map(([managedId]) => { + managedColumnIds.add(managedId); + }); + } + }); + return managedColumnIds; +}); + export function checkReferences(layer: IndexPatternLayer, columnId: string) { const column = layer.columns[columnId] as ReferenceBasedIndexPatternColumn; @@ -72,7 +90,8 @@ export function checkReferences(layer: IndexPatternLayer, columnId: string) { column: referenceColumn, }); - if (!isValid) { + // do not enforce column validity if current column is part of managed subtree + if (!isValid && !getFullyManagedColumnIds(layer).has(columnId)) { errors.push( i18n.translate('xpack.lens.indexPattern.invalidReferenceConfiguration', { defaultMessage: 'Dimension "{dimensionLabel}" is configured incorrectly', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx index e6aa29ea4d763e..279e76b8395484 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx @@ -413,13 +413,13 @@ describe('formula', () => { ).newLayer ).toEqual({ ...layer, - columnOrder: ['col1X0', 'col1X1', 'col1'], + columnOrder: ['col1X0', 'col1'], columns: { ...layer.columns, col1: { ...currentColumn, label: 'average(bytes)', - references: ['col1X1'], + references: ['col1X0'], params: { ...currentColumn.params, formula: 'average(bytes)', @@ -436,18 +436,6 @@ describe('formula', () => { sourceField: 'bytes', timeScale: false, }, - col1X1: { - customLabel: true, - dataType: 'number', - isBucketed: false, - label: 'Part of average(bytes)', - operationType: 'math', - params: { - tinymathAst: 'col1X0', - }, - references: ['col1X0'], - scale: 'ratio', - }, }, }); }); @@ -568,8 +556,8 @@ describe('formula', () => { ).locations ).toEqual({ col1X0: { min: 15, max: 29 }, - col1X2: { min: 0, max: 41 }, - col1X3: { min: 42, max: 50 }, + col1X1: { min: 0, max: 41 }, + col1X2: { min: 42, max: 50 }, }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts index 8b726d06f46023..cb1d0dc143efcf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts @@ -123,17 +123,20 @@ function extractColumns( if (nodeOperation.input === 'fullReference') { const [referencedOp] = functions; const consumedParam = parseNode(referencedOp); + const hasActualMathContent = typeof consumedParam !== 'string'; - const subNodeVariables = consumedParam ? findVariables(consumedParam) : []; - const mathColumn = mathOperation.buildColumn({ - layer, - indexPattern, - }); - mathColumn.references = subNodeVariables.map(({ value }) => value); - mathColumn.params.tinymathAst = consumedParam!; - columns.push({ column: mathColumn }); - mathColumn.customLabel = true; - mathColumn.label = label; + if (hasActualMathContent) { + const subNodeVariables = consumedParam ? findVariables(consumedParam) : []; + const mathColumn = mathOperation.buildColumn({ + layer, + indexPattern, + }); + mathColumn.references = subNodeVariables.map(({ value }) => value); + mathColumn.params.tinymathAst = consumedParam!; + columns.push({ column: mathColumn }); + mathColumn.customLabel = true; + mathColumn.label = label; + } const mappedParams = getOperationParams(nodeOperation, namedArguments || []); const newCol = (nodeOperation as OperationDefinition< @@ -143,7 +146,11 @@ function extractColumns( { layer, indexPattern, - referenceIds: [getManagedId(idPrefix, columns.length - 1)], + referenceIds: [ + hasActualMathContent + ? getManagedId(idPrefix, columns.length - 1) + : (consumedParam as string), + ], }, mappedParams ); @@ -160,16 +167,19 @@ function extractColumns( if (root === undefined) { return []; } - const variables = findVariables(root); - const mathColumn = mathOperation.buildColumn({ - layer, - indexPattern, - }); - mathColumn.references = variables.map(({ value }) => value); - mathColumn.params.tinymathAst = root!; - mathColumn.customLabel = true; - mathColumn.label = label; - columns.push({ column: mathColumn }); + const topLevelMath = typeof root !== 'string'; + if (topLevelMath) { + const variables = findVariables(root); + const mathColumn = mathOperation.buildColumn({ + layer, + indexPattern, + }); + mathColumn.references = variables.map(({ value }) => value); + mathColumn.params.tinymathAst = root!; + mathColumn.customLabel = true; + mathColumn.label = label; + columns.push({ column: mathColumn }); + } return columns; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index 387a61ff792640..7de1318cbac612 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts @@ -25,6 +25,7 @@ import { documentField } from '../document_field'; import { getFieldByNameFactory } from '../pure_helpers'; import { generateId } from '../../id_generator'; import { createMockedFullReference, createMockedManagedReference } from './mocks'; +import { TinymathAST } from 'packages/kbn-tinymath'; jest.mock('../operations'); jest.mock('../../id_generator'); @@ -105,28 +106,34 @@ describe('state_helpers', () => { const source = { dataType: 'number' as const, isBucketed: false, - label: 'moving_average(sum(bytes), window=5)', + label: '5 + moving_average(sum(bytes), window=5)', operationType: 'formula' as const, params: { - formula: 'moving_average(sum(bytes), window=5)', + formula: '5 + moving_average(sum(bytes), window=5)', isFormulaBroken: false, }, - references: ['formulaX1'], + references: ['formulaX2'], }; const math = { customLabel: true, dataType: 'number' as const, isBucketed: false, - label: 'Part of moving_average(sum(bytes), window=5)', operationType: 'math' as const, - params: { tinymathAst: 'formulaX2' }, - references: ['formulaX2'], + label: 'Part of 5 + moving_average(sum(bytes), window=5)', + references: ['formulaX1'], + params: { + tinymathAst: { + type: 'function', + name: 'add', + args: [5, 'formulaX1'], + } as TinymathAST, + }, }; const sum = { customLabel: true, dataType: 'number' as const, isBucketed: false, - label: 'Part of moving_average(sum(bytes), window=5)', + label: 'Part of 5 + moving_average(sum(bytes), window=5)', operationType: 'sum' as const, scale: 'ratio' as const, sourceField: 'bytes', @@ -135,7 +142,7 @@ describe('state_helpers', () => { customLabel: true, dataType: 'number' as const, isBucketed: false, - label: 'Part of moving_average(sum(bytes), window=5)', + label: 'Part of 5 + moving_average(sum(bytes), window=5)', operationType: 'moving_average' as const, params: { window: 5 }, references: ['formulaX0'], @@ -148,14 +155,8 @@ describe('state_helpers', () => { columns: { source, formulaX0: sum, - formulaX1: math, - formulaX2: movingAvg, - formulaX3: { - ...math, - label: 'Part of moving_average(sum(bytes), window=5)', - references: ['formulaX2'], - params: { tinymathAst: 'formulaX2' }, - }, + formulaX1: movingAvg, + formulaX2: math, }, }, targetId: 'copy', @@ -171,40 +172,34 @@ describe('state_helpers', () => { 'formulaX0', 'formulaX1', 'formulaX2', - 'formulaX3', 'copyX0', 'copyX1', 'copyX2', - 'copyX3', 'copy', ], columns: { source, formulaX0: sum, - formulaX1: math, - formulaX2: movingAvg, - formulaX3: { - ...math, - references: ['formulaX2'], - params: { tinymathAst: 'formulaX2' }, - }, - copy: expect.objectContaining({ ...source, references: ['copyX3'] }), + formulaX1: movingAvg, + formulaX2: math, + copy: expect.objectContaining({ ...source, references: ['copyX2'] }), copyX0: expect.objectContaining({ ...sum, }), copyX1: expect.objectContaining({ - ...math, + ...movingAvg, references: ['copyX0'], - params: { tinymathAst: 'copyX0' }, }), copyX2: expect.objectContaining({ - ...movingAvg, - references: ['copyX1'], - }), - copyX3: expect.objectContaining({ ...math, - references: ['copyX2'], - params: { tinymathAst: 'copyX2' }, + references: ['copyX1'], + params: { + tinymathAst: expect.objectContaining({ + type: 'function', + name: 'add', + args: [5, 'copyX1'], + } as TinymathAST), + }, }), }, }); From b7aaa1fb91f1bc9459cd904230356e99ce8271bb Mon Sep 17 00:00:00 2001 From: Gloria Hornero Date: Wed, 23 Jun 2021 13:59:35 +0200 Subject: [PATCH 14/61] Cypress baseline for osquery (#102265) * Cypress baseline for osquery * fix types * Update visual_config.ts Co-authored-by: Patryk Kopycinski Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/dev/typescript/projects.ts | 3 + x-pack/plugins/osquery/cypress/README.md | 138 ++++++++++++++++++ x-pack/plugins/osquery/cypress/cypress.json | 14 ++ .../integration/osquery_manager.spec.ts | 29 ++++ .../plugins/osquery/cypress/plugins/index.js | 29 ++++ .../osquery/cypress/screens/integrations.ts | 10 ++ .../osquery/cypress/screens/navigation.ts | 9 ++ .../osquery/cypress/screens/osquery.ts | 8 + .../osquery/cypress/support/commands.js | 32 ++++ .../plugins/osquery/cypress/support/index.ts | 30 ++++ .../osquery/cypress/tasks/integrations.ts | 20 +++ .../osquery/cypress/tasks/navigation.ts | 19 +++ x-pack/plugins/osquery/cypress/tsconfig.json | 15 ++ x-pack/plugins/osquery/package.json | 13 ++ x-pack/test/osquery_cypress/cli_config.ts | 19 +++ x-pack/test/osquery_cypress/config.ts | 43 ++++++ .../osquery_cypress/ftr_provider_context.d.ts | 12 ++ x-pack/test/osquery_cypress/runner.ts | 81 ++++++++++ x-pack/test/osquery_cypress/services.ts | 8 + x-pack/test/osquery_cypress/visual_config.ts | 19 +++ 20 files changed, 551 insertions(+) create mode 100644 x-pack/plugins/osquery/cypress/README.md create mode 100644 x-pack/plugins/osquery/cypress/cypress.json create mode 100644 x-pack/plugins/osquery/cypress/integration/osquery_manager.spec.ts create mode 100644 x-pack/plugins/osquery/cypress/plugins/index.js create mode 100644 x-pack/plugins/osquery/cypress/screens/integrations.ts create mode 100644 x-pack/plugins/osquery/cypress/screens/navigation.ts create mode 100644 x-pack/plugins/osquery/cypress/screens/osquery.ts create mode 100644 x-pack/plugins/osquery/cypress/support/commands.js create mode 100644 x-pack/plugins/osquery/cypress/support/index.ts create mode 100644 x-pack/plugins/osquery/cypress/tasks/integrations.ts create mode 100644 x-pack/plugins/osquery/cypress/tasks/navigation.ts create mode 100644 x-pack/plugins/osquery/cypress/tsconfig.json create mode 100644 x-pack/plugins/osquery/package.json create mode 100644 x-pack/test/osquery_cypress/cli_config.ts create mode 100644 x-pack/test/osquery_cypress/config.ts create mode 100644 x-pack/test/osquery_cypress/ftr_provider_context.d.ts create mode 100644 x-pack/test/osquery_cypress/runner.ts create mode 100644 x-pack/test/osquery_cypress/services.ts create mode 100644 x-pack/test/osquery_cypress/visual_config.ts diff --git a/src/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index 050743114f657d..f372cf052d3683 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -22,6 +22,9 @@ export const PROJECTS = [ new Project(resolve(REPO_ROOT, 'x-pack/plugins/security_solution/cypress/tsconfig.json'), { name: 'security_solution/cypress', }), + new Project(resolve(REPO_ROOT, 'x-pack/plugins/osquery/cypress/tsconfig.json'), { + name: 'osquery/cypress', + }), new Project(resolve(REPO_ROOT, 'x-pack/plugins/apm/e2e/tsconfig.json'), { name: 'apm/cypress', disableTypeCheck: true, diff --git a/x-pack/plugins/osquery/cypress/README.md b/x-pack/plugins/osquery/cypress/README.md new file mode 100644 index 00000000000000..0df311ebc0a05b --- /dev/null +++ b/x-pack/plugins/osquery/cypress/README.md @@ -0,0 +1,138 @@ +# Cypress Tests + +The `osquery/cypress` directory contains functional UI tests that execute using [Cypress](https://www.cypress.io/). + +## Running the tests + +There are currently three ways to run the tests, comprised of two execution modes and two target environments, which will be detailed below. + +### Execution modes + +#### Interactive mode + +When you run Cypress in interactive mode, an interactive runner is displayed that allows you to see commands as they execute while also viewing the application under test. For more information, please see [cypress documentation](https://docs.cypress.io/guides/core-concepts/test-runner.html#Overview). + +#### Headless mode + +A headless browser is a browser simulation program that does not have a user interface. These programs operate like any other browser, but do not display any UI. This is why meanwhile you are executing the tests on this mode you are not going to see the application under test. Just the output of the test is displayed on the terminal once the execution is finished. + +### Target environments + +#### FTR (CI) + +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` + +### Test Execution: Examples + +#### FTR + Headless (Chrome) + +Since this is how tests are run on CI, this will likely be the configuration you want to reproduce failures locally, etc. + +```shell +# bootstrap kibana from the project root +yarn kbn bootstrap + +# build the plugins/assets that cypress will execute against +node scripts/build_kibana_platform_plugins + +# launch the cypress test runner +cd x-pack/plugins/security_solution +yarn cypress:run-as-ci +``` +#### FTR + Interactive + +This is the preferred mode for developing new tests. + +```shell +# bootstrap kibana from the project root +yarn kbn bootstrap + +# build the plugins/assets that cypress will execute against +node scripts/build_kibana_platform_plugins + +# launch the cypress test runner +cd x-pack/plugins/security_solution +yarn cypress:open-as-ci +``` + +Note that you can select the browser you want to use on the top right side of the interactive runner. + +## Folder Structure + +### integration/ + +Cypress convention. Contains the specs that are going to be executed. + +### fixtures/ + +Cypress convention. Fixtures are used as external pieces of static data when we stub responses. + +### plugins/ + +Cypress convention. As a convenience, by default Cypress will automatically include the plugins file cypress/plugins/index.js before every single spec file it runs. + +### screens/ + +Contains the elements we want to interact with in our tests. + +Each file inside the screens folder represents a screen in our application. + +### tasks/ + +_Tasks_ are functions that may be reused across tests. + +Each file inside the tasks folder represents a screen of our application. + +## Test data + +The data the tests need: + +- Is generated on the fly using our application APIs (preferred way) +- Is ingested on the ELS instance using the `es_archive` utility + +### How to generate a new archive + +**Note:** As mentioned above, archives are only meant to contain external data, e.g. beats data. Due to the tendency for archived domain objects (rules, signals) to quickly become out of date, it is strongly suggested that you generate this data within the test, through interaction with either the UI or the API. + +We use es_archiver to manage the data that our Cypress tests need. + +1. Set up a clean instance of kibana and elasticsearch (if this is not possible, try to clean/minimize the data that you are going to archive). +2. With the kibana and elasticsearch instance up and running, create the data that you need for your test. +3. When you are sure that you have all the data you need run the following command from: `x-pack/plugins/security_solution` + +```sh +node ../../../scripts/es_archiver save --dir ../../test/security_solution_cypress/es_archives --config ../../../test/functional/config.js --es-url http://:@: +``` + +Example: + +```sh +node ../../../scripts/es_archiver save custom_rules ".kibana",".siem-signal*" --dir ../../test/security_solution_cypress/es_archives --config ../../../test/functional/config.js --es-url http://elastic:changeme@localhost:9220 +``` + +Note that the command will create the folder if it does not exist. + +## Development Best Practices + +### Clean up the state + +Remember to clean up the state of the test after its execution, typically with the `cleanKibana` function. Be mindful of failure scenarios, as well: if your test fails, will it leave the environment in a recoverable state? + +### Minimize the use of es_archive + +When possible, create all the data that you need for executing the tests using the application APIS or the UI. + +### Speed up test execution time + +Loading the web page takes a big amount of time, in order to minimize that impact, the following points should be +taken into consideration until another solution is implemented: + +- Group the tests that are similar in different contexts. +- For every context login only once, clean the state between tests if needed without re-loading the page. +- All tests in a spec file must be order-independent. + +Remember that minimizing the number of times the web page is loaded, we minimize as well the execution time. + +## Linting + +Optional linting rules for Cypress and linting setup can be found [here](https://github.com/cypress-io/eslint-plugin-cypress#usage) diff --git a/x-pack/plugins/osquery/cypress/cypress.json b/x-pack/plugins/osquery/cypress/cypress.json new file mode 100644 index 00000000000000..eb24616607ec3f --- /dev/null +++ b/x-pack/plugins/osquery/cypress/cypress.json @@ -0,0 +1,14 @@ +{ + "baseUrl": "http://localhost:5620", + "defaultCommandTimeout": 60000, + "execTimeout": 120000, + "pageLoadTimeout": 120000, + "nodeVersion": "system", + "retries": { + "runMode": 2 + }, + "trashAssetsBeforeRuns": false, + "video": false, + "viewportHeight": 900, + "viewportWidth": 1440 +} \ No newline at end of file diff --git a/x-pack/plugins/osquery/cypress/integration/osquery_manager.spec.ts b/x-pack/plugins/osquery/cypress/integration/osquery_manager.spec.ts new file mode 100644 index 00000000000000..0babfd2f10a8e6 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/integration/osquery_manager.spec.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HEADER } from '../screens/osquery'; +import { OSQUERY_NAVIGATION_LINK } from '../screens/navigation'; + +import { INTEGRATIONS, OSQUERY, openNavigationFlyout, navigateTo } from '../tasks/navigation'; +import { addIntegration } from '../tasks/integrations'; + +describe('Osquery Manager', () => { + before(() => { + navigateTo(INTEGRATIONS); + addIntegration('Osquery Manager'); + }); + + it('Displays Osquery on the navigation flyout once installed ', () => { + openNavigationFlyout(); + cy.get(OSQUERY_NAVIGATION_LINK).should('exist'); + }); + + it('Displays Live queries history title when navigating to Osquery', () => { + navigateTo(OSQUERY); + cy.get(HEADER).should('have.text', 'Live queries history'); + }); +}); diff --git a/x-pack/plugins/osquery/cypress/plugins/index.js b/x-pack/plugins/osquery/cypress/plugins/index.js new file mode 100644 index 00000000000000..7dbb69ced7016c --- /dev/null +++ b/x-pack/plugins/osquery/cypress/plugins/index.js @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +module.exports = (_on, _config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +}; diff --git a/x-pack/plugins/osquery/cypress/screens/integrations.ts b/x-pack/plugins/osquery/cypress/screens/integrations.ts new file mode 100644 index 00000000000000..0b29e857f46ee4 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/screens/integrations.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const ADD_POLICY_BTN = '[data-test-subj="addIntegrationPolicyButton"]'; +export const CREATE_PACKAGE_POLICY_SAVE_BTN = '[data-test-subj="createPackagePolicySaveButton"]'; +export const INTEGRATIONS_CARD = '.euiCard__titleAnchor'; diff --git a/x-pack/plugins/osquery/cypress/screens/navigation.ts b/x-pack/plugins/osquery/cypress/screens/navigation.ts new file mode 100644 index 00000000000000..7884cf347d7c09 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/screens/navigation.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 TOGGLE_NAVIGATION_BTN = '[data-test-subj="toggleNavButton"]'; +export const OSQUERY_NAVIGATION_LINK = '[data-test-subj="collapsibleNavAppLink"] [title="Osquery"]'; diff --git a/x-pack/plugins/osquery/cypress/screens/osquery.ts b/x-pack/plugins/osquery/cypress/screens/osquery.ts new file mode 100644 index 00000000000000..bc387a57e9e3c5 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/screens/osquery.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 const HEADER = 'h1'; diff --git a/x-pack/plugins/osquery/cypress/support/commands.js b/x-pack/plugins/osquery/cypress/support/commands.js new file mode 100644 index 00000000000000..66f94350355712 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/support/commands.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) diff --git a/x-pack/plugins/osquery/cypress/support/index.ts b/x-pack/plugins/osquery/cypress/support/index.ts new file mode 100644 index 00000000000000..72618c943f4d24 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/support/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands'; + +// Alternatively you can use CommonJS syntax: +// require('./commands') +Cypress.on('uncaught:exception', () => { + return false; +}); diff --git a/x-pack/plugins/osquery/cypress/tasks/integrations.ts b/x-pack/plugins/osquery/cypress/tasks/integrations.ts new file mode 100644 index 00000000000000..f85ef56550af50 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/tasks/integrations.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 { + ADD_POLICY_BTN, + CREATE_PACKAGE_POLICY_SAVE_BTN, + INTEGRATIONS_CARD, +} from '../screens/integrations'; + +export const addIntegration = (integration: string) => { + cy.get(INTEGRATIONS_CARD).contains(integration).click(); + cy.get(ADD_POLICY_BTN).click(); + cy.get(CREATE_PACKAGE_POLICY_SAVE_BTN).click(); + cy.get(CREATE_PACKAGE_POLICY_SAVE_BTN).should('not.exist'); + cy.reload(); +}; diff --git a/x-pack/plugins/osquery/cypress/tasks/navigation.ts b/x-pack/plugins/osquery/cypress/tasks/navigation.ts new file mode 100644 index 00000000000000..63d6b205b433bf --- /dev/null +++ b/x-pack/plugins/osquery/cypress/tasks/navigation.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TOGGLE_NAVIGATION_BTN } from '../screens/navigation'; + +export const INTEGRATIONS = 'app/integrations#/'; +export const OSQUERY = 'app/osquery/live_queries'; + +export const navigateTo = (page: string) => { + cy.visit(page); +}; + +export const openNavigationFlyout = () => { + cy.get(TOGGLE_NAVIGATION_BTN).click(); +}; diff --git a/x-pack/plugins/osquery/cypress/tsconfig.json b/x-pack/plugins/osquery/cypress/tsconfig.json new file mode 100644 index 00000000000000..467ea13fc48695 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../../tsconfig.base.json", + "exclude": [], + "include": [ + "./**/*" + ], + "compilerOptions": { + "tsBuildInfoFile": "../../../../build/tsbuildinfo/osquery/cypress", + "types": [ + "cypress", + "node" + ], + "resolveJsonModule": true, + }, + } diff --git a/x-pack/plugins/osquery/package.json b/x-pack/plugins/osquery/package.json new file mode 100644 index 00000000000000..5bbb95e556d6be --- /dev/null +++ b/x-pack/plugins/osquery/package.json @@ -0,0 +1,13 @@ +{ + "author": "Elastic", + "name": "osquery", + "version": "8.0.0", + "private": true, + "license": "Elastic-License", + "scripts": { + "cypress:open": "../../../node_modules/.bin/cypress open --config-file ./cypress/cypress.json", + "cypress:open-as-ci": "node ../../../scripts/functional_tests --config ../../test/osquery_cypress/visual_config.ts", + "cypress:run": "../../../node_modules/.bin/cypress run --config-file ./cypress/cypress.json", + "cypress:run-as-ci": "node ../../../scripts/functional_tests --config ../../test/osquery_cypress/cli_config.ts" + } +} diff --git a/x-pack/test/osquery_cypress/cli_config.ts b/x-pack/test/osquery_cypress/cli_config.ts new file mode 100644 index 00000000000000..d0de73151952db --- /dev/null +++ b/x-pack/test/osquery_cypress/cli_config.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; + +import { OsqueryCypressCliTestRunner } from './runner'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const osqueryCypressConfig = await readConfigFile(require.resolve('./config.ts')); + return { + ...osqueryCypressConfig.getAll(), + + testRunner: OsqueryCypressCliTestRunner, + }; +} diff --git a/x-pack/test/osquery_cypress/config.ts b/x-pack/test/osquery_cypress/config.ts new file mode 100644 index 00000000000000..18b4605fb9d8bf --- /dev/null +++ b/x-pack/test/osquery_cypress/config.ts @@ -0,0 +1,43 @@ +/* + * Copyright 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 { CA_CERT_PATH } from '@kbn/dev-utils'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const kibanaCommonTestsConfig = await readConfigFile( + require.resolve('../../../test/common/config.js') + ); + const xpackFunctionalTestsConfig = await readConfigFile( + require.resolve('../functional/config.js') + ); + + return { + ...kibanaCommonTestsConfig.getAll(), + + esTestCluster: { + ...xpackFunctionalTestsConfig.get('esTestCluster'), + serverArgs: [ + ...xpackFunctionalTestsConfig.get('esTestCluster.serverArgs'), + // define custom es server here + // API Keys is enabled at the top level + 'xpack.security.enabled=true', + ], + }, + + kbnTestServer: { + ...xpackFunctionalTestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xpackFunctionalTestsConfig.get('kbnTestServer.serverArgs'), + '--csp.strict=false', + // define custom kibana server args here + `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, + ], + }, + }; +} diff --git a/x-pack/test/osquery_cypress/ftr_provider_context.d.ts b/x-pack/test/osquery_cypress/ftr_provider_context.d.ts new file mode 100644 index 00000000000000..aa56557c09df82 --- /dev/null +++ b/x-pack/test/osquery_cypress/ftr_provider_context.d.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GenericFtrProviderContext } from '@kbn/test'; + +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/osquery_cypress/runner.ts b/x-pack/test/osquery_cypress/runner.ts new file mode 100644 index 00000000000000..32c84af5faf76d --- /dev/null +++ b/x-pack/test/osquery_cypress/runner.ts @@ -0,0 +1,81 @@ +/* + * Copyright 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 { resolve } from 'path'; +import Url from 'url'; + +import { withProcRunner } from '@kbn/dev-utils'; + +import { FtrProviderContext } from './ftr_provider_context'; + +export async function OsqueryCypressCliTestRunner({ getService }: FtrProviderContext) { + const log = getService('log'); + const config = getService('config'); + + await withProcRunner(log, async (procs) => { + await procs.run('cypress', { + cmd: 'yarn', + args: ['cypress:run'], + cwd: resolve(__dirname, '../../plugins/osquery'), + env: { + FORCE_COLOR: '1', + // eslint-disable-next-line @typescript-eslint/naming-convention + CYPRESS_baseUrl: Url.format(config.get('servers.kibana')), + // eslint-disable-next-line @typescript-eslint/naming-convention + CYPRESS_protocol: config.get('servers.kibana.protocol'), + // eslint-disable-next-line @typescript-eslint/naming-convention + CYPRESS_hostname: config.get('servers.kibana.hostname'), + // eslint-disable-next-line @typescript-eslint/naming-convention + CYPRESS_configport: config.get('servers.kibana.port'), + 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'), + CYPRESS_KIBANA_URL: Url.format({ + protocol: config.get('servers.kibana.protocol'), + hostname: config.get('servers.kibana.hostname'), + port: config.get('servers.kibana.port'), + }), + ...process.env, + }, + wait: true, + }); + }); +} + +export async function OsqueryCypressVisualTestRunner({ getService }: FtrProviderContext) { + const log = getService('log'); + const config = getService('config'); + + await withProcRunner(log, async (procs) => { + await procs.run('cypress', { + cmd: 'yarn', + args: ['cypress:open'], + cwd: resolve(__dirname, '../../plugins/osquery'), + env: { + FORCE_COLOR: '1', + // eslint-disable-next-line @typescript-eslint/naming-convention + CYPRESS_baseUrl: Url.format(config.get('servers.kibana')), + // eslint-disable-next-line @typescript-eslint/naming-convention + CYPRESS_protocol: config.get('servers.kibana.protocol'), + // eslint-disable-next-line @typescript-eslint/naming-convention + CYPRESS_hostname: config.get('servers.kibana.hostname'), + // eslint-disable-next-line @typescript-eslint/naming-convention + CYPRESS_configport: config.get('servers.kibana.port'), + 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'), + CYPRESS_KIBANA_URL: Url.format({ + protocol: config.get('servers.kibana.protocol'), + hostname: config.get('servers.kibana.hostname'), + port: config.get('servers.kibana.port'), + }), + ...process.env, + }, + wait: true, + }); + }); +} diff --git a/x-pack/test/osquery_cypress/services.ts b/x-pack/test/osquery_cypress/services.ts new file mode 100644 index 00000000000000..5e063134081ad2 --- /dev/null +++ b/x-pack/test/osquery_cypress/services.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 * from '../../../test/common/services'; diff --git a/x-pack/test/osquery_cypress/visual_config.ts b/x-pack/test/osquery_cypress/visual_config.ts new file mode 100644 index 00000000000000..35ffe311fdc27c --- /dev/null +++ b/x-pack/test/osquery_cypress/visual_config.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; + +import { OsqueryCypressVisualTestRunner } from './runner'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const osqueryCypressConfig = await readConfigFile(require.resolve('./config.ts')); + return { + ...osqueryCypressConfig.getAll(), + + testRunner: OsqueryCypressVisualTestRunner, + }; +} From f8a03829ea454a29bafc2bbfd319678d295d5649 Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Wed, 23 Jun 2021 15:20:21 +0300 Subject: [PATCH 15/61] Allow restored session to run missing searches and show a warning (#101650) * Allow restored session to run missing searches and show a warning * tests and docs * improve warning * tests for new functionality NoSearchIdInSessionError type * managmeent tests * Update texts * fix search service pus * link to docs * imports * format import Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...-plugin-core-public.doclinksstart.links.md | 1 + ...kibana-plugin-core-public.doclinksstart.md | 2 +- ...public.ikibanasearchresponse.isrestored.md | 13 +++ ...ugins-data-public.ikibanasearchresponse.md | 1 + .../kibana-plugin-plugins-data-server.md | 1 + ....nosearchidinsessionerror._constructor_.md | 13 +++ ...ns-data-server.nosearchidinsessionerror.md | 18 ++++ .../public/doc_links/doc_links_service.ts | 2 + src/core/public/public.api.md | 1 + src/plugins/data/common/search/types.ts | 5 + src/plugins/data/public/public.api.md | 1 + .../data/public/search/errors/index.ts | 1 + .../search_session_incomplete_warning.tsx | 31 +++++++ .../search_interceptor.test.ts | 93 +++++++++++++++++++ .../search_interceptor/search_interceptor.ts | 27 ++++++ src/plugins/data/server/index.ts | 1 + .../search/errors/no_search_id_in_session.ts | 15 +++ src/plugins/data/server/search/index.ts | 1 + .../data/server/search/search_service.test.ts | 17 ++++ .../data/server/search/search_service.ts | 43 +++++++-- src/plugins/data/server/server.api.md | 32 ++++--- .../sessions_mgmt/components/status.test.tsx | 1 + .../components/table/table.test.tsx | 6 +- .../search/sessions_mgmt/lib/api.test.ts | 3 + .../public/search/sessions_mgmt/lib/api.ts | 2 + .../sessions_mgmt/lib/get_columns.test.tsx | 36 ++++++- .../search/sessions_mgmt/lib/get_columns.tsx | 14 +++ .../public/search/sessions_mgmt/types.ts | 1 + .../server/search/session/session_service.ts | 8 +- .../api_integration/apis/search/session.ts | 7 +- 30 files changed, 366 insertions(+), 31 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.isrestored.md create mode 100644 docs/development/plugins/data/server/kibana-plugin-plugins-data-server.nosearchidinsessionerror._constructor_.md create mode 100644 docs/development/plugins/data/server/kibana-plugin-plugins-data-server.nosearchidinsessionerror.md create mode 100644 src/plugins/data/public/search/errors/search_session_incomplete_warning.tsx create mode 100644 src/plugins/data/server/search/errors/no_search_id_in_session.ts diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index ae433e3db14c68..b10ad949c49443 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -106,6 +106,7 @@ readonly links: { }; readonly search: { readonly sessions: string; + readonly sessionLimits: string; }; readonly indexPatterns: { readonly introduction: string; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index b0800c7dfc65ea..c020f57faa8825 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
}>;
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
} | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.isrestored.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.isrestored.md new file mode 100644 index 00000000000000..d649212ae05477 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.isrestored.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IKibanaSearchResponse](./kibana-plugin-plugins-data-public.ikibanasearchresponse.md) > [isRestored](./kibana-plugin-plugins-data-public.ikibanasearchresponse.isrestored.md) + +## IKibanaSearchResponse.isRestored property + +Indicates whether the results returned are from the async-search index + +Signature: + +```typescript +isRestored?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.md index 1d3e0c08dfc18d..c7046902dac72b 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.md @@ -16,6 +16,7 @@ export interface IKibanaSearchResponse | --- | --- | --- | | [id](./kibana-plugin-plugins-data-public.ikibanasearchresponse.id.md) | string | Some responses may contain a unique id to identify the request this response came from. | | [isPartial](./kibana-plugin-plugins-data-public.ikibanasearchresponse.ispartial.md) | boolean | Indicates whether the results returned are complete or partial | +| [isRestored](./kibana-plugin-plugins-data-public.ikibanasearchresponse.isrestored.md) | boolean | Indicates whether the results returned are from the async-search index | | [isRunning](./kibana-plugin-plugins-data-public.ikibanasearchresponse.isrunning.md) | boolean | Indicates whether search is still in flight | | [loaded](./kibana-plugin-plugins-data-public.ikibanasearchresponse.loaded.md) | number | If relevant to the search strategy, return a loaded number that represents how progress is indicated. | | [rawResponse](./kibana-plugin-plugins-data-public.ikibanasearchresponse.rawresponse.md) | RawResponse | The raw response returned by the internal search method (usually the raw ES response) | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md index b1745b298e27e8..9816b884c46144 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md @@ -13,6 +13,7 @@ | [IndexPatternsFetcher](./kibana-plugin-plugins-data-server.indexpatternsfetcher.md) | | | [IndexPatternsService](./kibana-plugin-plugins-data-server.indexpatternsservice.md) | | | [IndexPatternsServiceProvider](./kibana-plugin-plugins-data-server.indexpatternsserviceprovider.md) | | +| [NoSearchIdInSessionError](./kibana-plugin-plugins-data-server.nosearchidinsessionerror.md) | | | [OptionedParamType](./kibana-plugin-plugins-data-server.optionedparamtype.md) | | | [Plugin](./kibana-plugin-plugins-data-server.plugin.md) | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.nosearchidinsessionerror._constructor_.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.nosearchidinsessionerror._constructor_.md new file mode 100644 index 00000000000000..e48a1c98f85785 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.nosearchidinsessionerror._constructor_.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [NoSearchIdInSessionError](./kibana-plugin-plugins-data-server.nosearchidinsessionerror.md) > [(constructor)](./kibana-plugin-plugins-data-server.nosearchidinsessionerror._constructor_.md) + +## NoSearchIdInSessionError.(constructor) + +Constructs a new instance of the `NoSearchIdInSessionError` class + +Signature: + +```typescript +constructor(); +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.nosearchidinsessionerror.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.nosearchidinsessionerror.md new file mode 100644 index 00000000000000..707739f845cd14 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.nosearchidinsessionerror.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [NoSearchIdInSessionError](./kibana-plugin-plugins-data-server.nosearchidinsessionerror.md) + +## NoSearchIdInSessionError class + +Signature: + +```typescript +export declare class NoSearchIdInSessionError extends KbnError +``` + +## Constructors + +| Constructor | Modifiers | Description | +| --- | --- | --- | +| [(constructor)()](./kibana-plugin-plugins-data-server.nosearchidinsessionerror._constructor_.md) | | Constructs a new instance of the NoSearchIdInSessionError class | + diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 8c52d09f821595..502b22a6f8e89c 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -204,6 +204,7 @@ export class DocLinksService { }, search: { sessions: `${KIBANA_DOCS}search-sessions.html`, + sessionLimits: `${KIBANA_DOCS}search-sessions.html#_limitations`, }, date: { dateMath: `${ELASTICSEARCH_DOCS}common-options.html#date-math`, @@ -523,6 +524,7 @@ export interface DocLinksStart { }; readonly search: { readonly sessions: string; + readonly sessionLimits: string; }; readonly indexPatterns: { readonly introduction: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 27569935bcc65f..31e85341fb5197 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -585,6 +585,7 @@ export interface DocLinksStart { }; readonly search: { readonly sessions: string; + readonly sessionLimits: string; }; readonly indexPatterns: { readonly introduction: string; diff --git a/src/plugins/data/common/search/types.ts b/src/plugins/data/common/search/types.ts index d1890ec97df4e1..c5cf3f9f09e6c7 100644 --- a/src/plugins/data/common/search/types.ts +++ b/src/plugins/data/common/search/types.ts @@ -65,6 +65,11 @@ export interface IKibanaSearchResponse { */ isPartial?: boolean; + /** + * Indicates whether the results returned are from the async-search index + */ + isRestored?: boolean; + /** * The raw response returned by the internal search method (usually the raw ES response) */ diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 4d9c69b137a3e0..7a5f323e51459a 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1351,6 +1351,7 @@ export interface IKibanaSearchRequest { export interface IKibanaSearchResponse { id?: string; isPartial?: boolean; + isRestored?: boolean; isRunning?: boolean; loaded?: number; rawResponse: RawResponse; diff --git a/src/plugins/data/public/search/errors/index.ts b/src/plugins/data/public/search/errors/index.ts index 82c9e04b797983..fcdea8dec1c2eb 100644 --- a/src/plugins/data/public/search/errors/index.ts +++ b/src/plugins/data/public/search/errors/index.ts @@ -12,3 +12,4 @@ export * from './timeout_error'; export * from './utils'; export * from './types'; export * from './http_error'; +export * from './search_session_incomplete_warning'; diff --git a/src/plugins/data/public/search/errors/search_session_incomplete_warning.tsx b/src/plugins/data/public/search/errors/search_session_incomplete_warning.tsx new file mode 100644 index 00000000000000..c5c5c37f31cf87 --- /dev/null +++ b/src/plugins/data/public/search/errors/search_session_incomplete_warning.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; +import { CoreStart } from 'kibana/public'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const SearchSessionIncompleteWarning = (docLinks: CoreStart['docLinks']) => ( + <> + + It needs more time to fully render. You can wait here or come back to it later. + + + + + + + +); diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts index fe66d4b6e99370..155638250a2a4c 100644 --- a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts @@ -29,6 +29,12 @@ jest.mock('./utils', () => ({ }), })); +jest.mock('../errors/search_session_incomplete_warning', () => ({ + SearchSessionIncompleteWarning: jest.fn(), +})); + +import { SearchSessionIncompleteWarning } from '../errors/search_session_incomplete_warning'; + let searchInterceptor: SearchInterceptor; let mockCoreSetup: MockedKeys; let bfetchSetup: jest.Mocked; @@ -508,6 +514,7 @@ describe('SearchInterceptor', () => { } : null ); + sessionServiceMock.isRestore.mockReturnValue(!!opts?.isRestore); fetchMock.mockResolvedValue({ result: 200 }); }; @@ -562,6 +569,92 @@ describe('SearchInterceptor', () => { (sessionService as jest.Mocked).getSearchOptions ).toHaveBeenCalledWith(sessionId); }); + + test('should not show warning if a search is available during restore', async () => { + setup({ + isRestore: true, + isStored: true, + sessionId: '123', + }); + + const responses = [ + { + time: 10, + value: { + isPartial: false, + isRunning: false, + isRestored: true, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + ]; + mockFetchImplementation(responses); + + const response = searchInterceptor.search( + {}, + { + sessionId: '123', + } + ); + response.subscribe({ next, error, complete }); + + await timeTravel(10); + + expect(SearchSessionIncompleteWarning).toBeCalledTimes(0); + }); + + test('should show warning once if a search is not available during restore', async () => { + setup({ + isRestore: true, + isStored: true, + sessionId: '123', + }); + + const responses = [ + { + time: 10, + value: { + isPartial: false, + isRunning: false, + isRestored: false, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + ]; + mockFetchImplementation(responses); + + searchInterceptor + .search( + {}, + { + sessionId: '123', + } + ) + .subscribe({ next, error, complete }); + + await timeTravel(10); + + expect(SearchSessionIncompleteWarning).toBeCalledTimes(1); + + searchInterceptor + .search( + {}, + { + sessionId: '123', + } + ) + .subscribe({ next, error, complete }); + + await timeTravel(10); + + expect(SearchSessionIncompleteWarning).toBeCalledTimes(1); + }); }); describe('Session tracking', () => { diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts index 57b156a9b3c00a..e0e1df65101c7d 100644 --- a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts @@ -43,6 +43,7 @@ import { PainlessError, SearchTimeoutError, TimeoutErrorMode, + SearchSessionIncompleteWarning, } from '../errors'; import { toMountPoint } from '../../../../kibana_react/public'; import { AbortError, KibanaServerError } from '../../../../kibana_utils/public'; @@ -82,6 +83,7 @@ export class SearchInterceptor { * @internal */ private application!: CoreStart['application']; + private docLinks!: CoreStart['docLinks']; private batchedFetch!: BatchedFunc< { request: IKibanaSearchRequest; options: ISearchOptionsSerializable }, IKibanaSearchResponse @@ -95,6 +97,7 @@ export class SearchInterceptor { this.deps.startServices.then(([coreStart]) => { this.application = coreStart.application; + this.docLinks = coreStart.docLinks; }); this.batchedFetch = deps.bfetch.batchedFunction({ @@ -345,6 +348,11 @@ export class SearchInterceptor { this.handleSearchError(e, searchOptions, searchAbortController.isTimeout()) ); }), + tap((response) => { + if (this.deps.session.isRestore() && response.isRestored === false) { + this.showRestoreWarning(this.deps.session.getSessionId()); + } + }), finalize(() => { this.pendingCount$.next(this.pendingCount$.getValue() - 1); if (untrackSearch && this.deps.session.isCurrentSession(sessionId)) { @@ -371,6 +379,25 @@ export class SearchInterceptor { } ); + private showRestoreWarningToast = (sessionId?: string) => { + this.deps.toasts.addWarning( + { + title: 'Your search session is still running', + text: toMountPoint(SearchSessionIncompleteWarning(this.docLinks)), + }, + { + toastLifeTimeMs: 60000, + } + ); + }; + + private showRestoreWarning = memoize( + this.showRestoreWarningToast, + (_: SearchTimeoutError, sessionId: string) => { + return sessionId; + } + ); + /** * Show one error notification per session. * @internal diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index 0764f4f441e425..dd60951e6d2285 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -238,6 +238,7 @@ export { DataRequestHandlerContext, AsyncSearchResponse, AsyncSearchStatusResponse, + NoSearchIdInSessionError, } from './search'; // Search namespace diff --git a/src/plugins/data/server/search/errors/no_search_id_in_session.ts b/src/plugins/data/server/search/errors/no_search_id_in_session.ts new file mode 100644 index 00000000000000..b291df1cee5ba7 --- /dev/null +++ b/src/plugins/data/server/search/errors/no_search_id_in_session.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KbnError } from '../../../../kibana_utils/common'; + +export class NoSearchIdInSessionError extends KbnError { + constructor() { + super('No search ID in this session matching the given search request'); + } +} diff --git a/src/plugins/data/server/search/index.ts b/src/plugins/data/server/search/index.ts index 812f3171aef99f..b9affe96ea2ddd 100644 --- a/src/plugins/data/server/search/index.ts +++ b/src/plugins/data/server/search/index.ts @@ -13,3 +13,4 @@ export * from './strategies/eql_search'; export { usageProvider, SearchUsage, searchUsageObserver } from './collectors'; export * from './aggs'; export * from './session'; +export * from './errors/no_search_id_in_session'; diff --git a/src/plugins/data/server/search/search_service.test.ts b/src/plugins/data/server/search/search_service.test.ts index 52ee8e60a5b26a..314cb2c3acbf87 100644 --- a/src/plugins/data/server/search/search_service.test.ts +++ b/src/plugins/data/server/search/search_service.test.ts @@ -25,6 +25,7 @@ import { ISearchSessionService, ISearchStart, ISearchStrategy, + NoSearchIdInSessionError, } from '.'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { expressionsPluginMock } from '../../../expressions/public/mocks'; @@ -175,6 +176,22 @@ describe('Search service', () => { expect(request).toStrictEqual({ ...searchRequest, id: 'my_id' }); }); + it('searches even if id is not found in session during restore', async () => { + const searchRequest = { params: {} }; + const options = { sessionId, isStored: true, isRestore: true }; + + mockSessionClient.getId = jest.fn().mockImplementation(() => { + throw new NoSearchIdInSessionError(); + }); + + const res = await mockScopedClient.search(searchRequest, options).toPromise(); + + const [request, callOptions] = mockStrategy.search.mock.calls[0]; + expect(callOptions).toBe(options); + expect(request).toStrictEqual({ ...searchRequest }); + expect(res.isRestored).toBe(false); + }); + it('does not fail if `trackId` throws', async () => { const searchRequest = { params: {} }; const options = { sessionId, isStored: false, isRestore: false }; diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index a651d7b3bf105e..00dffefa5e3a6e 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -19,7 +19,7 @@ import { SharedGlobalConfig, StartServicesAccessor, } from 'src/core/server'; -import { first, switchMap, tap } from 'rxjs/operators'; +import { first, map, switchMap, tap, withLatestFrom } from 'rxjs/operators'; import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import type { @@ -80,6 +80,7 @@ import { registerBsearchRoute } from './routes/bsearch'; import { getKibanaContext } from './expressions/kibana_context'; import { enhancedEsSearchStrategyProvider } from './strategies/ese_search'; import { eqlSearchStrategyProvider } from './strategies/eql_search'; +import { NoSearchIdInSessionError } from './errors/no_search_id_in_session'; type StrategyMap = Record>; @@ -287,24 +288,48 @@ export class SearchService implements Plugin { options.strategy ); - const getSearchRequest = async () => - !options.sessionId || !options.isRestore || request.id - ? request - : { + const getSearchRequest = async () => { + if (!options.sessionId || !options.isRestore || request.id) { + return request; + } else { + try { + const id = await deps.searchSessionsClient.getId(request, options); + this.logger.debug(`Found search session id for request ${id}`); + return { ...request, - id: await deps.searchSessionsClient.getId(request, options), + id, }; + } catch (e) { + if (e instanceof NoSearchIdInSessionError) { + this.logger.debug('Ignoring missing search ID'); + return request; + } else { + throw e; + } + } + } + }; - return from(getSearchRequest()).pipe( + const searchRequest$ = from(getSearchRequest()); + const search$ = searchRequest$.pipe( switchMap((searchRequest) => strategy.search(searchRequest, options, deps)), - tap((response) => { - if (!options.sessionId || !response.id || options.isRestore) return; + withLatestFrom(searchRequest$), + tap(([response, requestWithId]) => { + if (!options.sessionId || !response.id || (options.isRestore && requestWithId.id)) return; // intentionally swallow tracking error, as it shouldn't fail the search deps.searchSessionsClient.trackId(request, response.id, options).catch((trackErr) => { this.logger.error(trackErr); }); + }), + map(([response, requestWithId]) => { + return { + ...response, + isRestored: !!requestWithId.id, + }; }) ); + + return search$; } catch (e) { return throwError(e); } diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index c2b533bc42dc6f..768c44d3e3e950 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -1205,6 +1205,14 @@ export enum METRIC_TYPES { TOP_HITS = "top_hits" } +// Warning: (ae-forgotten-export) The symbol "KbnError" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "NoSearchIdInSessionError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class NoSearchIdInSessionError extends KbnError { + constructor(); +} + // Warning: (ae-missing-release-tag) "OptionedParamType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1537,18 +1545,18 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "HistogramFormat" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:128:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:128:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:244:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:244:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:246:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:247:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:256:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:257:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:258:1 - (ae-forgotten-export) The symbol "IpAddress" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:262:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:263:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:267:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:270:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:271:1 - (ae-forgotten-export) The symbol "calcAutoIntervalLessThan" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:245:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:245:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:247:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:248:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:257:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:258:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:259:1 - (ae-forgotten-export) The symbol "IpAddress" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:263:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:264:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:268:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:271:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:272:1 - (ae-forgotten-export) The symbol "calcAutoIntervalLessThan" needs to be exported by the entry point index.d.ts // src/plugins/data/server/plugin.ts:81:74 - (ae-forgotten-export) The symbol "DataEnhancements" needs to be exported by the entry point index.d.ts // src/plugins/data/server/search/types.ts:115:5 - (ae-forgotten-export) The symbol "ISearchStartSearchSource" needs to be exported by the entry point index.d.ts diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/status.test.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/status.test.tsx index 86f5564a17d528..59da0f0f4d17e5 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/status.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/status.test.tsx @@ -27,6 +27,7 @@ describe('Background Search Session management status labels', () => { id: 'wtywp9u2802hahgp-gsla', restoreUrl: '/app/great-app-url/#45', reloadUrl: '/app/great-app-url/#45', + numSearches: 1, appId: 'security', status: SearchSessionStatus.IN_PROGRESS, created: '2020-12-02T00:19:32Z', diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/table/table.test.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/table/table.test.tsx index 42ff270ed44a06..6dfe3a5153670b 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/table/table.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/table/table.test.tsx @@ -70,6 +70,7 @@ describe('Background Search Session Management Table', () => { status: SearchSessionStatus.IN_PROGRESS, created: '2020-12-02T00:19:32Z', expires: '2020-12-07T00:19:32Z', + idMapping: {}, }, }, ], @@ -95,10 +96,12 @@ describe('Background Search Session Management Table', () => { ); }); - expect(table.find('thead th').map((node) => node.text())).toMatchInlineSnapshot(` + expect(table.find('thead th .euiTableCellContent__text').map((node) => node.text())) + .toMatchInlineSnapshot(` Array [ "App", "Name", + "# Searches", "Status", "Created", "Expiration", @@ -130,6 +133,7 @@ describe('Background Search Session Management Table', () => { Array [ "App", "Namevery background search ", + "# Searches0", "StatusExpired", "Created2 Dec, 2020, 00:19:32", "Expiration--", diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.test.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.test.ts index 3857b08ad0a3ae..cc79f8002a98c4 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.test.ts @@ -52,6 +52,7 @@ describe('Search Sessions Management API', () => { status: 'complete', initialState: {}, restoreState: {}, + idMapping: [], }, }, ], @@ -78,6 +79,7 @@ describe('Search Sessions Management API', () => { "id": "hello-pizza-123", "initialState": Object {}, "name": "Veggie", + "numSearches": 0, "reloadUrl": "hello-cool-undefined-url", "restoreState": Object {}, "restoreUrl": "hello-cool-undefined-url", @@ -100,6 +102,7 @@ describe('Search Sessions Management API', () => { expires: moment().subtract(3, 'days'), initialState: {}, restoreState: {}, + idMapping: {}, }, }, ], diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts index 3710dfa16e76b8..0369dc4a839b51 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts @@ -90,6 +90,7 @@ const mapToUISession = (urls: UrlGeneratorsStart, config: SessionsConfigSchema) urlGeneratorId, initialState, restoreState, + idMapping, } = savedObject.attributes; const status = getUIStatus(savedObject.attributes); @@ -113,6 +114,7 @@ const mapToUISession = (urls: UrlGeneratorsStart, config: SessionsConfigSchema) reloadUrl, initialState, restoreState, + numSearches: Object.keys(idMapping).length, }; }; diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx index 4b68e0c9e2afd0..fc4e67360ea4aa 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx @@ -70,6 +70,7 @@ describe('Search Sessions Management table column factory', () => { reloadUrl: '/app/great-app-url', restoreUrl: '/app/great-app-url/#42', appId: 'discovery', + numSearches: 3, status: SearchSessionStatus.IN_PROGRESS, created: '2020-12-02T00:19:32Z', expires: '2020-12-07T00:19:32Z', @@ -95,6 +96,12 @@ describe('Search Sessions Management table column factory', () => { "sortable": true, "width": "20%", }, + Object { + "field": "numSearches", + "name": "# Searches", + "render": [Function], + "sortable": true, + }, Object { "field": "status", "name": "Status", @@ -146,10 +153,29 @@ describe('Search Sessions Management table column factory', () => { }); }); + // Num of searches column + describe('num of searches', () => { + test('renders', () => { + const [, , numOfSearches] = getColumns( + mockCoreStart, + mockPluginsSetup, + api, + mockConfig, + tz, + handleAction + ) as Array>; + + const numOfSearchesLine = mount( + numOfSearches.render!(mockSession.numSearches, mockSession) as ReactElement + ); + expect(numOfSearchesLine.text()).toMatchInlineSnapshot(`"3"`); + }); + }); + // Status column describe('status', () => { test('render in_progress', () => { - const [, , status] = getColumns( + const [, , , status] = getColumns( mockCoreStart, mockPluginsSetup, api, @@ -165,7 +191,7 @@ describe('Search Sessions Management table column factory', () => { }); test('error handling', () => { - const [, , status] = getColumns( + const [, , , status] = getColumns( mockCoreStart, mockPluginsSetup, api, @@ -188,7 +214,7 @@ describe('Search Sessions Management table column factory', () => { test('render using Browser timezone', () => { tz = 'Browser'; - const [, , , createdDateCol] = getColumns( + const [, , , , createdDateCol] = getColumns( mockCoreStart, mockPluginsSetup, api, @@ -205,7 +231,7 @@ describe('Search Sessions Management table column factory', () => { test('render using AK timezone', () => { tz = 'US/Alaska'; - const [, , , createdDateCol] = getColumns( + const [, , , , createdDateCol] = getColumns( mockCoreStart, mockPluginsSetup, api, @@ -220,7 +246,7 @@ describe('Search Sessions Management table column factory', () => { }); test('error handling', () => { - const [, , , createdDateCol] = getColumns( + const [, , , , createdDateCol] = getColumns( mockCoreStart, mockPluginsSetup, api, diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx index 1805ef52b85f1d..d8d2fa0aeac59c 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx @@ -120,6 +120,20 @@ export const getColumns = ( }, }, + // # Searches + { + field: 'numSearches', + name: i18n.translate('xpack.data.mgmt.searchSessions.table.numSearches', { + defaultMessage: '# Searches', + }), + sortable: true, + render: (numSearches: UISession['numSearches'], session) => ( + + {numSearches} + + ), + }, + // Session status { field: 'status', diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/types.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/types.ts index d0d5ee9fb17dd3..6a8ace8dbdc79a 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/types.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/types.ts @@ -34,6 +34,7 @@ export interface UISession { created: string; expires: string | null; status: UISearchSessionState; + numSearches: number; actions?: ACTION[]; reloadUrl: string; restoreUrl: string; diff --git a/x-pack/plugins/data_enhanced/server/search/session/session_service.ts b/x-pack/plugins/data_enhanced/server/search/session/session_service.ts index 138f42549a0944..81a12f607935d2 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/session_service.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/session_service.ts @@ -24,7 +24,11 @@ import { ENHANCED_ES_SEARCH_STRATEGY, SEARCH_SESSION_TYPE, } from '../../../../../../src/plugins/data/common'; -import { esKuery, ISearchSessionService } from '../../../../../../src/plugins/data/server'; +import { + esKuery, + ISearchSessionService, + NoSearchIdInSessionError, +} from '../../../../../../src/plugins/data/server'; import { AuthenticatedUser, SecurityPluginSetup } from '../../../../security/server'; import { TaskManagerSetupContract, @@ -436,7 +440,7 @@ export class SearchSessionService const requestHash = createRequestHash(searchRequest.params); if (!session.attributes.idMapping.hasOwnProperty(requestHash)) { this.logger.error(`getId | ${sessionId} | ${requestHash} not found`); - throw new Error('No search ID in this session matching the given search request'); + throw new NoSearchIdInSessionError(); } this.logger.debug(`getId | ${sessionId} | ${requestHash}`); diff --git a/x-pack/test/api_integration/apis/search/session.ts b/x-pack/test/api_integration/apis/search/session.ts index d47199a0f1c1e2..06be7c6759bc05 100644 --- a/x-pack/test/api_integration/apis/search/session.ts +++ b/x-pack/test/api_integration/apis/search/session.ts @@ -403,7 +403,12 @@ export default function ({ getService }: FtrProviderContext) { const { id: id1 } = searchRes1.body; // it might take the session a moment to be created - await new Promise((resolve) => setTimeout(resolve, 2500)); + await retry.waitFor('search session created', async () => { + const response = await supertest + .get(`/internal/session/${sessionId}`) + .set('kbn-xsrf', 'foo'); + return response.body.statusCode === undefined; + }); const getSessionFirstTime = await supertest .get(`/internal/session/${sessionId}`) From 771f7de87b89b3f05d6b31fcf1eb0c01a8c7cb9a Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 23 Jun 2021 08:26:46 -0400 Subject: [PATCH 16/61] [Fleet] Improve default port experience in the settings UI (#102982) --- .../services/hosts_utils.test.ts | 0 .../services/hosts_utils.ts | 0 x-pack/plugins/fleet/common/services/index.ts | 2 + .../components/settings_flyout/index.tsx | 47 ++++++++++++++----- .../plugins/fleet/server/services/output.ts | 3 +- .../plugins/fleet/server/services/settings.ts | 7 ++- 6 files changed, 42 insertions(+), 17 deletions(-) rename x-pack/plugins/fleet/{server => common}/services/hosts_utils.test.ts (100%) rename x-pack/plugins/fleet/{server => common}/services/hosts_utils.ts (100%) diff --git a/x-pack/plugins/fleet/server/services/hosts_utils.test.ts b/x-pack/plugins/fleet/common/services/hosts_utils.test.ts similarity index 100% rename from x-pack/plugins/fleet/server/services/hosts_utils.test.ts rename to x-pack/plugins/fleet/common/services/hosts_utils.test.ts diff --git a/x-pack/plugins/fleet/server/services/hosts_utils.ts b/x-pack/plugins/fleet/common/services/hosts_utils.ts similarity index 100% rename from x-pack/plugins/fleet/server/services/hosts_utils.ts rename to x-pack/plugins/fleet/common/services/hosts_utils.ts diff --git a/x-pack/plugins/fleet/common/services/index.ts b/x-pack/plugins/fleet/common/services/index.ts index 86361ae1633995..a6f4cd319b9701 100644 --- a/x-pack/plugins/fleet/common/services/index.ts +++ b/x-pack/plugins/fleet/common/services/index.ts @@ -30,3 +30,5 @@ export { validationHasErrors, countValidationErrors, } from './validate_package_policy'; + +export { normalizeHostsForAgents } from './hosts_utils'; diff --git a/x-pack/plugins/fleet/public/components/settings_flyout/index.tsx b/x-pack/plugins/fleet/public/components/settings_flyout/index.tsx index d748e655bd5062..9bc1bc977b7861 100644 --- a/x-pack/plugins/fleet/public/components/settings_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/components/settings_flyout/index.tsx @@ -38,7 +38,7 @@ import { useGetOutputs, sendPutOutput, } from '../../hooks'; -import { isDiffPathProtocol } from '../../../common'; +import { isDiffPathProtocol, normalizeHostsForAgents } from '../../../common'; import { SettingsConfirmModal } from './confirm_modal'; import type { SettingsConfirmModalProps } from './confirm_modal'; @@ -53,8 +53,20 @@ interface Props { onClose: () => void; } -function isSameArrayValue(arrayA: string[] = [], arrayB: string[] = []) { - return arrayA.length === arrayB.length && arrayA.every((val, index) => val === arrayB[index]); +function normalizeHosts(hostsInput: string[]) { + return hostsInput.map((host) => { + try { + return normalizeHostsForAgents(host); + } catch (err) { + return host; + } + }); +} + +function isSameArrayValueWithNormalizedHosts(arrayA: string[] = [], arrayB: string[] = []) { + const hostsA = normalizeHosts(arrayA); + const hostsB = normalizeHosts(arrayB); + return hostsA.length === hostsB.length && hostsA.every((val, index) => val === hostsB[index]); } function useSettingsForm(outputId: string | undefined, onSuccess: () => void) { @@ -234,8 +246,11 @@ export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { return false; } return ( - !isSameArrayValue(settings.fleet_server_hosts, inputs.fleetServerHosts.value) || - !isSameArrayValue(output.hosts, inputs.elasticsearchUrl.value) || + !isSameArrayValueWithNormalizedHosts( + settings.fleet_server_hosts, + inputs.fleetServerHosts.value + ) || + !isSameArrayValueWithNormalizedHosts(output.hosts, inputs.elasticsearchUrl.value) || (output.config_yaml || '') !== inputs.additionalYamlConfig.value ); }, [settings, inputs, output]); @@ -246,32 +261,37 @@ export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { } const tmpChanges: SettingsConfirmModalProps['changes'] = []; - if (!isSameArrayValue(output.hosts, inputs.elasticsearchUrl.value)) { + if (!isSameArrayValueWithNormalizedHosts(output.hosts, inputs.elasticsearchUrl.value)) { tmpChanges.push( { type: 'elasticsearch', direction: 'removed', - urls: output.hosts || [], + urls: normalizeHosts(output.hosts || []), }, { type: 'elasticsearch', direction: 'added', - urls: inputs.elasticsearchUrl.value, + urls: normalizeHosts(inputs.elasticsearchUrl.value), } ); } - if (!isSameArrayValue(settings.fleet_server_hosts, inputs.fleetServerHosts.value)) { + if ( + !isSameArrayValueWithNormalizedHosts( + settings.fleet_server_hosts, + inputs.fleetServerHosts.value + ) + ) { tmpChanges.push( { type: 'fleet_server', direction: 'removed', - urls: settings.fleet_server_hosts, + urls: normalizeHosts(settings.fleet_server_hosts || []), }, { type: 'fleet_server', direction: 'added', - urls: inputs.fleetServerHosts.value, + urls: normalizeHosts(inputs.fleetServerHosts.value), } ); } @@ -300,7 +320,7 @@ export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { helpText={ = ({ onClose }) => { defaultMessage: 'Elasticsearch hosts', })} helpText={i18n.translate('xpack.fleet.settings.elasticsearchUrlsHelpTect', { - defaultMessage: 'Specify the Elasticsearch URLs where agents send data.', + defaultMessage: + 'Specify the Elasticsearch URLs where agents send data. Elasticsearch uses port 9200 by default.', })} /> diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index 0c7b086f78fdf8..8c6bc7eca04010 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -9,10 +9,9 @@ import type { SavedObjectsClientContract } from 'src/core/server'; import type { NewOutput, Output, OutputSOAttributes } from '../types'; import { DEFAULT_OUTPUT, OUTPUT_SAVED_OBJECT_TYPE } from '../constants'; -import { decodeCloudId } from '../../common'; +import { decodeCloudId, normalizeHostsForAgents } from '../../common'; import { appContextService } from './app_context'; -import { normalizeHostsForAgents } from './hosts_utils'; const SAVED_OBJECT_TYPE = OUTPUT_SAVED_OBJECT_TYPE; diff --git a/x-pack/plugins/fleet/server/services/settings.ts b/x-pack/plugins/fleet/server/services/settings.ts index 226fbb29467c2f..26d581f32d9a23 100644 --- a/x-pack/plugins/fleet/server/services/settings.ts +++ b/x-pack/plugins/fleet/server/services/settings.ts @@ -8,11 +8,14 @@ import Boom from '@hapi/boom'; import type { SavedObjectsClientContract } from 'kibana/server'; -import { decodeCloudId, GLOBAL_SETTINGS_SAVED_OBJECT_TYPE } from '../../common'; +import { + decodeCloudId, + GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + normalizeHostsForAgents, +} from '../../common'; import type { SettingsSOAttributes, Settings, BaseSettings } from '../../common'; import { appContextService } from './app_context'; -import { normalizeHostsForAgents } from './hosts_utils'; export async function getSettings(soClient: SavedObjectsClientContract): Promise { const res = await soClient.find({ From 6d8f53d8d0077c98e62a0ceb91926d2225376107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20G=C3=B3mez?= Date: Wed, 23 Jun 2021 15:50:39 +0200 Subject: [PATCH 17/61] Adjust copy for non-removable integrations/packages (#103068) --- .../sections/epm/screens/detail/settings/settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx index 995423ea91f968..9e8d200344b01d 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx @@ -233,7 +233,7 @@ export const SettingsPage: React.FC = memo(({ packageInfo }: Props) => { , From f49ecb3d1a461f0e928d04d134ad2f2f3b93c866 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Wed, 23 Jun 2021 09:53:16 -0400 Subject: [PATCH 18/61] Update chart reference docs (#102430) * Update chart reference docs * Update from feedback * Update from review feedback * Update more from comments * Apply left alignment --- .../dashboard/aggregation-reference.asciidoc | 448 +++++++++++------- docs/user/dashboard/lens.asciidoc | 36 ++ 2 files changed, 309 insertions(+), 175 deletions(-) diff --git a/docs/user/dashboard/aggregation-reference.asciidoc b/docs/user/dashboard/aggregation-reference.asciidoc index cb5c484def3b9d..17bfc19c2e0c9d 100644 --- a/docs/user/dashboard/aggregation-reference.asciidoc +++ b/docs/user/dashboard/aggregation-reference.asciidoc @@ -12,91 +12,168 @@ This reference can help simplify the comparison if you need a specific feature. [options="header"] |=== -| Type | Aggregation-based | Lens | TSVB | Timelion | Vega +| Type | Lens | TSVB | Agg-based | Vega | Timelion | Table -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ | | -| Table with summary row -^| X -^| X -| +| Bar, line, and area +| ✓ +| ✓ +| ✓ +| ✓ +| ✓ + +| Split chart/small multiples | +| ✓ +| ✓ +| ✓ | -| Bar, line, and area charts -^| X -^| X -^| X -^| X -^| X +| Pie and donut +| ✓ +| +| ✓ +| ✓ +| -| Percentage bar or area chart +| Sunburst +| ✓ | -^| X -^| X +| ✓ +| ✓ | -^| X -| Split bar, line, and area charts -^| X +| Treemap +| ✓ +| | +| ✓ | + +| Heat map +| ✓ +| ✓ +| ✓ +| ✓ | -^| X -| Pie and donut charts -^| X -^| X +| Gauge and Goal | +| ✓ +| ✓ +| ✓ | -^| X -| Sunburst chart -^| X -^| X +| Markdown +| +| ✓ | | | -| Heat map -^| X -^| X +| Metric +| ✓ +| ✓ +| ✓ +| ✓ +| + +| Tag cloud | | -^| X +| ✓ +| ✓ +| -| Gauge and Goal -^| X +|=== + +[float] +[[table-features]] +=== Table features + +[options="header"] +|=== + +| Type | Lens | TSVB | Agg-based + +| Summary row +| ✓ | -^| X +| ✓ + +| Pivot table +| ✓ | | -| Markdown +| Calculated column +| Formula +| ✓ +| Percent only + +| Color by value +| ✓ +| ✓ | + +|=== + +[float] +[[xy-features]] +=== Bar, line, area features + +[options="header"] +|=== + +| Type | Lens | TSVB | Agg-based | Vega | Timelion + +| Dense time series +| Customizable +| ✓ +| Customizable +| ✓ +| ✓ + +| Percentage mode +| ✓ +| ✓ +| ✓ +| ✓ | -^| X + +| Break downs +| 1 +| 1 +| 3 +| ∞ +| 1 + +| Custom color with break downs | +| Only for Filters +| ✓ +| ✓ | -| Metric -^| X -^| X -^| X +| Fit missing values +| ✓ | -^| X +| ✓ +| ✓ +| ✓ -| Tag cloud -^| X +| Synchronized tooltips +| +| ✓ | | | -^| X |=== @@ -111,67 +188,57 @@ For information about {es} bucket aggregations, refer to {ref}/search-aggregatio [options="header"] |=== -| Type | Agg-based | Markdown | Lens | TSVB +| Type | Lens | TSVB | Agg-based | Histogram -^| X -^| X -^| X +| ✓ | +| ✓ | Date histogram -^| X -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ | Date range -^| X -^| X -| +| Use filters | +| ✓ | Filter -^| X -^| X | -^| X +| ✓ +| | Filters -^| X -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ | GeoHash grid -^| X -^| X | | +| ✓ | IP range -^| X -^| X -| -| +| Use filters +| Use filters +| ✓ | Range -^| X -^| X -^| X -| +| ✓ +| Use filters +| ✓ | Terms -^| X -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ | Significant terms -^| X -^| X | -^| X +| +| ✓ |=== @@ -186,67 +253,57 @@ For information about {es} metrics aggregations, refer to {ref}/search-aggregati [options="header"] |=== -| Type | Agg-based | Markdown | Lens | TSVB +| Type | Lens | TSVB | Agg-based | Metrics with filters +| ✓ | | -^| X -| - -| Average -^| X -^| X -^| X -^| X -| Sum -^| X -^| X -^| X -^| X +| Average, Sum, Max, Min +| ✓ +| ✓ +| ✓ | Unique count (Cardinality) -^| X -^| X -^| X -^| X - -| Max -^| X -^| X -^| X -^| X - -| Min -^| X -^| X -^| X -^| X - -| Percentiles -^| X -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ + +| Percentiles and Median +| ✓ +| ✓ +| ✓ | Percentiles Rank -^| X -^| X -| -^| X +| +| ✓ +| ✓ + +| Standard deviation +| +| ✓ +| ✓ + +| Sum of squares +| +| ✓ +| | Top hit (Last value) -^| X -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ | Value count | | +| ✓ + +| Variance +| +| ✓ | -^| X |=== @@ -261,61 +318,94 @@ For information about {es} pipeline aggregations, refer to {ref}/search-aggregat [options="header"] |=== -| Type | Agg-based | Markdown | Lens | TSVB +| Type | Lens | TSVB | Agg-based | Avg bucket -^| X -^| X -| -^| X +| <> +| ✓ +| ✓ | Derivative -^| X -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ | Max bucket -^| X -^| X -| -^| X +| <> +| ✓ +| ✓ | Min bucket -^| X -^| X -| -^| X +| <> +| ✓ +| ✓ | Sum bucket -^| X -^| X -| -^| X +| <> +| ✓ +| ✓ | Moving average -^| X -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ | Cumulative sum -^| X -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ | Bucket script | | +| ✓ + +| Bucket selector +| | -^| X +| | Serial differencing -^| X -^| X | -^| X +| ✓ +| ✓ + +|=== + +[float] +[[custom-functions]] +=== Additional functions + +[options="header"] +|=== + +| Type | Lens | TSVB | Agg-based + +| Counter rate +| ✓ +| ✓ +| + +| <> +| Use <> +| ✓ +| + +| <> +| +| ✓ +| + +| <> +| +| ✓ +| + +| Static value +| +| ✓ +| + |=== @@ -329,41 +419,49 @@ build their advanced visualization. [options="header"] |=== -| Type | Agg-based | Lens | TSVB | Timelion | Vega +| Type | Lens | TSVB | Agg-based | Vega | Timelion -| Math on aggregated data +| Math +| ✓ +| ✓ | -^| X -^| X -^| X -^| X +| ✓ +| ✓ | Visualize two indices +| ✓ +| ✓ | -^| X -^| X -^| X -^| X +| ✓ +| ✓ | Math across indices | | | -^| X -^| X +| ✓ +| ✓ | Time shifts +| ✓ +| ✓ | -^| X -^| X -^| X -^| X +| ✓ +| ✓ | Fully custom {es} queries | | | +| ✓ | -^| X + +| Normalize by time +| ✓ +| ✓ +| +| +| + |=== diff --git a/docs/user/dashboard/lens.asciidoc b/docs/user/dashboard/lens.asciidoc index 4ecfcc92501228..2071f17ecff3d5 100644 --- a/docs/user/dashboard/lens.asciidoc +++ b/docs/user/dashboard/lens.asciidoc @@ -139,6 +139,42 @@ image::images/lens_drag_drop_3.gif[Using drag and drop to reorder] . Press Space bar to confirm, or to cancel, press Esc. +[float] +[[lens-formulas]] +==== Use formulas to perform math + +Formulas let you perform math on aggregated data in Lens by typing +math and quick functions. To access formulas, +click the *Formula* tab in the dimension editor. Access the complete +reference for formulas from the help menu. + +The most common formulas are dividing two values to produce a percent. +To display accurately, set *Value format* to *Percent*. + +Filter ratio:: + +Use `kql=''` to filter one set of documents and compare it to other documents within the same grouping. +For example, to see how the error rate changes over time: ++ +``` +count(kql='response.status_code > 400') / count() +``` + +Week over week:: Use `shift='1w'` to get the value of each grouping from +the previous week. Time shift should not be used with the *Top values* function. ++ +``` +percentile(system.network.in.bytes, percentile=99) / +percentile(system.network.in.bytes, percentile=99, shift='1w') +``` + +Percent of total:: Formulas can calculate `overall_sum` for all the groupings, +which lets you convert each grouping into a percent of total: ++ +``` +sum(products.base_price) / overall_sum(sum(products.base_price)) +``` + [float] [[lens-faq]] ==== Frequently asked questions From eb9726987cc1e57a0285acb89bd2428035873018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Wed, 23 Jun 2021 16:04:23 +0200 Subject: [PATCH 19/61] [Security Solution][Endpoint] Hide endpoint event filters list in detections tab (#102644) * Add event filters filter on exception list to hide it in UI * Fixes unit test and added more tests for showEventFilters * fixes test adding showEventFilters test cases * Pass params as js object instead of individual variables Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../src/typescript_types/index.ts | 1 + .../src/use_exception_lists/index.ts | 7 +- .../get_event_filters_filter/index.test.ts | 39 +++ .../src/get_event_filters_filter/index.ts | 27 ++ .../src/get_filters/index.test.ts | 274 ++++++++++++++++-- .../src/get_filters/index.ts | 24 +- .../hooks/use_exception_lists.test.ts | 89 +++++- .../rules/all/exceptions/exceptions_table.tsx | 1 + 8 files changed, 420 insertions(+), 42 deletions(-) create mode 100644 packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts create mode 100644 packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts index f75f0dcebf4f67..1909bcb1bcc2e6 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts @@ -42,6 +42,7 @@ export interface UseExceptionListsProps { notifications: NotificationsStart; pagination?: Pagination; showTrustedApps: boolean; + showEventFilters: boolean; } export interface UseExceptionListProps { diff --git a/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts b/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts index a9a93aa8df49a6..0bd4c6c705668d 100644 --- a/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts +++ b/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts @@ -28,6 +28,7 @@ export type ReturnExceptionLists = [boolean, ExceptionListSchema[], Pagination, * @param namespaceTypes spaces to be searched * @param notifications kibana service for displaying toasters * @param showTrustedApps boolean - include/exclude trusted app lists + * @param showEventFilters boolean - include/exclude event filters lists * @param pagination * */ @@ -43,6 +44,7 @@ export const useExceptionLists = ({ namespaceTypes, notifications, showTrustedApps = false, + showEventFilters = false, }: UseExceptionListsProps): ReturnExceptionLists => { const [exceptionLists, setExceptionLists] = useState([]); const [paginationInfo, setPagination] = useState(pagination); @@ -51,8 +53,9 @@ export const useExceptionLists = ({ const namespaceTypesAsString = useMemo(() => namespaceTypes.join(','), [namespaceTypes]); const filters = useMemo( - (): string => getFilters(filterOptions, namespaceTypes, showTrustedApps), - [namespaceTypes, filterOptions, showTrustedApps] + (): string => + getFilters({ filters: filterOptions, namespaceTypes, showTrustedApps, showEventFilters }), + [namespaceTypes, filterOptions, showTrustedApps, showEventFilters] ); useEffect(() => { diff --git a/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts b/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts new file mode 100644 index 00000000000000..934a9cbff56a64 --- /dev/null +++ b/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.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 { getEventFiltersFilter } from '.'; + +describe('getEventFiltersFilter', () => { + test('it returns filter to search for "exception-list" namespace trusted apps', () => { + const filter = getEventFiltersFilter(true, ['exception-list']); + + expect(filter).toEqual('(exception-list.attributes.list_id: endpoint_event_filters*)'); + }); + + test('it returns filter to search for "exception-list" and "agnostic" namespace trusted apps', () => { + const filter = getEventFiltersFilter(true, ['exception-list', 'exception-list-agnostic']); + + expect(filter).toEqual( + '(exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it returns filter to exclude "exception-list" namespace trusted apps', () => { + const filter = getEventFiltersFilter(false, ['exception-list']); + + expect(filter).toEqual('(not exception-list.attributes.list_id: endpoint_event_filters*)'); + }); + + test('it returns filter to exclude "exception-list" and "agnostic" namespace trusted apps', () => { + const filter = getEventFiltersFilter(false, ['exception-list', 'exception-list-agnostic']); + + expect(filter).toEqual( + '(not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); +}); diff --git a/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts b/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts new file mode 100644 index 00000000000000..7e55073228fcab --- /dev/null +++ b/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright 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 { ENDPOINT_EVENT_FILTERS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import { SavedObjectType } from '../types'; + +export const getEventFiltersFilter = ( + showEventFilter: boolean, + namespaceTypes: SavedObjectType[] +): string => { + if (showEventFilter) { + const filters = namespaceTypes.map((namespace) => { + return `${namespace}.attributes.list_id: ${ENDPOINT_EVENT_FILTERS_LIST_ID}*`; + }); + return `(${filters.join(' OR ')})`; + } else { + const filters = namespaceTypes.map((namespace) => { + return `not ${namespace}.attributes.list_id: ${ENDPOINT_EVENT_FILTERS_LIST_ID}*`; + }); + return `(${filters.join(' AND ')})`; + } +}; diff --git a/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts b/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts index 327a29dc1b987a..bfaad52ee81472 100644 --- a/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts +++ b/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts @@ -11,106 +11,318 @@ import { getFilters } from '.'; describe('getFilters', () => { describe('single', () => { test('it properly formats when no filters passed and "showTrustedApps" is false', () => { - const filter = getFilters({}, ['single'], false); + const filter = getFilters({ + filters: {}, + namespaceTypes: ['single'], + showTrustedApps: false, + showEventFilters: false, + }); - expect(filter).toEqual('(not exception-list.attributes.list_id: endpoint_trusted_apps*)'); + expect(filter).toEqual( + '(not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*)' + ); }); test('it properly formats when no filters passed and "showTrustedApps" is true', () => { - const filter = getFilters({}, ['single'], true); + const filter = getFilters({ + filters: {}, + namespaceTypes: ['single'], + showTrustedApps: true, + showEventFilters: false, + }); - expect(filter).toEqual('(exception-list.attributes.list_id: endpoint_trusted_apps*)'); + expect(filter).toEqual( + '(exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*)' + ); }); test('it properly formats when filters passed and "showTrustedApps" is false', () => { - const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['single'], false); + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['single'], + showTrustedApps: false, + showEventFilters: false, + }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*)' ); }); test('it if filters passed and "showTrustedApps" is true', () => { - const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['single'], true); + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['single'], + showTrustedApps: true, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when no filters passed and "showEventFilters" is false', () => { + const filter = getFilters({ + filters: {}, + namespaceTypes: ['single'], + showTrustedApps: false, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when no filters passed and "showEventFilters" is true', () => { + const filter = getFilters({ + filters: {}, + namespaceTypes: ['single'], + showTrustedApps: false, + showEventFilters: true, + }); + + expect(filter).toEqual( + '(not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when filters passed and "showEventFilters" is false', () => { + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['single'], + showTrustedApps: false, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it if filters passed and "showEventFilters" is true', () => { + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['single'], + showTrustedApps: false, + showEventFilters: true, + }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters*)' ); }); }); describe('agnostic', () => { test('it properly formats when no filters passed and "showTrustedApps" is false', () => { - const filter = getFilters({}, ['agnostic'], false); + const filter = getFilters({ + filters: {}, + namespaceTypes: ['agnostic'], + showTrustedApps: false, + showEventFilters: false, + }); expect(filter).toEqual( - '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); }); test('it properly formats when no filters passed and "showTrustedApps" is true', () => { - const filter = getFilters({}, ['agnostic'], true); + const filter = getFilters({ + filters: {}, + namespaceTypes: ['agnostic'], + showTrustedApps: true, + showEventFilters: false, + }); expect(filter).toEqual( - '(exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); }); test('it properly formats when filters passed and "showTrustedApps" is false', () => { - const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['agnostic'], false); + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['agnostic'], + showTrustedApps: false, + showEventFilters: false, + }); expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); }); test('it if filters passed and "showTrustedApps" is true', () => { - const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['agnostic'], true); + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['agnostic'], + showTrustedApps: true, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when no filters passed and "showEventFilters" is false', () => { + const filter = getFilters({ + filters: {}, + namespaceTypes: ['agnostic'], + showTrustedApps: false, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when no filters passed and "showEventFilters" is true', () => { + const filter = getFilters({ + filters: {}, + namespaceTypes: ['agnostic'], + showTrustedApps: false, + showEventFilters: true, + }); + + expect(filter).toEqual( + '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when filters passed and "showEventFilters" is false', () => { + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['agnostic'], + showTrustedApps: false, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it if filters passed and "showEventFilters" is true', () => { + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['agnostic'], + showTrustedApps: false, + showEventFilters: true, + }); expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); }); }); describe('single, agnostic', () => { test('it properly formats when no filters passed and "showTrustedApps" is false', () => { - const filter = getFilters({}, ['single', 'agnostic'], false); + const filter = getFilters({ + filters: {}, + namespaceTypes: ['single', 'agnostic'], + showTrustedApps: false, + showEventFilters: false, + }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); }); test('it properly formats when no filters passed and "showTrustedApps" is true', () => { - const filter = getFilters({}, ['single', 'agnostic'], true); + const filter = getFilters({ + filters: {}, + namespaceTypes: ['single', 'agnostic'], + showTrustedApps: true, + showEventFilters: false, + }); expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); }); test('it properly formats when filters passed and "showTrustedApps" is false', () => { - const filter = getFilters( - { created_by: 'moi', name: 'Sample' }, - ['single', 'agnostic'], - false - ); + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['single', 'agnostic'], + showTrustedApps: false, + showEventFilters: false, + }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); }); test('it properly formats when filters passed and "showTrustedApps" is true', () => { - const filter = getFilters( - { created_by: 'moi', name: 'Sample' }, - ['single', 'agnostic'], - true + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['single', 'agnostic'], + showTrustedApps: true, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when no filters passed and "showEventFilters" is false', () => { + const filter = getFilters({ + filters: {}, + namespaceTypes: ['single', 'agnostic'], + showTrustedApps: false, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when no filters passed and "showEventFilters" is true', () => { + const filter = getFilters({ + filters: {}, + namespaceTypes: ['single', 'agnostic'], + showTrustedApps: false, + showEventFilters: true, + }); + + expect(filter).toEqual( + '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when filters passed and "showEventFilters" is false', () => { + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['single', 'agnostic'], + showTrustedApps: false, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); + }); + + test('it properly formats when filters passed and "showEventFilters" is true', () => { + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['single', 'agnostic'], + showTrustedApps: false, + showEventFilters: true, + }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); }); }); diff --git a/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts b/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts index c9dd6ccae484c2..238ae5541343cf 100644 --- a/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts @@ -10,14 +10,26 @@ import { ExceptionListFilter, NamespaceType } from '@kbn/securitysolution-io-ts- import { getGeneralFilters } from '../get_general_filters'; import { getSavedObjectTypes } from '../get_saved_object_types'; import { getTrustedAppsFilter } from '../get_trusted_apps_filter'; +import { getEventFiltersFilter } from '../get_event_filters_filter'; -export const getFilters = ( - filters: ExceptionListFilter, - namespaceTypes: NamespaceType[], - showTrustedApps: boolean -): string => { +export interface GetFiltersParams { + filters: ExceptionListFilter; + namespaceTypes: NamespaceType[]; + showTrustedApps: boolean; + showEventFilters: boolean; +} + +export const getFilters = ({ + filters, + namespaceTypes, + showTrustedApps, + showEventFilters, +}: GetFiltersParams): string => { const namespaces = getSavedObjectTypes({ namespaceType: namespaceTypes }); const generalFilters = getGeneralFilters(filters, namespaces); const trustedAppsFilter = getTrustedAppsFilter(showTrustedApps, namespaces); - return [generalFilters, trustedAppsFilter].filter((filter) => filter.trim() !== '').join(' AND '); + const eventFiltersFilter = getEventFiltersFilter(showEventFilters, namespaces); + return [generalFilters, trustedAppsFilter, eventFiltersFilter] + .filter((filter) => filter.trim() !== '') + .join(' AND '); }; diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts index bdcb4224eed9c5..4987de321c556c 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts @@ -48,6 +48,7 @@ describe('useExceptionLists', () => { perPage: 20, total: 0, }, + showEventFilters: false, showTrustedApps: false, }) ); @@ -83,6 +84,7 @@ describe('useExceptionLists', () => { perPage: 20, total: 0, }, + showEventFilters: false, showTrustedApps: false, }) ); @@ -122,6 +124,7 @@ describe('useExceptionLists', () => { perPage: 20, total: 0, }, + showEventFilters: false, showTrustedApps: true, }) ); @@ -132,7 +135,7 @@ describe('useExceptionLists', () => { expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ filters: - '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)', + '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)', http: mockKibanaHttpService, namespaceTypes: 'single,agnostic', pagination: { page: 1, perPage: 20 }, @@ -157,6 +160,7 @@ describe('useExceptionLists', () => { perPage: 20, total: 0, }, + showEventFilters: false, showTrustedApps: false, }) ); @@ -167,7 +171,79 @@ describe('useExceptionLists', () => { expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ filters: - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)', + '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)', + http: mockKibanaHttpService, + namespaceTypes: 'single,agnostic', + pagination: { page: 1, perPage: 20 }, + signal: new AbortController().signal, + }); + }); + }); + + test('fetches event filters lists if "showEventFilters" is true', async () => { + const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); + + await act(async () => { + const { waitForNextUpdate } = renderHook(() => + useExceptionLists({ + errorMessage: 'Uh oh', + filterOptions: {}, + http: mockKibanaHttpService, + namespaceTypes: ['single', 'agnostic'], + notifications: mockKibanaNotificationsService, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showEventFilters: true, + showTrustedApps: false, + }) + ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ + filters: + '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*)', + http: mockKibanaHttpService, + namespaceTypes: 'single,agnostic', + pagination: { page: 1, perPage: 20 }, + signal: new AbortController().signal, + }); + }); + }); + + test('does not fetch event filters lists if "showEventFilters" is false', async () => { + const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); + + await act(async () => { + const { waitForNextUpdate } = renderHook(() => + useExceptionLists({ + errorMessage: 'Uh oh', + filterOptions: {}, + http: mockKibanaHttpService, + namespaceTypes: ['single', 'agnostic'], + notifications: mockKibanaNotificationsService, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showEventFilters: false, + showTrustedApps: false, + }) + ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ + filters: + '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)', http: mockKibanaHttpService, namespaceTypes: 'single,agnostic', pagination: { page: 1, perPage: 20 }, @@ -195,6 +271,7 @@ describe('useExceptionLists', () => { perPage: 20, total: 0, }, + showEventFilters: false, showTrustedApps: false, }) ); @@ -205,7 +282,7 @@ describe('useExceptionLists', () => { expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ filters: - '(exception-list.attributes.created_by:Moi OR exception-list-agnostic.attributes.created_by:Moi) AND (exception-list.attributes.name.text:Sample Endpoint OR exception-list-agnostic.attributes.name.text:Sample Endpoint) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)', + '(exception-list.attributes.created_by:Moi OR exception-list-agnostic.attributes.created_by:Moi) AND (exception-list.attributes.name.text:Sample Endpoint OR exception-list-agnostic.attributes.name.text:Sample Endpoint) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)', http: mockKibanaHttpService, namespaceTypes: 'single,agnostic', pagination: { page: 1, perPage: 20 }, @@ -228,6 +305,7 @@ describe('useExceptionLists', () => { namespaceTypes, notifications, pagination, + showEventFilters, showTrustedApps, }) => useExceptionLists({ @@ -237,6 +315,7 @@ describe('useExceptionLists', () => { namespaceTypes, notifications, pagination, + showEventFilters, showTrustedApps, }), { @@ -251,6 +330,7 @@ describe('useExceptionLists', () => { perPage: 20, total: 0, }, + showEventFilters: false, showTrustedApps: false, }, } @@ -271,6 +351,7 @@ describe('useExceptionLists', () => { perPage: 20, total: 0, }, + showEventFilters: false, showTrustedApps: false, }); // NOTE: Only need one call here because hook already initilaized @@ -298,6 +379,7 @@ describe('useExceptionLists', () => { perPage: 20, total: 0, }, + showEventFilters: false, showTrustedApps: false, }) ); @@ -336,6 +418,7 @@ describe('useExceptionLists', () => { perPage: 20, total: 0, }, + showEventFilters: false, showTrustedApps: false, }) ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx index 35404f4486bc3e..f38bde4839f18b 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx @@ -77,6 +77,7 @@ export const ExceptionListsTable = React.memo( namespaceTypes: ['single', 'agnostic'], notifications, showTrustedApps: false, + showEventFilters: false, }); const [loadingTableInfo, exceptionListsWithRuleRefs, exceptionsListsRef] = useAllExceptionLists( { From bb4e0cc1fc2cbefa4570561b7eb23785981b382b Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Wed, 23 Jun 2021 07:21:26 -0700 Subject: [PATCH 20/61] Adds a versioned class name to a root DOM element (#102443) --- src/core/public/chrome/chrome_service.test.ts | 54 ++++++++++++++++++- src/core/public/chrome/chrome_service.tsx | 13 +++++ src/core/public/core_system.test.ts | 7 +-- src/core/public/core_system.ts | 8 +-- src/core/public/public.api.md | 2 +- 5 files changed, 75 insertions(+), 9 deletions(-) diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index 0264c8a1acf754..92f5a854f6b00f 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -53,8 +53,21 @@ function defaultStartDeps(availableApps?: App[]) { return deps; } +function defaultStartTestOptions({ + browserSupportsCsp = true, + kibanaVersion = 'version', +}: { + browserSupportsCsp?: boolean; + kibanaVersion?: string; +}): any { + return { + browserSupportsCsp, + kibanaVersion, + }; +} + async function start({ - options = { browserSupportsCsp: true }, + options = defaultStartTestOptions({}), cspConfigMock = { warnLegacyBrowsers: true }, startDeps = defaultStartDeps(), }: { options?: any; cspConfigMock?: any; startDeps?: ReturnType } = {}) { @@ -82,7 +95,9 @@ afterAll(() => { describe('start', () => { it('adds legacy browser warning if browserSupportsCsp is disabled and warnLegacyBrowsers is enabled', async () => { - const { startDeps } = await start({ options: { browserSupportsCsp: false } }); + const { startDeps } = await start({ + options: { browserSupportsCsp: false, kibanaVersion: '7.0.0' }, + }); expect(startDeps.notifications.toasts.addWarning.mock.calls).toMatchInlineSnapshot(` Array [ @@ -95,6 +110,41 @@ describe('start', () => { `); }); + it('adds the kibana versioned class to the document body', async () => { + const { chrome, service } = await start({ + options: { browserSupportsCsp: false, kibanaVersion: '1.2.3' }, + }); + const promise = chrome.getBodyClasses$().pipe(toArray()).toPromise(); + service.stop(); + await expect(promise).resolves.toMatchInlineSnapshot(` + Array [ + Array [ + "kbnBody", + "kbnBody--noHeaderBanner", + "kbnBody--chromeHidden", + "kbnVersion-1-2-3", + ], + ] + `); + }); + it('strips off "snapshot" from the kibana version if present', async () => { + const { chrome, service } = await start({ + options: { browserSupportsCsp: false, kibanaVersion: '8.0.0-SnAPshot' }, + }); + const promise = chrome.getBodyClasses$().pipe(toArray()).toPromise(); + service.stop(); + await expect(promise).resolves.toMatchInlineSnapshot(` + Array [ + Array [ + "kbnBody", + "kbnBody--noHeaderBanner", + "kbnBody--chromeHidden", + "kbnVersion-8-0-0", + ], + ] + `); + }); + it('does not add legacy browser warning if browser supports CSP', async () => { const { startDeps } = await start(); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 5ed447edde75a0..f1381c52ce7793 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -37,9 +37,11 @@ import { export type { ChromeNavControls, ChromeRecentlyAccessed, ChromeDocTitle }; const IS_LOCKED_KEY = 'core.chrome.isLocked'; +const SNAPSHOT_REGEX = /-snapshot/i; interface ConstructorParams { browserSupportsCsp: boolean; + kibanaVersion: string; } interface StartDeps { @@ -116,6 +118,16 @@ export class ChromeService { const helpSupportUrl$ = new BehaviorSubject(KIBANA_ASK_ELASTIC_LINK); const isNavDrawerLocked$ = new BehaviorSubject(localStorage.getItem(IS_LOCKED_KEY) === 'true'); + const getKbnVersionClass = () => { + // we assume that the version is valid and has the form 'X.X.X' + // strip out `SNAPSHOT` and reformat to 'X-X-X' + const formattedVersionClass = this.params.kibanaVersion + .replace(SNAPSHOT_REGEX, '') + .split('.') + .join('-'); + return `kbnVersion-${formattedVersionClass}`; + }; + const headerBanner$ = new BehaviorSubject(undefined); const bodyClasses$ = combineLatest([headerBanner$, this.isVisible$!]).pipe( map(([headerBanner, isVisible]) => { @@ -123,6 +135,7 @@ export class ChromeService { 'kbnBody', headerBanner ? 'kbnBody--hasHeaderBanner' : 'kbnBody--noHeaderBanner', isVisible ? 'kbnBody--chromeVisible' : 'kbnBody--chromeHidden', + getKbnVersionClass(), ]; }) ); diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index 1c4e78f0a5c2ef..8ead0f50785bdc 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -46,6 +46,7 @@ const defaultCoreSystemParams = { csp: { warnLegacyBrowsers: true, }, + version: 'version', } as any, }; @@ -91,12 +92,12 @@ describe('constructor', () => { }); }); - it('passes browserSupportsCsp to ChromeService', () => { + it('passes browserSupportsCsp and coreContext to ChromeService', () => { createCoreSystem(); - expect(ChromeServiceConstructor).toHaveBeenCalledTimes(1); expect(ChromeServiceConstructor).toHaveBeenCalledWith({ - browserSupportsCsp: expect.any(Boolean), + browserSupportsCsp: true, + kibanaVersion: 'version', }); }); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index f0ea1e62fc33f8..9a28bf45df9273 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -5,7 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - import { CoreId } from '../server'; import { PackageInfo, EnvironmentMode } from '../server/types'; import { CoreSetup, CoreStart } from '.'; @@ -98,6 +97,7 @@ export class CoreSystem { this.injectedMetadata = new InjectedMetadataService({ injectedMetadata, }); + this.coreContext = { coreId: Symbol('core'), env: injectedMetadata.env }; this.fatalErrors = new FatalErrorsService(rootDomElement, () => { // Stop Core before rendering any fatal errors into the DOM @@ -109,14 +109,16 @@ export class CoreSystem { this.savedObjects = new SavedObjectsService(); this.uiSettings = new UiSettingsService(); this.overlay = new OverlayService(); - this.chrome = new ChromeService({ browserSupportsCsp }); + this.chrome = new ChromeService({ + browserSupportsCsp, + kibanaVersion: injectedMetadata.version, + }); this.docLinks = new DocLinksService(); this.rendering = new RenderingService(); this.application = new ApplicationService(); this.integrations = new IntegrationsService(); this.deprecations = new DeprecationsService(); - this.coreContext = { coreId: Symbol('core'), env: injectedMetadata.env }; this.plugins = new PluginsService(this.coreContext, injectedMetadata.uiPlugins); this.coreApp = new CoreApp(this.coreContext); } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 31e85341fb5197..ca95b253f9cdbb 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1632,6 +1632,6 @@ export interface UserProvidedValues { // Warnings were encountered during analysis: // -// src/core/public/core_system.ts:166:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts +// src/core/public/core_system.ts:168:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts ``` From dd907e5487e94c0f36f2560880cf8f0d8274992e Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 23 Jun 2021 08:36:46 -0600 Subject: [PATCH 21/61] [maps] fix user has to click back button twice to navigate back to dashboard from create maps screen (#103002) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/maps/public/routes/map_page/url_state/app_sync.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/maps/public/routes/map_page/url_state/app_sync.ts b/x-pack/plugins/maps/public/routes/map_page/url_state/app_sync.ts index 268e5fa600b464..f05836dff2bd98 100644 --- a/x-pack/plugins/maps/public/routes/map_page/url_state/app_sync.ts +++ b/x-pack/plugins/maps/public/routes/map_page/url_state/app_sync.ts @@ -60,7 +60,9 @@ export function startAppStateSyncing(appStateManager: AppStateManager) { stateContainer.set(initialAppState); // set current url to whatever is in app state container - kbnUrlStateStorage.set('_a', initialAppState); + kbnUrlStateStorage.set('_a', initialAppState, { + replace: true, + }); // finally start syncing state containers with url startSyncingAppStateWithUrl(); From b4b17cfdec89c408b8c373be026dd67d543752d6 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 23 Jun 2021 08:37:15 -0600 Subject: [PATCH 22/61] [Maps] show radius when drawing distance filter (#102808) * [Maps] show radius when drawing distance filter * show more precision when radius is between 10km and 1km * move radius display from line to left of cursor Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../mb_map/draw_control/draw_circle.ts | 66 ++++++++++++++++--- .../mb_map/draw_control/draw_control.tsx | 24 ++++++- 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_circle.ts b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_circle.ts index f0df797582bef7..998329a78bfbbd 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_circle.ts +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_circle.ts @@ -11,7 +11,11 @@ import turfDistance from '@turf/distance'; // @ts-expect-error import turfCircle from '@turf/circle'; -import { Position } from 'geojson'; +import { Feature, GeoJSON, Position } from 'geojson'; + +const DRAW_CIRCLE_RADIUS = 'draw-circle-radius'; + +export const DRAW_CIRCLE_RADIUS_MB_FILTER = ['==', 'meta', DRAW_CIRCLE_RADIUS]; export interface DrawCircleProperties { center: Position; @@ -22,10 +26,12 @@ type DrawCircleState = { circle: { properties: Omit & { center: Position | null; + edge: Position | null; + radiusKm: number; }; id: string | number; incomingCoords: (coords: unknown[]) => void; - toGeoJSON: () => unknown; + toGeoJSON: () => GeoJSON; }; }; @@ -43,6 +49,7 @@ export const DrawCircle = { type: 'Feature', properties: { center: null, + edge: null, radiusKm: 0, }, geometry: { @@ -96,6 +103,7 @@ export const DrawCircle = { } const mouseLocation = [e.lngLat.lng, e.lngLat.lat]; + state.circle.properties.edge = mouseLocation; state.circle.properties.radiusKm = turfDistance(state.circle.properties.center, mouseLocation); const newCircleFeature = turfCircle( state.circle.properties.center, @@ -124,15 +132,53 @@ export const DrawCircle = { this.changeMode('simple_select', {}, { silent: true }); } }, - toDisplayFeatures( - state: DrawCircleState, - geojson: { properties: { active: string } }, - display: (geojson: unknown) => unknown - ) { - if (state.circle.properties.center) { - geojson.properties.active = 'true'; - return display(geojson); + toDisplayFeatures(state: DrawCircleState, geojson: Feature, display: (geojson: Feature) => void) { + if (!state.circle.properties.center || !state.circle.properties.edge) { + return null; + } + + geojson.properties!.active = 'true'; + + let radiusLabel = ''; + if (state.circle.properties.radiusKm <= 1) { + radiusLabel = `${Math.round(state.circle.properties.radiusKm * 1000)} m`; + } else if (state.circle.properties.radiusKm <= 10) { + radiusLabel = `${state.circle.properties.radiusKm.toFixed(1)} km`; + } else { + radiusLabel = `${Math.round(state.circle.properties.radiusKm)} km`; } + + // display radius label, requires custom 'symbol' style with DRAW_CIRCLE_RADIUS_MB_FILTER filter + display({ + type: 'Feature', + properties: { + meta: DRAW_CIRCLE_RADIUS, + parent: state.circle.id, + radiusLabel, + active: 'false', + }, + geometry: { + type: 'Point', + coordinates: state.circle.properties.edge, + }, + }); + + // display line from center vertex to edge + display({ + type: 'Feature', + properties: { + meta: 'draw-circle-radius-line', + parent: state.circle.id, + active: 'true', + }, + geometry: { + type: 'LineString', + coordinates: [state.circle.properties.center, state.circle.properties.edge], + }, + }); + + // display circle + display(geojson); }, onTrash(state: DrawCircleState) { // @ts-ignore diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx index 879bd85dd6019d..5d9cb59bbe522f 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx @@ -14,9 +14,11 @@ import DrawRectangle from 'mapbox-gl-draw-rectangle-mode'; import type { Map as MbMap } from '@kbn/mapbox-gl'; import { Feature } from 'geojson'; import { DRAW_SHAPE } from '../../../../common/constants'; -import { DrawCircle } from './draw_circle'; +import { DrawCircle, DRAW_CIRCLE_RADIUS_MB_FILTER } from './draw_circle'; import { DrawTooltip } from './draw_tooltip'; +const GL_DRAW_RADIUS_LABEL_LAYER_ID = 'gl-draw-radius-label'; + const mbModeEquivalencies = new Map([ ['simple_select', DRAW_SHAPE.SIMPLE_SELECT], ['draw_rectangle', DRAW_SHAPE.BOUNDS], @@ -94,6 +96,7 @@ export class DrawControl extends Component { this.props.mbMap.getCanvas().style.cursor = ''; this.props.mbMap.off('draw.modechange', this._onModeChange); this.props.mbMap.off('draw.create', this._onDraw); + this.props.mbMap.removeLayer(GL_DRAW_RADIUS_LABEL_LAYER_ID); this.props.mbMap.removeControl(this._mbDrawControl); this._mbDrawControlAdded = false; } @@ -105,6 +108,25 @@ export class DrawControl extends Component { if (!this._mbDrawControlAdded) { this.props.mbMap.addControl(this._mbDrawControl); + this.props.mbMap.addLayer({ + id: GL_DRAW_RADIUS_LABEL_LAYER_ID, + type: 'symbol', + source: 'mapbox-gl-draw-hot', + filter: DRAW_CIRCLE_RADIUS_MB_FILTER, + layout: { + 'text-anchor': 'right', + 'text-field': '{radiusLabel}', + 'text-size': 16, + 'text-offset': [-1, 0], + 'text-ignore-placement': true, + 'text-allow-overlap': true, + }, + paint: { + 'text-color': '#fbb03b', + 'text-halo-color': 'rgba(255, 255, 255, 1)', + 'text-halo-width': 2, + }, + }); this._mbDrawControlAdded = true; this.props.mbMap.getCanvas().style.cursor = 'crosshair'; this.props.mbMap.on('draw.modechange', this._onModeChange); From 4fa939d9c9d2d4b9f30237d6b6b820523694ca9f Mon Sep 17 00:00:00 2001 From: Dmitry Tomashevich <39378793+Dmitriynj@users.noreply.github.com> Date: Wed, 23 Jun 2021 17:40:58 +0300 Subject: [PATCH 23/61] [Discover] Improve flaky test - doc navigation (#102859) * [Discover] test flakiness * [Discover] wait for doc loaded * [Discover] update related test * [Discover] clean statement --- test/functional/apps/discover/_data_grid_doc_navigation.ts | 6 ++++-- test/functional/apps/discover/_doc_navigation.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/test/functional/apps/discover/_data_grid_doc_navigation.ts b/test/functional/apps/discover/_data_grid_doc_navigation.ts index e3e8a20b693f85..cf5532aa6d7625 100644 --- a/test/functional/apps/discover/_data_grid_doc_navigation.ts +++ b/test/functional/apps/discover/_data_grid_doc_navigation.ts @@ -41,8 +41,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await rowActions[0].click(); }); - const hasDocHit = await testSubjects.exists('doc-hit'); - expect(hasDocHit).to.be(true); + await retry.waitFor('hit loaded', async () => { + const hasDocHit = await testSubjects.exists('doc-hit'); + return !!hasDocHit; + }); }); // no longer relevant as null field won't be returned in the Fields API response diff --git a/test/functional/apps/discover/_doc_navigation.ts b/test/functional/apps/discover/_doc_navigation.ts index 771dac4d40a64f..8d156cb305586b 100644 --- a/test/functional/apps/discover/_doc_navigation.ts +++ b/test/functional/apps/discover/_doc_navigation.ts @@ -51,8 +51,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await rowActions[1].click(); }); - const hasDocHit = await testSubjects.exists('doc-hit'); - expect(hasDocHit).to.be(true); + await retry.waitFor('hit loaded', async () => { + const hasDocHit = await testSubjects.exists('doc-hit'); + return !!hasDocHit; + }); }); // no longer relevant as null field won't be returned in the Fields API response From 91295fddd7445e009b4799979054c6fd17d632d2 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 23 Jun 2021 08:45:02 -0600 Subject: [PATCH 24/61] [Maps] remove undefined from map embeddable by_value URL (#102949) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/maps/common/constants.ts | 11 +++++------ .../plugins/maps/public/embeddable/map_embeddable.tsx | 10 +++++----- .../maps/public/routes/map_page/map_app/map_app.tsx | 4 ++-- x-pack/plugins/maps/server/plugin.ts | 8 ++++---- x-pack/plugins/maps/server/saved_objects/map.ts | 4 ++-- 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index 37a8e8063c4ed1..fa065e701184e9 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -58,15 +58,14 @@ export const KBN_IS_CENTROID_FEATURE = '__kbn_is_centroid_feature__'; export const MVT_TOKEN_PARAM_NAME = 'token'; -const MAP_BASE_URL = `/${MAPS_APP_PATH}/${MAP_PATH}`; export function getNewMapPath() { - return MAP_BASE_URL; + return `/${MAPS_APP_PATH}/${MAP_PATH}`; } -export function getExistingMapPath(id: string) { - return `${MAP_BASE_URL}/${id}`; +export function getFullPath(id: string | undefined) { + return `/${MAPS_APP_PATH}${getEditPath(id)}`; } -export function getEditPath(id: string) { - return `/${MAP_PATH}/${id}`; +export function getEditPath(id: string | undefined) { + return id ? `/${MAP_PATH}/${id}` : `/${MAP_PATH}`; } export enum LAYER_TYPE { diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index 5a477754683e68..509cece671dd6d 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -54,9 +54,9 @@ import { } from '../selectors/map_selectors'; import { APP_ID, - getExistingMapPath, + getEditPath, + getFullPath, MAP_SAVED_OBJECT_TYPE, - MAP_PATH, RawValue, } from '../../common/constants'; import { RenderToolTipContent } from '../classes/tooltips/tooltip_property'; @@ -180,13 +180,13 @@ export class MapEmbeddable : ''; const input = this.getInput(); const title = input.hidePanelTitles ? '' : input.title || savedMapTitle; - const savedObjectId = (input as MapByReferenceInput).savedObjectId; + const savedObjectId = 'savedObjectId' in input ? input.savedObjectId : undefined; this.updateOutput({ ...this.getOutput(), defaultTitle: savedMapTitle, title, - editPath: `/${MAP_PATH}/${savedObjectId}`, - editUrl: getHttp().basePath.prepend(getExistingMapPath(savedObjectId)), + editPath: getEditPath(savedObjectId), + editUrl: getHttp().basePath.prepend(getFullPath(savedObjectId)), indexPatterns: await this._getIndexPatterns(), }); } diff --git a/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx b/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx index 0dfff5a2c221ed..92459ed28ab91e 100644 --- a/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx +++ b/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx @@ -44,7 +44,7 @@ import { getTopNavConfig } from '../top_nav_config'; import { MapQuery } from '../../../../common/descriptor_types'; import { goToSpecifiedPath } from '../../../render_app'; import { MapSavedObjectAttributes } from '../../../../common/map_saved_object_type'; -import { getExistingMapPath, APP_ID } from '../../../../common/constants'; +import { getFullPath, APP_ID } from '../../../../common/constants'; import { getInitialQuery, getInitialRefreshConfig, @@ -356,7 +356,7 @@ export class MapApp extends React.Component { const savedObjectId = this.props.savedMap.getSavedObjectId(); if (savedObjectId) { getCoreChrome().recentlyAccessed.add( - getExistingMapPath(savedObjectId), + getFullPath(savedObjectId), this.props.savedMap.getTitle(), savedObjectId ); diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index c7532979320378..b8676559a4e2b1 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -22,7 +22,7 @@ import { getFlightsSavedObjects } from './sample_data/flights_saved_objects.js'; // @ts-ignore import { getWebLogsSavedObjects } from './sample_data/web_logs_saved_objects.js'; import { registerMapsUsageCollector } from './maps_telemetry/collectors/register'; -import { APP_ID, APP_ICON, MAP_SAVED_OBJECT_TYPE, getExistingMapPath } from '../common/constants'; +import { APP_ID, APP_ICON, MAP_SAVED_OBJECT_TYPE, getFullPath } from '../common/constants'; import { mapSavedObjects, mapsTelemetrySavedObjects } from './saved_objects'; import { MapsXPackConfig } from '../config'; // @ts-ignore @@ -77,7 +77,7 @@ export class MapsPlugin implements Plugin { home.sampleData.addAppLinksToSampleDataset('ecommerce', [ { - path: getExistingMapPath('2c9c1f60-1909-11e9-919b-ffe5949a18d2'), + path: getFullPath('2c9c1f60-1909-11e9-919b-ffe5949a18d2'), label: sampleDataLinkLabel, icon: APP_ICON, }, @@ -99,7 +99,7 @@ export class MapsPlugin implements Plugin { home.sampleData.addAppLinksToSampleDataset('flights', [ { - path: getExistingMapPath('5dd88580-1906-11e9-919b-ffe5949a18d2'), + path: getFullPath('5dd88580-1906-11e9-919b-ffe5949a18d2'), label: sampleDataLinkLabel, icon: APP_ICON, }, @@ -120,7 +120,7 @@ export class MapsPlugin implements Plugin { home.sampleData.addSavedObjectsToSampleDataset('logs', getWebLogsSavedObjects()); home.sampleData.addAppLinksToSampleDataset('logs', [ { - path: getExistingMapPath('de71f4f0-1902-11e9-919b-ffe5949a18d2'), + path: getFullPath('de71f4f0-1902-11e9-919b-ffe5949a18d2'), label: sampleDataLinkLabel, icon: APP_ICON, }, diff --git a/x-pack/plugins/maps/server/saved_objects/map.ts b/x-pack/plugins/maps/server/saved_objects/map.ts index 78f70e27b2b7bf..24effd651a31b0 100644 --- a/x-pack/plugins/maps/server/saved_objects/map.ts +++ b/x-pack/plugins/maps/server/saved_objects/map.ts @@ -6,7 +6,7 @@ */ import { SavedObjectsType } from 'src/core/server'; -import { APP_ICON, getExistingMapPath } from '../../common/constants'; +import { APP_ICON, getFullPath } from '../../common/constants'; // @ts-ignore import { savedObjectMigrations } from './saved_object_migrations'; @@ -34,7 +34,7 @@ export const mapSavedObjects: SavedObjectsType = { }, getInAppUrl(obj) { return { - path: getExistingMapPath(obj.id), + path: getFullPath(obj.id), uiCapabilitiesPath: 'maps.show', }; }, From a96eaa480f697eb36fe2f56ae6ec268930484523 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Wed, 23 Jun 2021 17:52:15 +0300 Subject: [PATCH 25/61] [Visualize] Adds an info icon tip to the update button (#101469) * [Visualize] Adds an info tooltip to the update button * Add iconTip to the visEditor update button * Move to the left and change the icon * Update test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/components/sidebar/controls.tsx | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/src/plugins/vis_default_editor/public/components/sidebar/controls.tsx b/src/plugins/vis_default_editor/public/components/sidebar/controls.tsx index a24673a4c12455..e757b5fe8f61dd 100644 --- a/src/plugins/vis_default_editor/public/components/sidebar/controls.tsx +++ b/src/plugins/vis_default_editor/public/components/sidebar/controls.tsx @@ -7,7 +7,14 @@ */ import React, { useCallback, useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiToolTip, + EuiIconTip, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import useDebounce from 'react-use/lib/useDebounce'; @@ -84,19 +91,32 @@ function DefaultEditorControls({ ) : ( - - - + + + + + + + + + + )}
From 77b5b236e505fe203fc4436dddea92494376f873 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Wed, 23 Jun 2021 16:52:49 +0200 Subject: [PATCH 26/61] [Discover] Unskip and improve empty results query functional test (#102995) --- test/functional/apps/discover/_discover.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index dce6bfba9cd99c..c68db8cbd797bd 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -181,8 +181,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/89550 - describe.skip('query #2, which has an empty time range', () => { + describe('query #2, which has an empty time range', () => { const fromTime = 'Jun 11, 1999 @ 09:22:11.000'; const toTime = 'Jun 12, 1999 @ 11:21:04.000'; @@ -193,8 +192,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should show "no results"', async () => { - const isVisible = await PageObjects.discover.hasNoResults(); - expect(isVisible).to.be(true); + await retry.waitFor('no results screen is displayed', async function () { + const isVisible = await PageObjects.discover.hasNoResults(); + return isVisible === true; + }); }); it('should suggest a new time range is picked', async () => { From 702661d34fb26e40869acbe1ca1e88a568901daf Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Wed, 23 Jun 2021 11:00:29 -0400 Subject: [PATCH 27/61] Implement new security solution wrapper (#100405) Co-authored-by: cchaos --- src/core/public/rendering/_base.scss | 1 + .../cases/public/components/panel/index.tsx | 2 +- .../security_solution/common/constants.ts | 3 +- .../detection_rules/sorting.spec.ts | 8 +- .../timelines/data_providers.spec.ts | 3 +- .../integration/timelines/pagination.spec.ts | 5 +- .../cypress/screens/timeline.ts | 2 + .../security_solution/public/app/404.tsx | 6 +- .../security_solution/public/app/app.tsx | 26 ++- .../public/app/home/global_header/index.tsx | 76 +++++++ .../public/app/home/home_navigations.tsx | 2 +- .../public/app/home/index.tsx | 71 ++---- .../template_wrapper/bottom_bar/index.tsx | 54 +++++ .../global_kql_header/index.tsx | 28 +++ .../app/home/template_wrapper/index.tsx | 96 ++++++++ .../security_solution/public/app/index.tsx | 9 +- .../security_solution/public/app/routes.tsx | 14 +- .../public/app/{home => }/translations.ts | 0 .../public/cases/components/create/index.tsx | 2 +- .../public/cases/pages/case.tsx | 6 +- .../public/cases/pages/case_details.tsx | 6 +- .../public/cases/pages/configure_cases.tsx | 6 +- .../public/cases/pages/create_case.tsx | 6 +- .../components/callouts/callout_switcher.tsx | 8 +- .../events_viewer/events_viewer.tsx | 1 + .../common/components/events_viewer/index.tsx | 4 +- .../filters_global.test.tsx.snap | 16 +- .../filters_global/filters_global.tsx | 23 +- .../components/header_global/index.test.tsx | 51 ----- .../common/components/header_global/index.tsx | 155 ------------- .../components/header_global/translations.ts | 19 -- .../__snapshots__/index.test.tsx.snap | 28 +-- .../components/header_page/index.test.tsx | 28 +-- .../common/components/header_page/index.tsx | 52 ++--- .../__snapshots__/index.test.tsx.snap | 2 + .../components/item_details_card/index.tsx | 2 +- .../components/ml_popover/ml_popover.tsx | 26 ++- .../components/navigation/index.test.tsx | 10 +- .../common/components/navigation/index.tsx | 166 ++++++++------ .../navigation/tab_navigation/types.ts | 8 +- .../common/components/navigation/types.ts | 27 +-- .../index.test.tsx | 214 ++++++++++++++++++ .../index.tsx | 90 ++++++++ .../use_security_solution_navigation/types.ts | 15 ++ .../use_navigation_items.tsx | 66 ++++++ .../use_primary_navigation.tsx | 68 ++++++ .../public/common/components/page/index.tsx | 121 +--------- .../__snapshots__/index.test.tsx.snap | 9 + .../index.test.tsx | 10 +- .../{wrapper_page => page_wrapper}/index.tsx | 33 +-- .../public/common/components/panel/index.tsx | 2 +- .../common/components/stat_items/index.tsx | 2 +- .../url_state/initialize_redux_by_url.tsx | 1 - .../__snapshots__/index.test.tsx.snap | 9 - .../common/hooks/use_global_header_portal.tsx | 6 +- .../alerts_histogram_panel/index.tsx | 2 +- .../components/alerts_table/index.tsx | 2 +- .../need_admin_for_update_callout/index.tsx | 19 +- .../no_api_integration_callout/index.tsx | 17 +- .../rules/step_about_rule_details/index.tsx | 2 +- .../components/rules/step_panel/index.tsx | 2 +- .../value_lists_management_modal/modal.tsx | 2 +- .../detection_engine/detection_engine.tsx | 18 +- .../detection_engine/rules/create/index.tsx | 15 +- .../rules/details/failure_history.tsx | 4 +- .../detection_engine/rules/details/index.tsx | 10 +- .../detection_engine/rules/edit/index.tsx | 6 +- .../pages/detection_engine/rules/index.tsx | 6 +- .../public/hosts/pages/details/index.tsx | 14 +- .../public/hosts/pages/hosts.test.tsx | 4 +- .../public/hosts/pages/hosts.tsx | 17 +- .../public/management/common/breadcrumbs.ts | 2 +- .../components/administration_list_page.tsx | 12 +- .../pages/policy/view/policy_details.tsx | 12 +- .../__snapshots__/index.test.tsx.snap | 60 ++--- .../__snapshots__/index.test.tsx.snap | 2 +- .../__snapshots__/embeddable.test.tsx.snap | 1 + .../components/embeddables/embeddable.tsx | 4 +- .../public/network/pages/details/index.tsx | 10 +- .../public/network/pages/network.tsx | 15 +- .../components/overview_host/index.tsx | 2 +- .../components/overview_network/index.tsx | 2 +- .../public/overview/pages/overview.tsx | 10 +- .../security_solution/public/plugin.tsx | 2 +- .../public/resolver/view/graph_controls.tsx | 4 +- .../resolver/view/panels/event_detail.tsx | 6 +- .../resolver/view/panels/node_detail.tsx | 6 +- .../resolver/view/panels/node_events.tsx | 4 +- .../view/panels/node_events_of_type.tsx | 4 +- .../public/resolver/view/panels/node_list.tsx | 2 +- .../public/resolver/view/styles.tsx | 5 + .../components/flyout/bottom_bar/index.tsx | 51 +---- .../open_timeline/open_timeline.tsx | 2 +- .../timeline/data_providers/providers.tsx | 1 - .../timelines/components/timeline/styles.tsx | 2 +- .../public/timelines/pages/timelines_page.tsx | 12 +- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 98 files changed, 1213 insertions(+), 868 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/app/home/global_header/index.tsx create mode 100644 x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx create mode 100644 x-pack/plugins/security_solution/public/app/home/template_wrapper/global_kql_header/index.tsx create mode 100644 x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx rename x-pack/plugins/security_solution/public/app/{home => }/translations.ts (100%) delete mode 100644 x-pack/plugins/security_solution/public/common/components/header_global/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/header_global/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/header_global/translations.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/types.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/page_wrapper/__snapshots__/index.test.tsx.snap rename x-pack/plugins/security_solution/public/common/components/{wrapper_page => page_wrapper}/index.test.tsx (65%) rename x-pack/plugins/security_solution/public/common/components/{wrapper_page => page_wrapper}/index.tsx (68%) delete mode 100644 x-pack/plugins/security_solution/public/common/components/wrapper_page/__snapshots__/index.test.tsx.snap diff --git a/src/core/public/rendering/_base.scss b/src/core/public/rendering/_base.scss index 4bd6afe90d3429..92ba28ff70887e 100644 --- a/src/core/public/rendering/_base.scss +++ b/src/core/public/rendering/_base.scss @@ -38,6 +38,7 @@ @mixin kbnAffordForHeader($headerHeight) { @include euiHeaderAffordForFixed($headerHeight); + #securitySolutionStickyKQL, #app-fixed-viewport { top: $headerHeight; } diff --git a/x-pack/plugins/cases/public/components/panel/index.tsx b/x-pack/plugins/cases/public/components/panel/index.tsx index 652d22409cb0c3..802fd4c7f44a60 100644 --- a/x-pack/plugins/cases/public/components/panel/index.tsx +++ b/x-pack/plugins/cases/public/components/panel/index.tsx @@ -25,7 +25,7 @@ import { EuiPanel } from '@elastic/eui'; * Ref: https://www.styled-components.com/docs/faqs#why-am-i-getting-html-attribute-warnings * Ref: https://reactjs.org/blog/2017/09/08/dom-attributes-in-react-16.html */ -export const Panel = styled(({ loading, ...props }) => )` +export const Panel = styled(({ loading, ...props }) => )` position: relative; ${({ loading }) => loading && diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index e65ff1afcc9c33..d112630facbc6f 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -44,7 +44,8 @@ export const DEFAULT_INTERVAL_VALUE = 300000; // ms export const DEFAULT_TIMEPICKER_QUICK_RANGES = 'timepicker:quickRanges'; export const DEFAULT_TRANSFORMS = 'securitySolution:transforms'; export const SCROLLING_DISABLED_CLASS_NAME = 'scrolling-disabled'; -export const GLOBAL_HEADER_HEIGHT = 98; // px +export const GLOBAL_HEADER_HEIGHT = 96; // px +export const GLOBAL_HEADER_HEIGHT_WITH_GLOBAL_BANNER = 128; // px export const FILTERS_GLOBAL_HEIGHT = 109; // px export const FULL_SCREEN_TOGGLED_CLASS_NAME = 'fullScreenToggled'; export const NO_ALERT_INDEX = 'no-alert-index-049FC71A-4C2C-446F-9901-37XMC5024C51'; diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts index f1ee0d39f545f5..bf5c281a43e39e 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts @@ -129,7 +129,13 @@ describe('Alerts detection rules', () => { }); it('Auto refreshes rules', () => { - cy.clock(Date.now()); + /** + * Ran into the error: timer created with setInterval() but cleared with cancelAnimationFrame() + * There are no cancelAnimationFrames in the codebase that are used to clear a setInterval so + * explicitly set the below overrides. see https://docs.cypress.io/api/commands/clock#Function-names + */ + + cy.clock(Date.now(), ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date']); goToManageAlertsDetectionRules(); waitForRulesTableToBeLoaded(); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/data_providers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/data_providers.spec.ts index d42632a66eb260..a0e7e77f89b679 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/data_providers.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/data_providers.spec.ts @@ -12,6 +12,7 @@ import { TIMELINE_DATA_PROVIDERS_ACTION_MENU, IS_DRAGGING_DATA_PROVIDERS, TIMELINE_FLYOUT_HEADER, + TIMELINE_BOTTOM_BAR_CONTAINER, } from '../../screens/timeline'; import { HOSTS_NAMES_DRAGGABLE } from '../../screens/hosts/all_hosts'; @@ -46,7 +47,7 @@ describe('timeline data providers', () => { it('renders the data provider of a host dragged from the All Hosts widget on the hosts page', () => { dragAndDropFirstHostToTimeline(); openTimelineUsingToggle(); - cy.get(TIMELINE_DROPPED_DATA_PROVIDERS) + cy.get(`${TIMELINE_BOTTOM_BAR_CONTAINER} ${TIMELINE_DROPPED_DATA_PROVIDERS}`) .first() .invoke('text') .then((dataProviderText) => { diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/pagination.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/pagination.spec.ts index 568fb90568fb33..8b65f99eb04b87 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/pagination.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/pagination.spec.ts @@ -6,6 +6,7 @@ */ import { + TIMELINE_BOTTOM_BAR_CONTAINER, TIMELINE_EVENT, TIMELINE_EVENTS_COUNT_NEXT_PAGE, TIMELINE_EVENTS_COUNT_PER_PAGE, @@ -50,10 +51,10 @@ describe('Pagination', () => { it('should be able to go to next / previous page', () => { cy.intercept('POST', '/internal/bsearch').as('refetch'); - cy.get(TIMELINE_EVENTS_COUNT_NEXT_PAGE).first().click(); + cy.get(`${TIMELINE_BOTTOM_BAR_CONTAINER} ${TIMELINE_EVENTS_COUNT_NEXT_PAGE}`).first().click(); cy.wait('@refetch').its('response.statusCode').should('eq', 200); - cy.get(TIMELINE_EVENTS_COUNT_PREV_PAGE).first().click(); + cy.get(`${TIMELINE_BOTTOM_BAR_CONTAINER} ${TIMELINE_EVENTS_COUNT_PREV_PAGE}`).first().click(); cy.wait('@refetch').its('response.statusCode').should('eq', 200); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index 0a9e5b44feb1f6..25cd2357fe02bc 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -143,6 +143,8 @@ export const TIMELINE_CORRELATION_TAB = '[data-test-subj="timelineTabs-eql"]'; export const IS_DRAGGING_DATA_PROVIDERS = '.is-dragging'; +export const TIMELINE_BOTTOM_BAR_CONTAINER = '[data-test-subj="timeline-bottom-bar-container"]'; + export const TIMELINE_DATA_PROVIDERS = '[data-test-subj="dataProviders"]'; export const TIMELINE_DATA_PROVIDERS_ACTION_MENU = '[data-test-subj="providerActions"]'; diff --git a/x-pack/plugins/security_solution/public/app/404.tsx b/x-pack/plugins/security_solution/public/app/404.tsx index c21f7a4d4d5782..2634ffd47bff1d 100644 --- a/x-pack/plugins/security_solution/public/app/404.tsx +++ b/x-pack/plugins/security_solution/public/app/404.tsx @@ -8,15 +8,15 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { WrapperPage } from '../common/components/wrapper_page'; +import { SecuritySolutionPageWrapper } from '../common/components/page_wrapper'; export const NotFoundPage = React.memo(() => ( - + - + )); NotFoundPage.displayName = 'NotFoundPage'; diff --git a/x-pack/plugins/security_solution/public/app/app.tsx b/x-pack/plugins/security_solution/public/app/app.tsx index 2dc7f632c84829..c223570c77201c 100644 --- a/x-pack/plugins/security_solution/public/app/app.tsx +++ b/x-pack/plugins/security_solution/public/app/app.tsx @@ -11,7 +11,7 @@ import { Store, Action } from 'redux'; import { Provider as ReduxStoreProvider } from 'react-redux'; import { EuiErrorBoundary } from '@elastic/eui'; -import { AppLeaveHandler } from '../../../../../src/core/public'; +import { AppLeaveHandler, AppMountParameters } from '../../../../../src/core/public'; import { ManageUserInfo } from '../detections/components/user_info'; import { DEFAULT_DARK_MODE, APP_NAME } from '../../common/constants'; @@ -30,10 +30,17 @@ interface StartAppComponent { children: React.ReactNode; history: History; onAppLeave: (handler: AppLeaveHandler) => void; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; store: Store; } -const StartAppComponent: FC = ({ children, history, onAppLeave, store }) => { +const StartAppComponent: FC = ({ + children, + history, + setHeaderActionMenu, + onAppLeave, + store, +}) => { const { i18n } = useKibana().services; const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); @@ -46,7 +53,11 @@ const StartAppComponent: FC = ({ children, history, onAppLeav - + {children} @@ -69,6 +80,7 @@ interface SecurityAppComponentProps { history: History; onAppLeave: (handler: AppLeaveHandler) => void; services: StartServices; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; store: Store; } @@ -77,6 +89,7 @@ const SecurityAppComponent: React.FC = ({ history, onAppLeave, services, + setHeaderActionMenu, store, }) => ( = ({ ...services, }} > - + {children} diff --git a/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx b/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx new file mode 100644 index 00000000000000..98ff11423ce01c --- /dev/null +++ b/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { + EuiHeaderSection, + EuiHeaderLinks, + EuiHeaderLink, + EuiHeaderSectionItem, +} from '@elastic/eui'; +import React, { useEffect, useMemo } from 'react'; +import { createPortalNode, OutPortal, InPortal } from 'react-reverse-portal'; +import { i18n } from '@kbn/i18n'; + +import { AppMountParameters } from '../../../../../../../src/core/public'; +import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; +import { MlPopover } from '../../../common/components/ml_popover/ml_popover'; +import { useKibana } from '../../../common/lib/kibana'; +import { ADD_DATA_PATH, APP_DETECTIONS_PATH } from '../../../../common/constants'; + +const BUTTON_ADD_DATA = i18n.translate('xpack.securitySolution.globalHeader.buttonAddData', { + defaultMessage: 'Add data', +}); + +/** + * This component uses the reverse portal to add the Add Data and ML job settings buttons on the + * right hand side of the Kibana global header + */ +export const GlobalHeader = React.memo( + ({ setHeaderActionMenu }: { setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'] }) => { + const portalNode = useMemo(() => createPortalNode(), []); + const { http } = useKibana().services; + + useEffect(() => { + let unmount = () => {}; + + setHeaderActionMenu((element) => { + const mount = toMountPoint(); + unmount = mount(element); + return unmount; + }); + + return () => { + portalNode.unmount(); + unmount(); + }; + }, [portalNode, setHeaderActionMenu]); + + return ( + + + {window.location.pathname.includes(APP_DETECTIONS_PATH) && ( + + + + )} + + + + {BUTTON_ADD_DATA} + + + + + + ); + } +); +GlobalHeader.displayName = 'GlobalHeader'; diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx b/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx index 7ebcc967538366..8358e2f9377b82 100644 --- a/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx +++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import * as i18n from './translations'; +import * as i18n from '../translations'; import { SecurityPageName } from '../types'; import { SiemNavTab } from '../../common/components/navigation/types'; import { diff --git a/x-pack/plugins/security_solution/public/app/home/index.tsx b/x-pack/plugins/security_solution/public/app/home/index.tsx index 1b0ddcfb9ae7d2..9a57ab3fc3a738 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.tsx @@ -5,57 +5,35 @@ * 2.0. */ -import React, { useEffect, useRef, useState } from 'react'; -import styled from 'styled-components'; +import React, { useRef } from 'react'; -import { TimelineId } from '../../../common/types/timeline'; import { DragDropContextWrapper } from '../../common/components/drag_and_drop/drag_drop_context_wrapper'; -import { Flyout } from '../../timelines/components/flyout'; +import { AppLeaveHandler, AppMountParameters } from '../../../../../../src/core/public'; import { SecuritySolutionAppWrapper } from '../../common/components/page'; -import { HeaderGlobal } from '../../common/components/header_global'; import { HelpMenu } from '../../common/components/help_menu'; -import { AutoSaveWarningMsg } from '../../timelines/components/timeline/auto_save_warning'; import { UseUrlState } from '../../common/components/url_state'; -import { useShowTimeline } from '../../common/utils/timeline/use_show_timeline'; import { navTabs } from './home_navigations'; import { useInitSourcerer, useSourcererScope } from '../../common/containers/sourcerer'; import { useKibana } from '../../common/lib/kibana'; import { DETECTIONS_SUB_PLUGIN_ID } from '../../../common/constants'; import { SourcererScopeName } from '../../common/store/sourcerer/model'; import { useUpgradeEndpointPackage } from '../../common/hooks/endpoint/upgrade'; -import { useThrottledResizeObserver } from '../../common/components/utils'; -import { AppLeaveHandler } from '../../../../../../src/core/public'; - -const Main = styled.main.attrs<{ paddingTop: number }>(({ paddingTop }) => ({ - style: { - paddingTop: `${paddingTop}px`, - }, -}))<{ paddingTop: number }>` - overflow: auto; - display: flex; - flex-direction: column; - flex: 1 1 auto; -`; - -Main.displayName = 'Main'; +import { GlobalHeader } from './global_header'; +import { SecuritySolutionTemplateWrapper } from './template_wrapper'; interface HomePageProps { children: React.ReactNode; onAppLeave: (handler: AppLeaveHandler) => void; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; } -const HomePageComponent: React.FC = ({ children, onAppLeave }) => { - const { application, overlays } = useKibana().services; +const HomePageComponent: React.FC = ({ + children, + onAppLeave, + setHeaderActionMenu, +}) => { + const { application } = useKibana().services; const subPluginId = useRef(''); - const { ref, height = 0 } = useThrottledResizeObserver(300); - const banners$ = overlays.banners.get$(); - const [headerFixed, setHeaderFixed] = useState(true); - const mainPaddingTop = headerFixed ? height : 0; - - useEffect(() => { - const subscription = banners$.subscribe((banners) => setHeaderFixed(!banners.length)); - return () => subscription.unsubscribe(); - }, [banners$]); // Only un/re-subscribe if the Observable changes application.currentAppId$.subscribe((appId) => { subPluginId.current = appId ?? ''; @@ -66,13 +44,13 @@ const HomePageComponent: React.FC = ({ children, onAppLeave }) => ? SourcererScopeName.detections : SourcererScopeName.default ); - const [showTimeline] = useShowTimeline(); - const { browserFields, indexPattern, indicesExist } = useSourcererScope( + const { browserFields, indexPattern } = useSourcererScope( subPluginId.current === DETECTIONS_SUB_PLUGIN_ID ? SourcererScopeName.detections : SourcererScopeName.default ); + // side effect: this will attempt to upgrade the endpoint package if it is not up to date // this will run when a user navigates to the Security Solution app and when they navigate between // tabs in the app. This is useful for keeping the endpoint package as up to date as possible until @@ -81,23 +59,14 @@ const HomePageComponent: React.FC = ({ children, onAppLeave }) => useUpgradeEndpointPackage(); return ( - - - -
- - - {indicesExist && showTimeline && ( - <> - - - - )} - + + + + + {children} - -
- + +
); diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx new file mode 100644 index 00000000000000..08ebbeaee55d44 --- /dev/null +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable react/display-name */ + +import React, { useRef } from 'react'; +import { KibanaPageTemplateProps } from '../../../../../../../../src/plugins/kibana_react/public'; +import { AppLeaveHandler } from '../../../../../../../../src/core/public'; +import { useKibana } from '../../../../common/lib/kibana'; +import { useShowTimeline } from '../../../../common/utils/timeline/use_show_timeline'; +import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { DETECTIONS_SUB_PLUGIN_ID } from '../../../../../common/constants'; +import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; +import { TimelineId } from '../../../../../common/types/timeline'; +import { AutoSaveWarningMsg } from '../../../../timelines/components/timeline/auto_save_warning'; +import { Flyout } from '../../../../timelines/components/flyout'; + +export const BOTTOM_BAR_CLASSNAME = 'timeline-bottom-bar'; + +export const SecuritySolutionBottomBar = React.memo( + ({ onAppLeave }: { onAppLeave: (handler: AppLeaveHandler) => void }) => { + const subPluginId = useRef(''); + const { application } = useKibana().services; + application.currentAppId$.subscribe((appId) => { + subPluginId.current = appId ?? ''; + }); + + const [showTimeline] = useShowTimeline(); + + const { indicesExist } = useSourcererScope( + subPluginId.current === DETECTIONS_SUB_PLUGIN_ID + ? SourcererScopeName.detections + : SourcererScopeName.default + ); + + return indicesExist && showTimeline ? ( + <> + + + + ) : null; + } +); + +export const SecuritySolutionBottomBarProps: KibanaPageTemplateProps['bottomBarProps'] = { + className: BOTTOM_BAR_CLASSNAME, + 'data-test-subj': 'timeline-bottom-bar-container', + position: 'fixed', + usePortal: false, +}; diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/global_kql_header/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/global_kql_header/index.tsx new file mode 100644 index 00000000000000..3e3c91133eab61 --- /dev/null +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/global_kql_header/index.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import styled from 'styled-components'; +import { OutPortal } from 'react-reverse-portal'; +import { useGlobalHeaderPortal } from '../../../../common/hooks/use_global_header_portal'; + +const StyledStickyWrapper = styled.div` + position: sticky; + z-index: ${(props) => props.theme.eui.euiZLevel2}; + // TOP location is declared in src/public/rendering/_base.scss to keep in line with Kibana Chrome +`; + +export const GlobalKQLHeader = React.memo(() => { + const { globalKQLHeaderPortalNode } = useGlobalHeaderPortal(); + + return ( + + + + ); +}); + +GlobalKQLHeader.displayName = 'GlobalKQLHeader'; diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx new file mode 100644 index 00000000000000..02fd07151f111a --- /dev/null +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import styled from 'styled-components'; +import { EuiPanel } from '@elastic/eui'; +import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid'; +import { AppLeaveHandler } from '../../../../../../../src/core/public'; +import { KibanaPageTemplate } from '../../../../../../../src/plugins/kibana_react/public'; +import { useSecuritySolutionNavigation } from '../../../common/components/navigation/use_security_solution_navigation'; +import { TimelineId } from '../../../../common/types/timeline'; +import { getTimelineShowStatusByIdSelector } from '../../../timelines/components/flyout/selectors'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { GlobalKQLHeader } from './global_kql_header'; +import { + BOTTOM_BAR_CLASSNAME, + SecuritySolutionBottomBar, + SecuritySolutionBottomBarProps, +} from './bottom_bar'; +import { useShowTimeline } from '../../../common/utils/timeline/use_show_timeline'; +import { gutterTimeline } from '../../../common/lib/helpers'; + +/* eslint-disable react/display-name */ + +/** + * Need to apply the styles via a className to effect the containing bottom bar + * rather than applying them to the timeline bar directly + */ +const StyledKibanaPageTemplate = styled(KibanaPageTemplate)<{ + $isShowingTimelineOverlay?: boolean; + $isTimelineBottomBarVisible?: boolean; +}>` + .${BOTTOM_BAR_CLASSNAME} { + animation: 'none !important'; // disable the default bottom bar slide animation + background: ${({ theme }) => + theme.eui.euiColorEmptyShade}; // Override bottom bar black background + color: inherit; // Necessary to override the bottom bar 'white text' + transform: ${( + { $isShowingTimelineOverlay } // Since the bottom bar wraps the whole overlay now, need to override any transforms when it is open + ) => ($isShowingTimelineOverlay ? 'none' : 'translateY(calc(100% - 50px))')}; + z-index: ${({ theme }) => theme.eui.euiZLevel8}; + + .${IS_DRAGGING_CLASS_NAME} & { + // When a drag is in process the bottom flyout should slide up to allow a drop + transform: none; + } + } + + // If the bottom bar is visible add padding to the navigation + ${({ $isTimelineBottomBarVisible }) => + $isTimelineBottomBarVisible && + ` + @media (min-width: 768px) { + .kbnPageTemplateSolutionNav { + padding-bottom: ${gutterTimeline}; + } + } + `} +`; + +interface SecuritySolutionPageWrapperProps { + onAppLeave: (handler: AppLeaveHandler) => void; +} + +export const SecuritySolutionTemplateWrapper: React.FC = React.memo( + ({ children, onAppLeave }) => { + const solutionNav = useSecuritySolutionNavigation(); + const [isTimelineBottomBarVisible] = useShowTimeline(); + const getTimelineShowStatus = useMemo(() => getTimelineShowStatusByIdSelector(), []); + const { show: isShowingTimelineOverlay } = useDeepEqualSelector((state) => + getTimelineShowStatus(state, TimelineId.active) + ); + + return ( + } + paddingSize="none" + solutionNav={solutionNav} + restrictWidth={false} + template="default" + > + + + {children} + + + ); + } +); diff --git a/x-pack/plugins/security_solution/public/app/index.tsx b/x-pack/plugins/security_solution/public/app/index.tsx index 1e304c26869602..194f119e35478e 100644 --- a/x-pack/plugins/security_solution/public/app/index.tsx +++ b/x-pack/plugins/security_solution/public/app/index.tsx @@ -15,12 +15,19 @@ export const renderApp = ({ element, history, onAppLeave, + setHeaderActionMenu, services, store, SubPluginRoutes, }: RenderAppProps): (() => void) => { render( - + , element diff --git a/x-pack/plugins/security_solution/public/app/routes.tsx b/x-pack/plugins/security_solution/public/app/routes.tsx index 6454653af5214d..a9a94a69982863 100644 --- a/x-pack/plugins/security_solution/public/app/routes.tsx +++ b/x-pack/plugins/security_solution/public/app/routes.tsx @@ -10,7 +10,7 @@ import React, { FC, memo, useEffect } from 'react'; import { Route, Router, Switch } from 'react-router-dom'; import { useDispatch } from 'react-redux'; -import { AppLeaveHandler } from '../../../../../src/core/public'; +import { AppLeaveHandler, AppMountParameters } from '../../../../../src/core/public'; import { ManageRoutesSpy } from '../common/utils/route/manage_spy_routes'; import { RouteCapture } from '../common/components/endpoint/route_capture'; import { AppAction } from '../common/store/actions'; @@ -21,9 +21,15 @@ interface RouterProps { children: React.ReactNode; history: History; onAppLeave: (handler: AppLeaveHandler) => void; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; } -const PageRouterComponent: FC = ({ children, history, onAppLeave }) => { +const PageRouterComponent: FC = ({ + children, + history, + onAppLeave, + setHeaderActionMenu, +}) => { const dispatch = useDispatch<(action: AppAction) => void>(); useEffect(() => { return () => { @@ -42,7 +48,9 @@ const PageRouterComponent: FC = ({ children, history, onAppLeave }) - {children} + + {children} + diff --git a/x-pack/plugins/security_solution/public/app/home/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/app/home/translations.ts rename to x-pack/plugins/security_solution/public/app/translations.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx index 91fb45de04320b..dfd53ae5cc0b07 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx @@ -38,7 +38,7 @@ export const Create = React.memo(() => { ); return ( - + {cases.getCreateCase({ onCancel: handleSetIsCancel, onSuccess, diff --git a/x-pack/plugins/security_solution/public/cases/pages/case.tsx b/x-pack/plugins/security_solution/public/cases/pages/case.tsx index 647647afbe0a4a..ad0176bda6905c 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/case.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/case.tsx @@ -7,7 +7,7 @@ import React from 'react'; -import { WrapperPage } from '../../common/components/wrapper_page'; +import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { useGetUserCasesPermissions } from '../../common/lib/kibana'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { AllCases } from '../components/all_cases'; @@ -20,9 +20,9 @@ export const CasesPage = React.memo(() => { return userPermissions == null || userPermissions?.read ? ( <> - + - + ) : ( diff --git a/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx b/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx index a086409e55df52..f6bb27b7b7104f 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx @@ -10,7 +10,7 @@ import { useParams } from 'react-router-dom'; import { SecurityPageName } from '../../app/types'; import { SpyRoute } from '../../common/utils/route/spy_routes'; -import { WrapperPage } from '../../common/components/wrapper_page'; +import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { useGetUrlSearch } from '../../common/components/navigation/use_get_url_search'; import { useGetUserCasesPermissions, useKibana } from '../../common/lib/kibana'; import { getCaseUrl } from '../../common/components/link_to'; @@ -37,13 +37,13 @@ export const CaseDetailsPage = React.memo(() => { return caseId != null ? ( <> - + - + ) : null; diff --git a/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx b/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx index c942065e45278d..d3f235a5da7dc1 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { SecurityPageName } from '../../app/types'; import { getCaseUrl } from '../../common/components/link_to'; import { useGetUrlSearch } from '../../common/components/navigation/use_get_url_search'; -import { WrapperPage } from '../../common/components/wrapper_page'; +import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { useGetUserCasesPermissions, useKibana } from '../../common/lib/kibana'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { navTabs } from '../../app/home/home_navigations'; @@ -51,7 +51,7 @@ const ConfigureCasesPageComponent: React.FC = () => { return ( <> - + @@ -63,7 +63,7 @@ const ConfigureCasesPageComponent: React.FC = () => { owner: [APP_ID], })} - + ); diff --git a/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx b/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx index 3c5197f19eff12..6c88c4afb63955 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx @@ -10,7 +10,7 @@ import React, { useEffect, useMemo } from 'react'; import { SecurityPageName } from '../../app/types'; import { getCaseUrl } from '../../common/components/link_to'; import { useGetUrlSearch } from '../../common/components/navigation/use_get_url_search'; -import { WrapperPage } from '../../common/components/wrapper_page'; +import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { useGetUserCasesPermissions, useKibana } from '../../common/lib/kibana'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { navTabs } from '../../app/home/home_navigations'; @@ -45,10 +45,10 @@ export const CreateCasePage = React.memo(() => { return ( <> - + - + ); diff --git a/x-pack/plugins/security_solution/public/common/components/callouts/callout_switcher.tsx b/x-pack/plugins/security_solution/public/common/components/callouts/callout_switcher.tsx index e700bb97e9893b..43f10604d8582e 100644 --- a/x-pack/plugins/security_solution/public/common/components/callouts/callout_switcher.tsx +++ b/x-pack/plugins/security_solution/public/common/components/callouts/callout_switcher.tsx @@ -6,6 +6,7 @@ */ import React, { FC, memo } from 'react'; +import { EuiSpacer } from '@elastic/eui'; import { CallOutMessage } from './callout_types'; import { CallOut } from './callout'; @@ -21,7 +22,12 @@ const CallOutSwitcherComponent: FC = ({ namespace, conditi const { isVisible, dismiss } = useCallOutStorage([message], namespace); const shouldRender = condition && isVisible(message); - return shouldRender ? : null; + return shouldRender ? ( + <> + + + + ) : null; }; export const CallOutSwitcher = memo(CallOutSwitcherComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 8326cdaaaf9952..5dadd740ae3bca 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -286,6 +286,7 @@ const EventsViewerComponent: React.FC = ({ {canQueryTimeline ? ( diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index c0a75bdd3edd2e..32aa716d4bce3e 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -27,10 +27,8 @@ import { useKibana } from '../../lib/kibana'; import { defaultControlColumn } from '../../../timelines/components/timeline/body/control_columns'; import { EventsViewer } from './events_viewer'; -const DEFAULT_EVENTS_VIEWER_HEIGHT = 652; - const FullScreenContainer = styled.div<{ $isFullScreen: boolean }>` - height: ${({ $isFullScreen }) => ($isFullScreen ? '100%' : `${DEFAULT_EVENTS_VIEWER_HEIGHT}px`)}; + height: ${({ $isFullScreen }) => ($isFullScreen ? '100%' : undefined)}; flex: 1 1 auto; display: flex; width: 100%; diff --git a/x-pack/plugins/security_solution/public/common/components/filters_global/__snapshots__/filters_global.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/filters_global/__snapshots__/filters_global.test.tsx.snap index 994e98d8619a18..51326d54a61611 100644 --- a/x-pack/plugins/security_solution/public/common/components/filters_global/__snapshots__/filters_global.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/filters_global/__snapshots__/filters_global.test.tsx.snap @@ -4,17 +4,19 @@ exports[`rendering renders correctly 1`] = ` } > - -

Additional filters here.

-
-
+ +
`; diff --git a/x-pack/plugins/security_solution/public/common/components/filters_global/filters_global.tsx b/x-pack/plugins/security_solution/public/common/components/filters_global/filters_global.tsx index c6b5b6ccde5cd1..79c08e50451f78 100644 --- a/x-pack/plugins/security_solution/public/common/components/filters_global/filters_global.tsx +++ b/x-pack/plugins/security_solution/public/common/components/filters_global/filters_global.tsx @@ -8,18 +8,9 @@ import React from 'react'; import styled from 'styled-components'; import { InPortal } from 'react-reverse-portal'; - +import { EuiPanel } from '@elastic/eui'; import { useGlobalHeaderPortal } from '../../hooks/use_global_header_portal'; -const Wrapper = styled.aside` - position: relative; - z-index: ${({ theme }) => theme.eui.euiZNavigation}; - background: ${({ theme }) => theme.eui.euiColorEmptyShade}; - border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; - padding: ${({ theme }) => theme.eui.paddingSizes.m} ${({ theme }) => theme.eui.paddingSizes.l}; -`; -Wrapper.displayName = 'Wrapper'; - const FiltersGlobalContainer = styled.header<{ show: boolean }>` display: ${({ show }) => (show ? 'block' : 'none')}; `; @@ -32,13 +23,15 @@ export interface FiltersGlobalProps { } export const FiltersGlobal = React.memo(({ children, show = true }) => { - const { globalHeaderPortalNode } = useGlobalHeaderPortal(); + const { globalKQLHeaderPortalNode } = useGlobalHeaderPortal(); return ( - - - {children} - + + + + {children} + + ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/header_global/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_global/index.test.tsx deleted file mode 100644 index 96a7eacb7fb08c..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/header_global/index.test.tsx +++ /dev/null @@ -1,51 +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 { mount } from 'enzyme'; - -import { useGetUserCasesPermissions } from '../../../common/lib/kibana'; -import { TestProviders } from '../../../common/mock'; -import { HeaderGlobal } from '.'; - -jest.mock('../../../common/lib/kibana'); - -describe('HeaderGlobal', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('does not display the cases tab when the user does not have read permissions', () => { - (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ - crud: false, - read: false, - }); - - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj="navigation-case"]`).exists()).toBeFalsy(); - }); - - it('displays the cases tab when the user has read permissions', () => { - (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ - crud: true, - read: true, - }); - - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj="navigation-case"]`).exists()).toBeTruthy(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx deleted file mode 100644 index e91905183aab10..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx +++ /dev/null @@ -1,155 +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, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; -import { pickBy } from 'lodash/fp'; -import React, { forwardRef, useCallback, useMemo } from 'react'; -import styled from 'styled-components'; -import { OutPortal } from 'react-reverse-portal'; - -import { navTabs } from '../../../app/home/home_navigations'; -import { useGlobalFullScreen, useTimelineFullScreen } from '../../containers/use_full_screen'; -import { SecurityPageName } from '../../../app/types'; -import { getAppOverviewUrl } from '../link_to'; -import { MlPopover } from '../ml_popover/ml_popover'; -import { SiemNavigation } from '../navigation'; -import * as i18n from './translations'; -import { useGetUrlSearch } from '../navigation/use_get_url_search'; -import { useGetUserCasesPermissions, useKibana } from '../../lib/kibana'; -import { APP_ID, ADD_DATA_PATH, APP_DETECTIONS_PATH } from '../../../../common/constants'; -import { useGlobalHeaderPortal } from '../../hooks/use_global_header_portal'; -import { LinkAnchor } from '../links'; - -const Wrapper = styled.header<{ $isFixed: boolean }>` - ${({ theme, $isFixed }) => ` - background: ${theme.eui.euiColorEmptyShade}; - border-bottom: ${theme.eui.euiBorderThin}; - width: 100%; - z-index: ${theme.eui.euiZNavigation}; - position: ${$isFixed ? 'fixed' : 'relative'}; - `} -`; -Wrapper.displayName = 'Wrapper'; - -const WrapperContent = styled.div<{ $globalFullScreen: boolean }>` - display: ${({ $globalFullScreen }) => ($globalFullScreen ? 'none' : 'block')}; - padding-top: ${({ $globalFullScreen, theme }) => - $globalFullScreen ? theme.eui.paddingSizes.s : theme.eui.paddingSizes.m}; -`; - -WrapperContent.displayName = 'WrapperContent'; - -const FlexItem = styled(EuiFlexItem)` - min-width: 0; -`; -FlexItem.displayName = 'FlexItem'; - -const FlexGroup = styled(EuiFlexGroup)<{ $hasSibling: boolean }>` - ${({ $hasSibling, theme }) => ` - border-bottom: ${theme.eui.euiBorderThin}; - margin-bottom: 1px; - padding-bottom: 4px; - padding-left: ${theme.eui.paddingSizes.l}; - padding-right: ${theme.eui.paddingSizes.l}; - ${$hasSibling ? `border-bottom: ${theme.eui.euiBorderThin};` : 'border-bottom-width: 0px;'} - `} -`; -FlexGroup.displayName = 'FlexGroup'; - -interface HeaderGlobalProps { - hideDetectionEngine?: boolean; - isFixed?: boolean; -} - -export const HeaderGlobal = React.memo( - forwardRef( - ({ hideDetectionEngine = false, isFixed = true }, ref) => { - const { globalHeaderPortalNode } = useGlobalHeaderPortal(); - const { globalFullScreen } = useGlobalFullScreen(); - const { timelineFullScreen } = useTimelineFullScreen(); - const search = useGetUrlSearch(navTabs.overview); - const { application, http } = useKibana().services; - const { navigateToApp, getUrlForApp } = application; - const overviewPath = useMemo( - () => getUrlForApp(APP_ID, { path: SecurityPageName.overview }), - [getUrlForApp] - ); - const overviewHref = useMemo(() => getAppOverviewUrl(overviewPath, search), [ - overviewPath, - search, - ]); - - const basePath = http.basePath.get(); - const goToOverview = useCallback( - (ev) => { - ev.preventDefault(); - navigateToApp(`${APP_ID}:${SecurityPageName.overview}`, { path: search }); - }, - [navigateToApp, search] - ); - - const hasCasesReadPermissions = useGetUserCasesPermissions()?.read; - - // build a list of tabs to exclude - const tabsToExclude = new Set([ - ...(hideDetectionEngine ? [SecurityPageName.detections] : []), - ...(!hasCasesReadPermissions ? [SecurityPageName.case] : []), - ]); - - // include the tab if it is not in the set of excluded ones - const tabsToDisplay = pickBy((_, key) => !tabsToExclude.has(key), navTabs); - - return ( - - - - - - - - - - - - - - - - - - - {window.location.pathname.includes(APP_DETECTIONS_PATH) && ( - - - - )} - - - - {i18n.BUTTON_ADD_DATA} - - - - - - - - - ); - } - ) -); -HeaderGlobal.displayName = 'HeaderGlobal'; diff --git a/x-pack/plugins/security_solution/public/common/components/header_global/translations.ts b/x-pack/plugins/security_solution/public/common/components/header_global/translations.ts deleted file mode 100644 index a2a22dfe31eb96..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/header_global/translations.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const SECURITY_SOLUTION = i18n.translate( - 'xpack.securitySolution.headerGlobal.securitySolution', - { - defaultMessage: 'Security solution', - } -); - -export const BUTTON_ADD_DATA = i18n.translate('xpack.securitySolution.headerGlobal.buttonAddData', { - defaultMessage: 'Add data', -}); diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/header_page/__snapshots__/index.test.tsx.snap index 84c8971e3d352f..9cb9f28612b155 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/header_page/__snapshots__/index.test.tsx.snap @@ -1,14 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`HeaderPage it renders 1`] = ` -
- + - + - - +

Test supplement

-
-
- + + + -
+ `; diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/index.test.tsx index 78bac02585b9f5..8a1748de582c43 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/index.test.tsx @@ -57,7 +57,7 @@ describe('HeaderPage', () => { ); - expect(wrapper.find('.siemHeaderPage__linkBack').first().exists()).toBe(true); + expect(wrapper.find('.securitySolutionHeaderPage__linkBack').first().exists()).toBe(true); }); test('it DOES NOT render the back link when not provided', () => { @@ -67,7 +67,7 @@ describe('HeaderPage', () => { ); - expect(wrapper.find('.siemHeaderPage__linkBack').first().exists()).toBe(false); + expect(wrapper.find('.securitySolutionHeaderPage__linkBack').first().exists()).toBe(false); }); test('it renders the first subtitle when provided', () => { @@ -134,27 +134,21 @@ describe('HeaderPage', () => { expect(wrapper.find('[data-test-subj="header-page-supplements"]').first().exists()).toBe(false); }); - test('it applies border styles when border is true', () => { - const wrapper = mount( - - - - ); - const siemHeaderPage = wrapper.find('.siemHeaderPage').first(); - - expect(siemHeaderPage).toHaveStyleRule('border-bottom', euiDarkVars.euiBorderThin); - expect(siemHeaderPage).toHaveStyleRule('padding-bottom', euiDarkVars.paddingSizes.l); - }); - test('it DOES NOT apply border styles when border is false', () => { const wrapper = mount( ); - const siemHeaderPage = wrapper.find('.siemHeaderPage').first(); + const securitySolutionHeaderPage = wrapper.find('.securitySolutionHeaderPage').first(); - expect(siemHeaderPage).not.toHaveStyleRule('border-bottom', euiDarkVars.euiBorderThin); - expect(siemHeaderPage).not.toHaveStyleRule('padding-bottom', euiDarkVars.paddingSizes.l); + expect(securitySolutionHeaderPage).not.toHaveStyleRule( + 'border-bottom', + euiDarkVars.euiBorderThin + ); + expect(securitySolutionHeaderPage).not.toHaveStyleRule( + 'padding-bottom', + euiDarkVars.paddingSizes.l + ); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx index d01869bb6999b0..1c87d70c0c7cb1 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx @@ -5,7 +5,13 @@ * 2.0. */ -import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui'; +import { + EuiBadge, + EuiProgress, + EuiPageHeader, + EuiPageHeaderSection, + EuiSpacer, +} from '@elastic/eui'; import React, { useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import styled, { css } from 'styled-components'; @@ -25,36 +31,16 @@ interface HeaderProps { } const Header = styled.header.attrs({ - className: 'siemHeaderPage', + className: 'securitySolutionHeaderPage', })` ${({ border, theme }) => css` margin-bottom: ${theme.eui.euiSizeL}; - - ${border && - css` - border-bottom: ${theme.eui.euiBorderThin}; - padding-bottom: ${theme.eui.paddingSizes.l}; - .euiProgress { - top: ${theme.eui.paddingSizes.l}; - } - `} `} `; Header.displayName = 'Header'; -const FlexItem = styled(EuiFlexItem)` - ${({ theme }) => css` - display: block; - - @media only screen and (min-width: ${theme.eui.euiBreakpoints.m}) { - max-width: 50%; - } - `} -`; -FlexItem.displayName = 'FlexItem'; - const LinkBack = styled.div.attrs({ - className: 'siemHeaderPage__linkBack', + className: 'securitySolutionHeaderPage__linkBack', })` ${({ theme }) => css` font-size: ${theme.eui.euiFontSizeXS}; @@ -117,9 +103,9 @@ const HeaderPageComponent: React.FC = ({ [backOptions, history] ); return ( -
- - + <> + + {backOptions && ( = ({ {subtitle && } {subtitle2 && } {border && isLoading && } - + {children && ( - + {children} - + )} - - {!hideSourcerer && } -
+ {!hideSourcerer && } + + {/* Manually add a 'padding-bottom' to header */} + + ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/item_details_card/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/item_details_card/__snapshots__/index.test.tsx.snap index c7841f6d6bbcc2..f0fd8427140df2 100644 --- a/x-pack/plugins/security_solution/public/common/components/item_details_card/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/item_details_card/__snapshots__/index.test.tsx.snap @@ -14,6 +14,7 @@ exports[`item_details_card ItemDetailsAction should render correctly 1`] = ` exports[`item_details_card ItemDetailsCard should render correctly with actions 1`] = ` ( ); return ( - + diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.tsx index 561805217e8a14..cc6ac5355f90b8 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.tsx @@ -5,7 +5,13 @@ * 2.0. */ -import { EuiButtonEmpty, EuiCallOut, EuiPopover, EuiPopoverTitle, EuiSpacer } from '@elastic/eui'; +import { + EuiHeaderSectionItemButton, + EuiCallOut, + EuiPopover, + EuiPopoverTitle, + EuiSpacer, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import moment from 'moment'; import React, { Dispatch, useCallback, useReducer, useState } from 'react'; @@ -115,14 +121,19 @@ export const MlPopover = React.memo(() => { anchorPosition="downRight" id="integrations-popover" button={ - setIsPopoverOpen(!isPopoverOpen)} + textProps={{ style: { fontSize: '1rem' } }} > {i18n.ML_JOB_SETTINGS} - + } isOpen={isPopoverOpen} closePopover={() => setIsPopoverOpen(!isPopoverOpen)} @@ -138,7 +149,11 @@ export const MlPopover = React.memo(() => { anchorPosition="downRight" id="integrations-popover" button={ - { setIsPopoverOpen(!isPopoverOpen); dispatch({ type: 'refresh' }); }} + textProps={{ style: { fontSize: '1rem' } }} > {i18n.ML_JOB_SETTINGS} - + } isOpen={isPopoverOpen} closePopover={() => setIsPopoverOpen(!isPopoverOpen)} diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx index 27db326dddec5c..c75b38e03acb46 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx @@ -9,12 +9,12 @@ import { mount } from 'enzyme'; import React from 'react'; import { CONSTANTS } from '../url_state/constants'; -import { SiemNavigationComponent } from './'; +import { TabNavigationComponent } from './'; import { setBreadcrumbs } from './breadcrumbs'; import { navTabs } from '../../../app/home/home_navigations'; import { HostsTableType } from '../../../hosts/store/model'; import { RouteSpyState } from '../../utils/route/types'; -import { SiemNavigationProps, SiemNavigationComponentProps } from './types'; +import { TabNavigationComponentProps, SecuritySolutionTabNavigationProps } from './types'; import { TimelineTabs } from '../../../../common/types/timeline'; jest.mock('react-router-dom', () => { @@ -48,7 +48,9 @@ jest.mock('../../lib/kibana', () => { jest.mock('../link_to'); describe('SIEM Navigation', () => { - const mockProps: SiemNavigationComponentProps & SiemNavigationProps & RouteSpyState = { + const mockProps: TabNavigationComponentProps & + SecuritySolutionTabNavigationProps & + RouteSpyState = { pageName: 'hosts', pathName: '/', detailName: undefined, @@ -89,7 +91,7 @@ describe('SIEM Navigation', () => { }, }, }; - const wrapper = mount(); + const wrapper = mount(); test('it calls setBreadcrumbs with correct path on mount', () => { expect(setBreadcrumbs).toHaveBeenNthCalledWith( 1, diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/index.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/index.tsx index 7ea0b26ae8b3b8..233b4b2cb1d029 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/index.tsx @@ -16,75 +16,93 @@ import { useRouteSpy } from '../../utils/route/use_route_spy'; import { makeMapStateToProps } from '../url_state/helpers'; import { setBreadcrumbs } from './breadcrumbs'; import { TabNavigation } from './tab_navigation'; -import { SiemNavigationProps, SiemNavigationComponentProps } from './types'; +import { TabNavigationComponentProps, SecuritySolutionTabNavigationProps } from './types'; -export const SiemNavigationComponent: React.FC< - SiemNavigationComponentProps & SiemNavigationProps & RouteSpyState -> = ({ - detailName, - display, - navTabs, - pageName, - pathName, - search, - tabName, - urlState, - flowTarget, - state, -}) => { - const { - chrome, - application: { getUrlForApp }, - } = useKibana().services; +/** + * @description - This component handels all of the tab navigation seen within a Security Soluton application page, not the Security Solution primary side navigation + * For the primary side nav see './use_security_solution_navigation' + */ +export const TabNavigationComponent: React.FC< + RouteSpyState & SecuritySolutionTabNavigationProps & TabNavigationComponentProps +> = React.memo( + ({ + detailName, + display, + flowTarget, + navTabs, + pageName, + pathName, + search, + state, + tabName, + urlState, + }) => { + const { + chrome, + application: { getUrlForApp }, + } = useKibana().services; - useEffect(() => { - if (pathName || pageName) { - setBreadcrumbs( - { - detailName, - filters: urlState.filters, - flowTarget, - navTabs, - pageName, - pathName, - query: urlState.query, - savedQuery: urlState.savedQuery, - search, - sourcerer: urlState.sourcerer, - state, - tabName, - timeline: urlState.timeline, - timerange: urlState.timerange, - }, - chrome, - getUrlForApp - ); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chrome, pageName, pathName, search, navTabs, urlState, state]); + useEffect(() => { + if (pathName || pageName) { + setBreadcrumbs( + { + detailName, + filters: urlState.filters, + flowTarget, + navTabs, + pageName, + pathName, + query: urlState.query, + savedQuery: urlState.savedQuery, + search, + sourcerer: urlState.sourcerer, + state, + tabName, + timeline: urlState.timeline, + timerange: urlState.timerange, + }, + chrome, + getUrlForApp + ); + } + }, [ + chrome, + pageName, + pathName, + search, + navTabs, + urlState, + state, + detailName, + flowTarget, + tabName, + getUrlForApp, + ]); - return ( - - ); -}; + return ( + + ); + } +); +TabNavigationComponent.displayName = 'TabNavigationComponent'; -export const SiemNavigationRedux = compose< - React.ComponentClass +export const SecuritySolutionTabNavigationRedux = compose< + React.ComponentClass >(connect(makeMapStateToProps))( React.memo( - SiemNavigationComponent, + TabNavigationComponent, (prevProps, nextProps) => prevProps.pathName === nextProps.pathName && prevProps.search === nextProps.search && @@ -94,16 +112,16 @@ export const SiemNavigationRedux = compose< ) ); -const SiemNavigationContainer: React.FC = (props) => { - const [routeProps] = useRouteSpy(); - const stateNavReduxProps: RouteSpyState & SiemNavigationProps = { - ...routeProps, - ...props, - }; - - return ; -}; +export const SecuritySolutionTabNavigation: React.FC = React.memo( + (props) => { + const [routeProps] = useRouteSpy(); + const stateNavReduxProps: RouteSpyState & SecuritySolutionTabNavigationProps = { + ...routeProps, + ...props, + }; -export const SiemNavigation = React.memo(SiemNavigationContainer, (prevProps, nextProps) => - deepEqual(prevProps.navTabs, nextProps.navTabs) + return ; + }, + (prevProps, nextProps) => deepEqual(prevProps.navTabs, nextProps.navTabs) ); +SecuritySolutionTabNavigation.displayName = 'SecuritySolutionTabNavigation'; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/types.ts index 4253d08d1ed197..53565d79e6948a 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/types.ts @@ -7,17 +7,17 @@ import { UrlInputsModel } from '../../../store/inputs/model'; import { CONSTANTS } from '../../url_state/constants'; -import { HostsTableType } from '../../../../hosts/store/model'; import { SourcererScopePatterns } from '../../../store/sourcerer/model'; import { TimelineUrl } from '../../../../timelines/store/timeline/model'; import { Filter, Query } from '../../../../../../../../src/plugins/data/public'; -import { SiemNavigationProps } from '../types'; +import { SecuritySolutionTabNavigationProps } from '../types'; +import { SiemRouteType } from '../../../utils/route/types'; -export interface TabNavigationProps extends SiemNavigationProps { +export interface TabNavigationProps extends SecuritySolutionTabNavigationProps { pathName: string; pageName: string; - tabName: HostsTableType | undefined; + tabName: SiemRouteType | undefined; [CONSTANTS.appQuery]?: Query; [CONSTANTS.filters]?: Filter[]; [CONSTANTS.savedQuery]?: string; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 9700afcb8cd59e..1c317700b1d150 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -5,31 +5,20 @@ * 2.0. */ -import { Filter, Query } from '../../../../../../../src/plugins/data/public'; -import { HostsTableType } from '../../../hosts/store/model'; -import { UrlInputsModel } from '../../store/inputs/model'; -import { TimelineUrl } from '../../../timelines/store/timeline/model'; -import { CONSTANTS, UrlStateType } from '../url_state/constants'; +import { UrlStateType } from '../url_state/constants'; import { SecurityPageName } from '../../../app/types'; -import { SourcererScopePatterns } from '../../store/sourcerer/model'; +import { UrlState } from '../url_state/types'; +import { SiemRouteType } from '../../utils/route/types'; -export interface SiemNavigationProps { +export interface SecuritySolutionTabNavigationProps { display?: 'default' | 'condensed'; navTabs: Record; } - -export interface SiemNavigationComponentProps { - pathName: string; +export interface TabNavigationComponentProps { pageName: string; - tabName: HostsTableType | undefined; - urlState: { - [CONSTANTS.appQuery]?: Query; - [CONSTANTS.filters]?: Filter[]; - [CONSTANTS.savedQuery]?: string; - [CONSTANTS.sourcerer]: SourcererScopePatterns; - [CONSTANTS.timerange]: UrlInputsModel; - [CONSTANTS.timeline]: TimelineUrl; - }; + tabName: SiemRouteType | undefined; + urlState: UrlState; + pathName: string; } export type SearchNavTab = NavTab | { urlKey: UrlStateType; isDetailPage: boolean }; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx new file mode 100644 index 00000000000000..48d3cfb5abcc14 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx @@ -0,0 +1,214 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { KibanaPageTemplateProps } from '../../../../../../../../src/plugins/kibana_react/public'; +import { useGetUserCasesPermissions, useKibana } from '../../../lib/kibana'; +import { SecurityPageName } from '../../../../app/types'; +import { useSecuritySolutionNavigation } from '.'; +import { CONSTANTS } from '../../url_state/constants'; +import { TimelineTabs } from '../../../../../common/types/timeline'; +import { useDeepEqualSelector } from '../../../hooks/use_selector'; +import { UrlInputsModel } from '../../../store/inputs/model'; +import { useRouteSpy } from '../../../utils/route/use_route_spy'; + +jest.mock('../../../lib/kibana'); +jest.mock('../../../hooks/use_selector'); +jest.mock('../../../utils/route/use_route_spy'); + +describe('useSecuritySolutionNavigation', () => { + const mockUrlState = { + [CONSTANTS.appQuery]: { query: 'host.name:"security-solution-es"', language: 'kuery' }, + [CONSTANTS.savedQuery]: '', + [CONSTANTS.sourcerer]: {}, + [CONSTANTS.timeline]: { + activeTab: TimelineTabs.query, + id: '', + isOpen: false, + graphEventId: '', + }, + [CONSTANTS.timerange]: { + global: { + [CONSTANTS.timerange]: { + from: '2020-07-07T08:20:18.966Z', + fromStr: 'now-24h', + kind: 'relative', + to: '2020-07-08T08:20:18.966Z', + toStr: 'now', + }, + linkTo: ['timeline'], + }, + timeline: { + [CONSTANTS.timerange]: { + from: '2020-07-07T08:20:18.966Z', + fromStr: 'now-24h', + kind: 'relative', + to: '2020-07-08T08:20:18.966Z', + toStr: 'now', + }, + linkTo: ['global'], + }, + } as UrlInputsModel, + }; + + const mockRouteSpy = [ + { + detailName: '', + flowTarget: '', + pathName: '', + search: '', + state: '', + tabName: '', + pageName: SecurityPageName.hosts, + }, + ]; + + beforeEach(() => { + (useDeepEqualSelector as jest.Mock).mockReturnValue({ urlState: mockUrlState }); + (useRouteSpy as jest.Mock).mockReturnValue(mockRouteSpy); + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + navigateToApp: jest.fn(), + getUrlForApp: (appId: string, options?: { path?: string; absolute?: boolean }) => + `${appId}${options?.path ?? ''}`, + }, + chrome: { + setBreadcrumbs: jest.fn(), + }, + }, + }); + }); + + it('should create navigation config', async () => { + const { result } = renderHook<{}, KibanaPageTemplateProps['solutionNav']>(() => + useSecuritySolutionNavigation() + ); + + expect(result.current).toMatchInlineSnapshot(` + Object { + "icon": "logoSecurity", + "items": Array [ + Object { + "id": "securitySolution", + "items": Array [ + Object { + "data-href": "securitySolution:overview?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-overview", + "disabled": false, + "href": "securitySolution:overview?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "overview", + "isSelected": false, + "name": "Overview", + "onClick": [Function], + }, + Object { + "data-href": "securitySolution:detections?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-detections", + "disabled": false, + "href": "securitySolution:detections?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "detections", + "isSelected": false, + "name": "Detections", + "onClick": [Function], + }, + Object { + "data-href": "securitySolution:hosts?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-hosts", + "disabled": false, + "href": "securitySolution:hosts?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "hosts", + "isSelected": true, + "name": "Hosts", + "onClick": [Function], + }, + Object { + "data-href": "securitySolution:network?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-network", + "disabled": false, + "href": "securitySolution:network?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "network", + "isSelected": false, + "name": "Network", + "onClick": [Function], + }, + Object { + "data-href": "securitySolution:timelines?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-timelines", + "disabled": false, + "href": "securitySolution:timelines?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "timelines", + "isSelected": false, + "name": "Timelines", + "onClick": [Function], + }, + Object { + "data-href": "securitySolution:administration", + "data-test-subj": "navigation-administration", + "disabled": false, + "href": "securitySolution:administration", + "id": "administration", + "isSelected": false, + "name": "Administration", + "onClick": [Function], + }, + ], + "name": "", + }, + ], + "name": "Security", + } + `); + }); + + describe('Permission gated routes', () => { + describe('cases', () => { + it('should display the cases navigation item when the user has read permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: true, + read: true, + }); + + const { result } = renderHook<{}, KibanaPageTemplateProps['solutionNav']>(() => + useSecuritySolutionNavigation() + ); + + const caseNavItem = result.current?.items[0].items?.find( + (item) => item['data-test-subj'] === 'navigation-case' + ); + expect(caseNavItem).toMatchInlineSnapshot(` + Object { + "data-href": "securitySolution:case?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-case", + "disabled": false, + "href": "securitySolution:case?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "case", + "isSelected": false, + "name": "Cases", + "onClick": [Function], + } + `); + }); + + it('should not display the cases navigation item when the user does not have read permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: false, + }); + + const { result } = renderHook<{}, KibanaPageTemplateProps['solutionNav']>(() => + useSecuritySolutionNavigation() + ); + + const caseNavItem = result.current?.items[0].items?.find( + (item) => item['data-test-subj'] === 'navigation-case' + ); + expect(caseNavItem).toBeFalsy(); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.tsx new file mode 100644 index 00000000000000..f2aee86912dd7a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect } from 'react'; +import { pickBy } from 'lodash/fp'; +import { usePrimaryNavigation } from './use_primary_navigation'; +import { useGetUserCasesPermissions, useKibana } from '../../../lib/kibana'; +import { setBreadcrumbs } from '../breadcrumbs'; +import { makeMapStateToProps } from '../../url_state/helpers'; +import { useRouteSpy } from '../../../utils/route/use_route_spy'; +import { navTabs } from '../../../../app/home/home_navigations'; +import { useDeepEqualSelector } from '../../../hooks/use_selector'; +import { SecurityPageName } from '../../../../../common/constants'; + +/** + * @description - This hook provides the structure necessary by the KibanaPageTemplate for rendering the primary security_solution side navigation. + * TODO: Consolidate & re-use the logic in the hooks in this directory that are replicated from the tab_navigation to maintain breadcrumbs, telemetry, etc... + */ +export const useSecuritySolutionNavigation = () => { + const [routeProps] = useRouteSpy(); + const urlMapState = makeMapStateToProps(); + const { urlState } = useDeepEqualSelector(urlMapState); + const { + chrome, + application: { getUrlForApp }, + } = useKibana().services; + + const { detailName, flowTarget, pageName, pathName, search, state, tabName } = routeProps; + + useEffect(() => { + if (pathName || pageName) { + setBreadcrumbs( + { + detailName, + filters: urlState.filters, + flowTarget, + navTabs, + pageName, + pathName, + query: urlState.query, + savedQuery: urlState.savedQuery, + search, + sourcerer: urlState.sourcerer, + state, + tabName, + timeline: urlState.timeline, + timerange: urlState.timerange, + }, + chrome, + getUrlForApp + ); + } + }, [ + chrome, + pageName, + pathName, + search, + urlState, + state, + detailName, + flowTarget, + tabName, + getUrlForApp, + ]); + + const hasCasesReadPermissions = useGetUserCasesPermissions()?.read; + + // build a list of tabs to exclude + const tabsToExclude = new Set([ + ...(!hasCasesReadPermissions ? [SecurityPageName.case] : []), + ]); + + // include the tab if it is not in the set of excluded ones + const tabsToDisplay = pickBy((_, key) => !tabsToExclude.has(key), navTabs); + + return usePrimaryNavigation({ + query: urlState.query, + filters: urlState.filters, + navTabs: tabsToDisplay, + pageName, + sourcerer: urlState.sourcerer, + savedQuery: urlState.savedQuery, + timeline: urlState.timeline, + timerange: urlState.timerange, + }); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/types.ts new file mode 100644 index 00000000000000..f639b8a37f0da4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TabNavigationProps } from '../tab_navigation/types'; + +export type PrimaryNavigationItemsProps = Omit< + TabNavigationProps, + 'pathName' | 'pageName' | 'tabName' +> & { selectedTabId: string }; + +export type PrimaryNavigationProps = Omit; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx new file mode 100644 index 00000000000000..42ca7f4c65460e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { APP_ID } from '../../../../../common/constants'; +import { track, METRIC_TYPE, TELEMETRY_EVENT } from '../../../lib/telemetry'; +import { getSearch } from '../helpers'; +import { PrimaryNavigationItemsProps } from './types'; +import { useKibana } from '../../../lib/kibana'; + +export const usePrimaryNavigationItems = ({ + filters, + navTabs, + query, + savedQuery, + selectedTabId, + sourcerer, + timeline, + timerange, +}: PrimaryNavigationItemsProps) => { + const { navigateToApp, getUrlForApp } = useKibana().services.application; + + const navItems = Object.values(navTabs).map((tab) => { + const { id, name, disabled } = tab; + const isSelected = selectedTabId === id; + const urlSearch = getSearch(tab, { + filters, + query, + savedQuery, + sourcerer, + timeline, + timerange, + }); + + const handleClick = (ev: React.MouseEvent) => { + ev.preventDefault(); + navigateToApp(`${APP_ID}:${id}`, { path: urlSearch }); + track(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.TAB_CLICKED}${id}`); + }; + + const appHref = getUrlForApp(`${APP_ID}:${id}`, { path: urlSearch }); + + return { + 'data-href': appHref, + 'data-test-subj': `navigation-${id}`, + disabled, + href: appHref, + id, + isSelected, + name, + onClick: handleClick, + }; + }); + + return [ + { + id: APP_ID, // TODO: When separating into sub-sections (detect, explore, investigate). Those names can also serve as the section id + items: navItems, + name: '', + }, + ]; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx new file mode 100644 index 00000000000000..390f44b48b0b17 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getOr } from 'lodash/fp'; +import { useEffect, useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { PrimaryNavigationProps } from './types'; +import { usePrimaryNavigationItems } from './use_navigation_items'; +import { KibanaPageTemplateProps } from '../../../../../../../../src/plugins/kibana_react/public'; + +const translatedNavTitle = i18n.translate('xpack.securitySolution.navigation.mainLabel', { + defaultMessage: 'Security', +}); + +export const usePrimaryNavigation = ({ + filters, + query, + navTabs, + pageName, + savedQuery, + sourcerer, + timeline, + timerange, +}: PrimaryNavigationProps): KibanaPageTemplateProps['solutionNav'] => { + const mapLocationToTab = useCallback( + (): string => + getOr( + '', + 'id', + Object.values(navTabs).find((item) => pageName === item.id && item.pageId == null) + ), + [pageName, navTabs] + ); + + const [selectedTabId, setSelectedTabId] = useState(mapLocationToTab()); + + useEffect(() => { + const currentTabSelected = mapLocationToTab(); + + if (currentTabSelected !== selectedTabId) { + setSelectedTabId(currentTabSelected); + } + + // we do need navTabs in case the selectedTabId appears after initial load (ex. checking permissions for anomalies) + }, [pageName, navTabs, mapLocationToTab, selectedTabId]); + + const navItems = usePrimaryNavigationItems({ + filters, + navTabs, + query, + savedQuery, + selectedTabId, + sourcerer, + timeline, + timerange, + }); + + return { + name: translatedNavTitle, + icon: 'logoSecurity', + items: navItems, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/page/index.tsx b/x-pack/plugins/security_solution/public/common/components/page/index.tsx index 30b89086fb99cb..051c1bd8ae5cb8 100644 --- a/x-pack/plugins/security_solution/public/common/components/page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/page/index.tsx @@ -5,14 +5,10 @@ * 2.0. */ -import { EuiBadge, EuiDescriptionList, EuiFlexGroup, EuiIcon, EuiPage } from '@elastic/eui'; +import { EuiBadge, EuiDescriptionList, EuiFlexGroup, EuiIcon } from '@elastic/eui'; import styled, { createGlobalStyle } from 'styled-components'; -import { - GLOBAL_HEADER_HEIGHT, - FULL_SCREEN_TOGGLED_CLASS_NAME, - SCROLLING_DISABLED_CLASS_NAME, -} from '../../../../common/constants'; +import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; export const SecuritySolutionAppWrapper = styled.div` display: flex; @@ -27,25 +23,6 @@ SecuritySolutionAppWrapper.displayName = 'SecuritySolutionAppWrapper'; and `EuiPopover`, `EuiToolTip` global styles */ export const AppGlobalStyle = createGlobalStyle<{ theme: { eui: { euiColorPrimary: string } } }>` - // fixes double scrollbar on views with EventsTable - #kibana-body { - overflow: hidden; - } - - div.kbnAppWrapper { - background-color: rgba(0,0,0,0); - } - - div.application { - background-color: rgba(0,0,0,0); - - // Security App wrapper - > div { - display: flex; - flex: 1 1 auto; - } - } - .euiPopover__panel.euiPopover__panel-isOpen { z-index: 9900 !important; min-width: 24px; @@ -82,10 +59,6 @@ export const AppGlobalStyle = createGlobalStyle<{ theme: { eui: { euiColorPrimar ${({ theme }) => `background-color: ${theme.eui.euiColorPrimary} !important`}; } - .${SCROLLING_DISABLED_CLASS_NAME} ${SecuritySolutionAppWrapper} { - max-height: calc(100vh - ${GLOBAL_HEADER_HEIGHT}px); - } - /* EuiScreenReaderOnly has a default 1px height and width. These extra pixels were adding additional height to every table row in the alerts table on the @@ -122,96 +95,6 @@ export const DescriptionListStyled = styled(EuiDescriptionList)` DescriptionListStyled.displayName = 'DescriptionListStyled'; -export const PageContainer = styled.div` - display: flex; - flex-direction: column; - align-items: stretch; - background-color: ${(props) => props.theme.eui.euiColorEmptyShade}; - height: 100%; - padding: 1rem; - overflow: hidden; - margin: 0px; -`; - -PageContainer.displayName = 'PageContainer'; - -export const PageContent = styled.div` - flex: 1 1 auto; - height: 100%; - position: relative; - overflow-y: hidden; - background-color: ${(props) => props.theme.eui.euiColorEmptyShade}; - margin-top: 62px; -`; - -PageContent.displayName = 'PageContent'; - -export const FlexPage = styled(EuiPage)` - flex: 1 0 0; -`; - -FlexPage.displayName = 'FlexPage'; - -export const PageHeader = styled.div` - background-color: ${(props) => props.theme.eui.euiColorEmptyShade}; - display: flex; - user-select: none; - padding: 1rem 1rem 0rem 1rem; - width: 100vw; - position: fixed; -`; - -PageHeader.displayName = 'PageHeader'; - -export const FooterContainer = styled.div` - flex: 0; - bottom: 0; - color: #666; - left: 0; - position: fixed; - text-align: left; - user-select: none; - width: 100%; - background-color: #f5f7fa; - padding: 16px; - border-top: 1px solid #d3dae6; -`; - -FooterContainer.displayName = 'FooterContainer'; - -export const PaneScrollContainer = styled.div` - height: 100%; - overflow-y: scroll; - > div:last-child { - margin-bottom: 3rem; - } -`; - -PaneScrollContainer.displayName = 'PaneScrollContainer'; - -export const Pane = styled.div` - height: 100%; - overflow: hidden; - user-select: none; -`; - -Pane.displayName = 'Pane'; - -export const PaneHeader = styled.div` - display: flex; -`; - -PaneHeader.displayName = 'PaneHeader'; - -export const Pane1FlexContent = styled.div` - display: flex; - flex-direction: row; - flex-wrap: wrap; - height: 100%; -`; - -Pane1FlexContent.displayName = 'Pane1FlexContent'; - export const CountBadge = (styled(EuiBadge)` margin-left: 5px; ` as unknown) as typeof EuiBadge; diff --git a/x-pack/plugins/security_solution/public/common/components/page_wrapper/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/page_wrapper/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000000..5da587f23693b8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/page_wrapper/__snapshots__/index.test.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SecuritySolutionPageWrapper it renders 1`] = ` + +

+ Test page +

+
+`; diff --git a/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/page_wrapper/index.test.tsx similarity index 65% rename from x-pack/plugins/security_solution/public/common/components/wrapper_page/index.test.tsx rename to x-pack/plugins/security_solution/public/common/components/page_wrapper/index.test.tsx index 3ec1e44205dd3f..f6ebf2a90abb4f 100644 --- a/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/page_wrapper/index.test.tsx @@ -9,18 +9,18 @@ import { shallow } from 'enzyme'; import React from 'react'; import { TestProviders } from '../../mock'; -import { WrapperPage } from './index'; +import { SecuritySolutionPageWrapper } from './index'; -describe('WrapperPage', () => { +describe('SecuritySolutionPageWrapper', () => { test('it renders', () => { const wrapper = shallow( - +

{'Test page'}

-
+
); - expect(wrapper.find('Memo(WrapperPageComponent)')).toMatchSnapshot(); + expect(wrapper.find('Memo(SecuritySolutionPageWrapperComponent)')).toMatchSnapshot(); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/page_wrapper/index.tsx similarity index 68% rename from x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx rename to x-pack/plugins/security_solution/public/common/components/page_wrapper/index.tsx index a3eb76a2728bf8..82e0ded264b061 100644 --- a/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/page_wrapper/index.tsx @@ -15,30 +15,26 @@ import { gutterTimeline } from '../../lib/helpers'; import { AppGlobalStyle } from '../page/index'; const Wrapper = styled.div` - padding: ${(props) => `${props.theme.eui.paddingSizes.l}`}; - - &.siemWrapperPage--fullHeight { + &.securitySolutionWrapper--fullHeight { height: 100%; display: flex; flex-direction: column; flex: 1 1 auto; } - - &.siemWrapperPage--noPadding { + &.securitySolutionWrapper--noPadding { padding: 0; display: flex; flex-direction: column; flex: 1 1 auto; } - - &.siemWrapperPage--withTimeline { + &.securitySolutionWrapper--withTimeline { padding-bottom: ${gutterTimeline}; } `; Wrapper.displayName = 'Wrapper'; -interface WrapperPageProps { +interface SecuritySolutionPageWrapperProps { children: React.ReactNode; restrictWidth?: boolean | number | string; style?: Record; @@ -46,24 +42,19 @@ interface WrapperPageProps { noTimeline?: boolean; } -const WrapperPageComponent: React.FC = ({ - children, - className, - style, - noPadding, - noTimeline, - ...otherProps -}) => { +const SecuritySolutionPageWrapperComponent: React.FC< + SecuritySolutionPageWrapperProps & CommonProps +> = ({ children, className, style, noPadding, noTimeline, ...otherProps }) => { const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); useEffect(() => { setGlobalFullScreen(false); // exit full screen mode on page load }, [setGlobalFullScreen]); const classes = classNames(className, { - siemWrapperPage: true, - 'siemWrapperPage--noPadding': noPadding, - 'siemWrapperPage--withTimeline': !noTimeline, - 'siemWrapperPage--fullHeight': globalFullScreen, + securitySolutionWrapper: true, + 'securitySolutionWrapper--noPadding': noPadding, + 'securitySolutionWrapper--withTimeline': !noTimeline, + 'securitySolutionWrapper--fullHeight': globalFullScreen, }); return ( @@ -74,4 +65,4 @@ const WrapperPageComponent: React.FC = ({ ); }; -export const WrapperPage = React.memo(WrapperPageComponent); +export const SecuritySolutionPageWrapper = React.memo(SecuritySolutionPageWrapperComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/panel/index.tsx b/x-pack/plugins/security_solution/public/common/components/panel/index.tsx index 652d22409cb0c3..802fd4c7f44a60 100644 --- a/x-pack/plugins/security_solution/public/common/components/panel/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/panel/index.tsx @@ -25,7 +25,7 @@ import { EuiPanel } from '@elastic/eui'; * Ref: https://www.styled-components.com/docs/faqs#why-am-i-getting-html-attribute-warnings * Ref: https://reactjs.org/blog/2017/09/08/dom-attributes-in-react-16.html */ -export const Panel = styled(({ loading, ...props }) => )` +export const Panel = styled(({ loading, ...props }) => )` position: relative; ${({ loading }) => loading && diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx b/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx index 5b4a8f67aa3617..2d8d55a5c943f3 100644 --- a/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx @@ -222,7 +222,7 @@ export const StatItemsComponent = React.memo( return ( - + diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx index a2d5076031328c..8a7c6bcb4a9b52 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx @@ -29,7 +29,6 @@ import { SecurityPageName } from '../../../../common/constants'; export const dispatchSetInitialStateFromUrl = ( dispatch: Dispatch ): DispatchSetInitialStateFromUrl => ({ - detailName, filterManager, indexPattern, pageName, diff --git a/x-pack/plugins/security_solution/public/common/components/wrapper_page/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/wrapper_page/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 89ed2f45a6bf1f..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/wrapper_page/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`WrapperPage it renders 1`] = ` - -

- Test page -

-
-`; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_global_header_portal.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_global_header_portal.tsx index 5b5877a4c2dedc..8e8d73ff12849e 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_global_header_portal.tsx +++ b/x-pack/plugins/security_solution/public/common/hooks/use_global_header_portal.tsx @@ -11,10 +11,10 @@ import { createPortalNode } from 'react-reverse-portal'; /** * A singleton portal for rendering content in the global header */ -const globalHeaderPortalNodeSingleton = createPortalNode(); +const globalKQLHeaderPortalNodeSingleton = createPortalNode(); export const useGlobalHeaderPortal = () => { - const [globalHeaderPortalNode] = useState(globalHeaderPortalNodeSingleton); + const [globalKQLHeaderPortalNode] = useState(globalKQLHeaderPortalNodeSingleton); - return { globalHeaderPortalNode }; + return { globalKQLHeaderPortalNode }; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/index.tsx index 91b5a106844054..d766104e356ebb 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/index.tsx @@ -298,7 +298,7 @@ export const AlertsHistogramPanel = memo( return ( - + = ({ if (loading || indexPatternsLoading || isEmpty(selectedPatterns)) { return ( - + diff --git a/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.tsx b/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.tsx index fd0be8e0021933..3b41c9280998b3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.tsx @@ -6,6 +6,7 @@ */ import React, { memo } from 'react'; +import { EuiSpacer } from '@elastic/eui'; import { CallOutMessage, CallOutPersistentSwitcher } from '../../../../common/components/callouts'; import { useUserData } from '../../user_info'; @@ -33,20 +34,22 @@ const needAdminForUpdateRulesMessage: CallOutMessage = { * hasIndexManage is also true, then the user should be performing the update on the page which is * why we do not show it for that condition. */ -const NeedAdminForUpdateCallOutComponent = (): JSX.Element => { +const NeedAdminForUpdateCallOutComponent = (): JSX.Element | null => { const [{ signalIndexMappingOutdated, hasIndexManage }] = useUserData(); const signalIndexMappingIsOutdated = signalIndexMappingOutdated != null && signalIndexMappingOutdated; const userDoesntHaveIndexManage = hasIndexManage != null && !hasIndexManage; - - return ( - - ); + const shouldShowCallout = signalIndexMappingIsOutdated && userDoesntHaveIndexManage; + + // Passing shouldShowCallout to the condition param will end up with an unecessary spacer being rendered + return shouldShowCallout ? ( + <> + + + + ) : null; }; export const NeedAdminForUpdateRulesCallOut = memo(NeedAdminForUpdateCallOutComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/callouts/no_api_integration_callout/index.tsx b/x-pack/plugins/security_solution/public/detections/components/callouts/no_api_integration_callout/index.tsx index f21c66380f30aa..7b483930db5053 100644 --- a/x-pack/plugins/security_solution/public/detections/components/callouts/no_api_integration_callout/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/callouts/no_api_integration_callout/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiCallOut, EuiButton } from '@elastic/eui'; +import { EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui'; import React, { memo, useCallback, useState } from 'react'; import * as i18n from './translations'; @@ -15,12 +15,15 @@ const NoApiIntegrationKeyCallOutComponent = () => { const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]); return showCallOut ? ( - -

{i18n.NO_API_INTEGRATION_KEY_CALLOUT_MSG}

- - {i18n.DISMISS_CALLOUT} - -
+ <> + +

{i18n.NO_API_INTEGRATION_KEY_CALLOUT_MSG}

+ + {i18n.DISMISS_CALLOUT} + +
+ + ) : null; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.tsx index a09afa3ca21642..c1078e1ba77e7c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.tsx @@ -82,7 +82,7 @@ const StepAboutRuleToggleDetailsComponent: React.FC = ({ ); return ( - + {loading && ( <> diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_panel/index.tsx index f9e6031d826caf..ac9a153ad76bff 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_panel/index.tsx @@ -24,7 +24,7 @@ const MyPanel = styled(EuiPanel)` MyPanel.displayName = 'MyPanel'; const StepPanelComponent: React.FC = ({ children, loading, title }) => ( - + {loading && } {children} diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx index dbad1c57fda77d..3d81735122e731 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx @@ -216,7 +216,7 @@ export const ValueListsModalComponent: React.FC = ({ - +

{i18n.TABLE_TITLE}

diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index 1c31dfd3b89078..0c12d8256d66d2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -22,7 +22,7 @@ import { UpdateDateRange } from '../../../common/components/charts/common'; import { FiltersGlobal } from '../../../common/components/filters_global'; import { getRulesUrl } from '../../../common/components/link_to/redirect_to_detection_engine'; import { SiemSearchBar } from '../../../common/components/search_bar'; -import { WrapperPage } from '../../../common/components/wrapper_page'; +import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper'; import { inputsSelectors } from '../../../common/store/inputs'; import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; import { SpyRoute } from '../../../common/utils/route/spy_routes'; @@ -197,22 +197,22 @@ const DetectionEnginePageComponent = () => { if (isUserAuthenticated != null && !isUserAuthenticated && !loading) { return ( - + - + ); } if (!loading && (isSignalIndexExists === false || needsListsConfiguration)) { return ( - + - + ); } @@ -228,7 +228,7 @@ const DetectionEnginePageComponent = () => { - + { onShowOnlyThreatIndicatorAlertsChanged={onShowOnlyThreatIndicatorAlertsCallback} to={to} /> - + ) : ( - + - + )} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx index 90247d19e05039..23edf785a7f3a2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx @@ -26,7 +26,7 @@ import { getRuleDetailsUrl, getRulesUrl, } from '../../../../../common/components/link_to/redirect_to_detection_engine'; -import { WrapperPage } from '../../../../../common/components/wrapper_page'; +import { SecuritySolutionPageWrapper } from '../../../../../common/components/page_wrapper'; import { displaySuccessToast, useStateToaster } from '../../../../../common/components/toasters'; import { SpyRoute } from '../../../../../common/utils/route/spy_routes'; import { useUserData } from '../../../../components/user_info'; @@ -287,7 +287,7 @@ const CreateRulePageComponent: React.FC = () => { return ( <> - + { text: i18n.BACK_TO_RULES, pageId: SecurityPageName.detections, }} - border isLoading={isLoading || loading} title={i18n.PAGE_TITLE} /> - + { - + { - + { - + { - + diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/failure_history.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/failure_history.tsx index 417e1c989ce9b4..2fedd6160af2c6 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/failure_history.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/failure_history.tsx @@ -29,7 +29,7 @@ const FailureHistoryComponent: React.FC = ({ id }) => { const [loading, ruleStatus] = useRuleStatus(id); if (loading) { return ( - + @@ -60,7 +60,7 @@ const FailureHistoryComponent: React.FC = ({ id }) => { }, ]; return ( - + { - + { /> )} {ruleDetailTab === RuleDetailTabs.failures && } - + ) : ( - + - + )} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx index 2d751459eb12fd..41710a822e5394 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx @@ -21,7 +21,7 @@ import { useParams, useHistory } from 'react-router-dom'; import { UpdateRulesSchema } from '../../../../../../common/detection_engine/schemas/request'; import { useRule, useUpdateRule } from '../../../../containers/detection_engine/rules'; import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config'; -import { WrapperPage } from '../../../../../common/components/wrapper_page'; +import { SecuritySolutionPageWrapper } from '../../../../../common/components/page_wrapper'; import { getRuleDetailsUrl, getDetectionEngineUrl, @@ -335,7 +335,7 @@ const EditRulePageComponent: FC = () => { return ( <> - + {
- + diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx index 8bacb10444a7d0..29fd8e2e8b247c 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx @@ -16,7 +16,7 @@ import { getCreateRuleUrl, } from '../../../../common/components/link_to/redirect_to_detection_engine'; import { DetectionEngineHeaderPage } from '../../../components/detection_engine_header_page'; -import { WrapperPage } from '../../../../common/components/wrapper_page'; +import { SecuritySolutionPageWrapper } from '../../../../common/components/page_wrapper'; import { SpyRoute } from '../../../../common/utils/route/spy_routes'; import { useUserData } from '../../../components/user_info'; @@ -182,7 +182,7 @@ const RulesPageComponent: React.FC = () => { subtitle={i18n.INITIAL_PROMPT_TEXT} title={i18n.IMPORT_RULE} /> - + { rulesNotUpdated={rulesNotUpdated} setRefreshRulesData={handleSetRefreshRulesData} /> - + diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx index d88e4f048f917a..22edd2c19d6bd2 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx @@ -21,11 +21,11 @@ import { hostToCriteria } from '../../../common/components/ml/criteria/host_to_c import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions'; import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; import { scoreIntervalToDateTime } from '../../../common/components/ml/score/score_interval_to_datetime'; -import { SiemNavigation } from '../../../common/components/navigation'; +import { SecuritySolutionTabNavigation } from '../../../common/components/navigation'; import { HostsDetailsKpiComponent } from '../../components/kpi_hosts'; import { HostOverview } from '../../../overview/components/host_overview'; import { SiemSearchBar } from '../../../common/components/search_bar'; -import { WrapperPage } from '../../../common/components/wrapper_page'; +import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper'; import { useGlobalTime } from '../../../common/containers/use_global_time'; import { useKibana } from '../../../common/lib/kibana'; import { convertToBuildEsQuery } from '../../../common/lib/keury'; @@ -123,7 +123,7 @@ const HostDetailsComponent: React.FC = ({ detailName, hostDeta - + = ({ detailName, hostDeta - @@ -207,14 +207,14 @@ const HostDetailsComponent: React.FC = ({ detailName, hostDeta indexPattern={indexPattern} setAbsoluteRangeDatePicker={setAbsoluteRangeDatePicker} /> - + ) : ( - + - + )} diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx index f1eab38c56db0a..d05b091381cca7 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx @@ -18,7 +18,7 @@ import { kibanaObservable, createSecuritySolutionStorageMock, } from '../../common/mock'; -import { SiemNavigation } from '../../common/components/navigation'; +import { SecuritySolutionTabNavigation } from '../../common/components/navigation'; import { inputsActions } from '../../common/store/inputs'; import { State, createStore } from '../../common/store'; import { Hosts } from './hosts'; @@ -102,7 +102,7 @@ describe('Hosts - rendering', () => { ); - expect(wrapper.find(SiemNavigation).exists()).toBe(true); + expect(wrapper.find(SecuritySolutionTabNavigation).exists()).toBe(true); }); test('it should add the new filters after init', async () => { diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx index ce0385b532fd5a..7d31d291e75f17 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx @@ -19,10 +19,10 @@ import { FiltersGlobal } from '../../common/components/filters_global'; import { HeaderPage } from '../../common/components/header_page'; import { LastEventTime } from '../../common/components/last_event_time'; import { hasMlUserPermissions } from '../../../common/machine_learning/has_ml_user_permissions'; -import { SiemNavigation } from '../../common/components/navigation'; +import { SecuritySolutionTabNavigation } from '../../common/components/navigation'; import { HostsKpiComponent } from '../components/kpi_hosts'; import { SiemSearchBar } from '../../common/components/search_bar'; -import { WrapperPage } from '../../common/components/wrapper_page'; +import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { useGlobalFullScreen } from '../../common/containers/use_full_screen'; import { useGlobalTime } from '../../common/containers/use_global_time'; import { TimelineId } from '../../../common/types/timeline'; @@ -164,10 +164,9 @@ const HostsComponent = () => { - + { - + @@ -207,14 +208,14 @@ const HostsComponent = () => { from={from} type={hostsModel.HostsType.page} /> - + ) : ( - + - + )} diff --git a/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts b/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts index 76acff7847671f..3bcbd81621588b 100644 --- a/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts +++ b/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts @@ -11,7 +11,7 @@ import { AdministrationSubTab } from '../types'; import { ENDPOINTS_TAB, EVENT_FILTERS_TAB, POLICIES_TAB, TRUSTED_APPS_TAB } from './translations'; import { AdministrationRouteSpyState } from '../../common/utils/route/types'; import { GetUrlForApp } from '../../common/components/navigation/types'; -import { ADMINISTRATION } from '../../app/home/translations'; +import { ADMINISTRATION } from '../../app/translations'; import { APP_ID, SecurityPageName } from '../../../common/constants'; const TabNameMappedToI18nKey: Record = { diff --git a/x-pack/plugins/security_solution/public/management/components/administration_list_page.tsx b/x-pack/plugins/security_solution/public/management/components/administration_list_page.tsx index 72a6de2a2de8d1..021c900824f8df 100644 --- a/x-pack/plugins/security_solution/public/management/components/administration_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/components/administration_list_page.tsx @@ -9,9 +9,9 @@ import React, { FC, memo } from 'react'; import { EuiPanel, EuiSpacer, CommonProps } from '@elastic/eui'; import styled from 'styled-components'; import { SecurityPageName } from '../../../common/constants'; -import { WrapperPage } from '../../common/components/wrapper_page'; +import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { HeaderPage } from '../../common/components/header_page'; -import { SiemNavigation } from '../../common/components/navigation'; +import { SecuritySolutionTabNavigation } from '../../common/components/navigation'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { AdministrationSubTab } from '../types'; import { @@ -46,7 +46,7 @@ export const AdministrationListPage: FC + - - {children} + {children} - + ); } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx index 204c3a86ce3e69..e9cdd16554f33b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx @@ -42,7 +42,7 @@ import { useFormatUrl } from '../../../../common/components/link_to'; import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; import { MANAGEMENT_APP_ID } from '../../../common/constants'; import { PolicyDetailsRouteState } from '../../../../../common/endpoint/types'; -import { WrapperPage } from '../../../../common/components/wrapper_page'; +import { SecuritySolutionPageWrapper } from '../../../../common/components/page_wrapper'; import { HeaderPage } from '../../../../common/components/header_page'; import { PolicyDetailsForm } from './policy_details_form'; @@ -51,7 +51,7 @@ const PolicyDetailsHeader = styled.div` padding: ${(props) => props.theme.eui.paddingSizes.xl} 0; background-color: #fafbfd; border-bottom: 1px solid #d3dae6; - .siemHeaderPage { + .securitySolutionHeaderPage { max-width: ${maxFormWidth}; margin: 0 auto; } @@ -159,7 +159,7 @@ export const PolicyDetails = React.memo(() => { // Else, if we have an error, then show error on the page. if (!policyItem) { return ( - + {isPolicyLoading ? ( ) : policyApiError ? ( @@ -168,7 +168,7 @@ export const PolicyDetails = React.memo(() => { ) : null} - + ); } @@ -190,7 +190,7 @@ export const PolicyDetails = React.memo(() => { onConfirm={handleSaveConfirmation} /> )} - { - + diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap index e984ea5bb1711f..51b60c8ff292be 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap @@ -427,7 +427,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` class="body-content undefined" >

diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/embeddable.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embeddable.tsx index 82b5b8a3e7b3db..3087dbe4ad6edc 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/embeddable.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/embeddable.tsx @@ -20,7 +20,9 @@ export interface EmbeddableProps { export const Embeddable = React.memo(({ children }) => (

- {children} + + {children} +
)); Embeddable.displayName = 'Embeddable'; diff --git a/x-pack/plugins/security_solution/public/network/pages/details/index.tsx b/x-pack/plugins/security_solution/public/network/pages/details/index.tsx index 4cccb536c08bbd..02be5f78261c1d 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/index.tsx @@ -28,7 +28,7 @@ import { manageQuery } from '../../../common/components/page/manage_query'; import { FlowTargetSelectConnected } from '../../components/flow_target_select_connected'; import { IpOverview } from '../../components/details'; import { SiemSearchBar } from '../../../common/components/search_bar'; -import { WrapperPage } from '../../../common/components/wrapper_page'; +import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper'; import { useNetworkDetails } from '../../containers/details'; import { useKibana } from '../../../common/lib/kibana'; import { decodeIpv6 } from '../../../common/lib/helpers'; @@ -128,7 +128,7 @@ const NetworkDetailsComponent: React.FC = () => { - + { hideHistogramIfEmpty={true} AnomaliesTableComponent={AnomaliesNetworkTable} /> - + ) : ( - + - + )} diff --git a/x-pack/plugins/security_solution/public/network/pages/network.tsx b/x-pack/plugins/security_solution/public/network/pages/network.tsx index dbfb250095ee26..13c04a5e5ec5b7 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.tsx @@ -20,11 +20,11 @@ import { EmbeddedMap } from '../components/embeddables/embedded_map'; import { FiltersGlobal } from '../../common/components/filters_global'; import { HeaderPage } from '../../common/components/header_page'; import { LastEventTime } from '../../common/components/last_event_time'; -import { SiemNavigation } from '../../common/components/navigation'; +import { SecuritySolutionTabNavigation } from '../../common/components/navigation'; import { NetworkKpiComponent } from '../components/kpi_network'; import { SiemSearchBar } from '../../common/components/search_bar'; -import { WrapperPage } from '../../common/components/wrapper_page'; +import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { useGlobalFullScreen } from '../../common/containers/use_full_screen'; import { useGlobalTime } from '../../common/containers/use_global_time'; import { LastEventIndexKey } from '../../../common/search_strategy'; @@ -155,10 +155,9 @@ const NetworkComponent = React.memo( - + ( - + @@ -217,13 +216,13 @@ const NetworkComponent = React.memo( ) : ( )} - + ) : ( - + - + )} diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx index 70f44a0008cbc4..f11b849f5df6b4 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx @@ -115,7 +115,7 @@ const OverviewHostComponent: React.FC = ({ return ( - + <>{hostPageButton} diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx index 107a47f6cc1324..39fb6ff08ee539 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx @@ -120,7 +120,7 @@ const OverviewNetworkComponent: React.FC = ({ return ( - + <> {networkPageButton} diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index 4270d8ec164b30..2cf998e5e133a4 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -12,7 +12,7 @@ import styled from 'styled-components'; import { AlertsByCategory } from '../components/alerts_by_category'; import { FiltersGlobal } from '../../common/components/filters_global'; import { SiemSearchBar } from '../../common/components/search_bar'; -import { WrapperPage } from '../../common/components/wrapper_page'; +import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { useGlobalTime } from '../../common/containers/use_global_time'; import { useFetchIndex } from '../../common/containers/source'; @@ -37,6 +37,10 @@ const SidebarFlexItem = styled(EuiFlexItem)` margin-right: 24px; `; +const StyledSecuritySolutionPageWrapper = styled(SecuritySolutionPageWrapper)` + overflow-x: auto; +`; + const OverviewComponent = () => { const getGlobalFiltersQuerySelector = useMemo( () => inputsSelectors.globalFiltersQuerySelector(), @@ -73,7 +77,7 @@ const OverviewComponent = () => { - + {!dismissMessage && !metadataIndexExists && isIngestEnabled && ( <> @@ -139,7 +143,7 @@ const OverviewComponent = () => { - + ) : ( diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 5a44faa58414a1..32e6748f38141c 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -61,7 +61,7 @@ import { DETECTION_ENGINE, CASE, ADMINISTRATION, -} from './app/home/translations'; +} from './app/translations'; import { IndexFieldsStrategyRequest, IndexFieldsStrategyResponse, diff --git a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx index 45f7e6950b0069..1f520a18470536 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx @@ -207,7 +207,7 @@ export const GraphControls = React.memo( /> - +
- +
); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx index a78f6adeca39fc..0f0cec0fbfcffd 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx @@ -41,8 +41,6 @@ export function FilterExpanded({ isNegated, filters: defaultFilters, }: Props) { - const { indexPattern } = useAppIndexPatternContext(); - const [value, setValue] = useState(''); const [isOpen, setIsOpen] = useState({ value: '', negate: false }); @@ -53,23 +51,25 @@ export function FilterExpanded({ const queryFilters: ESFilter[] = []; + const { indexPatterns } = useAppIndexPatternContext(series.dataType); + defaultFilters?.forEach((qFilter: PersistableFilter | ExistsFilter) => { if (qFilter.query) { queryFilters.push(qFilter.query); } const asExistFilter = qFilter as ExistsFilter; if (asExistFilter?.exists) { - queryFilters.push(asExistFilter.exists as QueryDslQueryContainer); + queryFilters.push({ exists: asExistFilter.exists } as QueryDslQueryContainer); } }); const { values, loading } = useValuesList({ query: value, - indexPatternTitle: indexPattern?.title, sourceField: field, time: series.time, keepHistory: true, filters: queryFilters, + indexPatternTitle: indexPatterns[series.dataType]?.title, }); const filters = series?.filters ?? []; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx index 79eb858b7624b9..c1790fea8c0c4a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx @@ -139,7 +139,7 @@ describe('FilterValueButton', function () { /> ); - expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(2); expect(spy).toBeCalledWith( expect.objectContaining({ filters: [ @@ -170,7 +170,7 @@ describe('FilterValueButton', function () { /> ); - expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledTimes(6); expect(spy).toBeCalledWith( expect.objectContaining({ filters: [ diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx index f04295a90e475f..bf4ca6eb83d94c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx @@ -41,7 +41,7 @@ export function FilterValueButton({ const series = getSeries(seriesId); - const { indexPattern } = useAppIndexPatternContext(); + const { indexPatterns } = useAppIndexPatternContext(series.dataType); const { setFilter, removeFilter } = useSeriesFilters({ seriesId }); @@ -96,7 +96,6 @@ export function FilterValueButton({ ) : ( button diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx index dc84352ff3b3da..e75f308dab1e59 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx @@ -26,9 +26,9 @@ export function RemoveSeries({ seriesId }: Props) { defaultMessage: 'Click to remove series', })} iconType="cross" - color="primary" + color="danger" onClick={onClick} - size="m" + size="s" /> ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx index 086a1d4341bbc6..51ebe6c6bd9d51 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx @@ -8,33 +8,93 @@ import React from 'react'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { isEmpty } from 'lodash'; import { RemoveSeries } from './remove_series'; -import { NEW_SERIES_KEY, useSeriesStorage } from '../../hooks/use_series_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { SeriesUrl } from '../../types'; interface Props { seriesId: string; + editorMode?: boolean; } -export function SeriesActions({ seriesId }: Props) { - const { getSeries, removeSeries, setSeries } = useSeriesStorage(); +export function SeriesActions({ seriesId, editorMode = false }: Props) { + const { getSeries, setSeries, allSeriesIds, removeSeries } = useSeriesStorage(); const series = getSeries(seriesId); const onEdit = () => { - removeSeries(seriesId); - setSeries(NEW_SERIES_KEY, { ...series }); + setSeries(seriesId, { ...series, isNew: true }); + }; + + const copySeries = () => { + let copySeriesId: string = `${seriesId}-copy`; + if (allSeriesIds.includes(copySeriesId)) { + copySeriesId = copySeriesId + allSeriesIds.length; + } + setSeries(copySeriesId, series); + }; + + const { reportType, reportDefinitions, isNew, ...restSeries } = series; + const isSaveAble = reportType && !isEmpty(reportDefinitions); + + const saveSeries = () => { + if (isSaveAble) { + const reportDefId = Object.values(reportDefinitions ?? {})[0]; + let newSeriesId = `${reportDefId}-${reportType}`; + + if (allSeriesIds.includes(newSeriesId)) { + newSeriesId = `${newSeriesId}-${allSeriesIds.length}`; + } + const newSeriesN: SeriesUrl = { + ...restSeries, + reportType, + reportDefinitions, + }; + + setSeries(newSeriesId, newSeriesN); + removeSeries(seriesId); + } }; return ( - - - - + + {!editorMode && ( + + + + )} + {editorMode && ( + + + + )} + {editorMode && ( + + + + )} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.test.tsx index 8363b6b0eadfdb..61081e7cc6f468 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.test.tsx @@ -16,7 +16,7 @@ describe('SelectedFilters', function () { mockAppIndexPattern(); const dataViewSeries = getDefaultConfigs({ - reportType: 'dist', + reportType: 'data-distribution', indexPattern: mockIndexPattern, dataType: 'ux', }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx index 63abb581c9c723..33496e617a3a67 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx @@ -39,7 +39,7 @@ export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props) const { removeFilter } = useSeriesFilters({ seriesId }); - const { indexPattern } = useAppIndexPatternContext(); + const { indexPattern } = useAppIndexPatternContext(series.dataType); return (filters.length > 0 || definitionFilters.length > 0) && indexPattern ? ( @@ -55,6 +55,7 @@ export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props) value={val} removeFilter={() => removeFilter({ field, value: val, negate: false })} negate={false} + indexPattern={indexPattern} /> ))} @@ -67,6 +68,7 @@ export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props) value={val} negate={true} removeFilter={() => removeFilter({ field, value: val, negate: true })} + indexPattern={indexPattern} /> ))} @@ -87,6 +89,7 @@ export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props) }} negate={false} definitionFilter={true} + indexPattern={indexPattern} /> ))} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx index 17d4356dcf65bf..bcceeb204a31e7 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx @@ -24,7 +24,7 @@ interface EditItem { } export function SeriesEditor() { - const { allSeries, firstSeriesId } = useSeriesStorage(); + const { allSeries, allSeriesIds } = useSeriesStorage(); const columns = [ { @@ -33,80 +33,77 @@ export function SeriesEditor() { }), field: 'id', width: '15%', - render: (val: string) => ( + render: (seriesId: string) => ( {' '} - {val === NEW_SERIES_KEY ? 'series-preview' : val} + {seriesId === NEW_SERIES_KEY ? 'series-preview' : seriesId} ), }, - ...(firstSeriesId !== NEW_SERIES_KEY - ? [ - { - name: i18n.translate('xpack.observability.expView.seriesEditor.filters', { - defaultMessage: 'Filters', - }), - field: 'defaultFilters', - width: '15%', - render: (defaultFilters: string[], { id, seriesConfig }: EditItem) => ( - - ), - }, - { - name: i18n.translate('xpack.observability.expView.seriesEditor.breakdowns', { - defaultMessage: 'Breakdowns', - }), - field: 'breakdowns', - width: '25%', - render: (val: string[], item: EditItem) => ( - - ), - }, - { - name: ( -
- -
- ), - width: '20%', - field: 'id', - align: 'right' as const, - render: (val: string, item: EditItem) => , - }, - { - name: i18n.translate('xpack.observability.expView.seriesEditor.actions', { - defaultMessage: 'Actions', - }), - align: 'center' as const, - width: '10%', - field: 'id', - render: (val: string, item: EditItem) => , - }, - ] - : []), + { + name: i18n.translate('xpack.observability.expView.seriesEditor.filters', { + defaultMessage: 'Filters', + }), + field: 'defaultFilters', + width: '15%', + render: (seriesId: string, { seriesConfig, id }: EditItem) => ( + + ), + }, + { + name: i18n.translate('xpack.observability.expView.seriesEditor.breakdowns', { + defaultMessage: 'Breakdowns', + }), + field: 'id', + width: '25%', + render: (seriesId: string, { seriesConfig, id }: EditItem) => ( + + ), + }, + { + name: ( +
+ +
+ ), + width: '20%', + field: 'id', + align: 'right' as const, + render: (seriesId: string, item: EditItem) => , + }, + { + name: i18n.translate('xpack.observability.expView.seriesEditor.actions', { + defaultMessage: 'Actions', + }), + align: 'center' as const, + width: '10%', + field: 'id', + render: (seriesId: string, item: EditItem) => , + }, ]; - const allSeriesKeys = Object.keys(allSeries); - + const { indexPatterns } = useAppIndexPatternContext(); const items: EditItem[] = []; - const { indexPattern } = useAppIndexPatternContext(); - - allSeriesKeys.forEach((seriesKey) => { + allSeriesIds.forEach((seriesKey) => { const series = allSeries[seriesKey]; - if (series.reportType && indexPattern) { + if (series?.reportType && indexPatterns[series.dataType] && !series.isNew) { items.push({ id: seriesKey, seriesConfig: getDefaultConfigs({ - indexPattern, + indexPattern: indexPatterns[series.dataType], reportType: series.reportType, dataType: series.dataType, }), @@ -114,6 +111,10 @@ export function SeriesEditor() { } }); + if (items.length === 0 && allSeriesIds.length > 0) { + return null; + } + return ( <> @@ -121,8 +122,7 @@ export function SeriesEditor() { items={items} rowHeader="firstName" columns={columns} - rowProps={() => (firstSeriesId === NEW_SERIES_KEY ? {} : { height: 100 })} - noItemsMessage={i18n.translate('xpack.observability.expView.seriesEditor.notFound', { + noItemsMessage={i18n.translate('xpack.observability.expView.seriesEditor.seriesNotFound', { defaultMessage: 'No series found, please add a series.', })} cellProps={{ diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts index 73b4d7794dd513..e8fccc5baab341 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts @@ -23,7 +23,7 @@ export const ReportViewTypes = { dist: 'data-distribution', kpi: 'kpi-over-time', cwv: 'core-web-vitals', - mdd: 'mobile-device-distribution', + mdd: 'device-data-distribution', } as const; type ValueOf = T[keyof T]; @@ -56,7 +56,6 @@ export interface DataSeries { reportType: ReportViewType; xAxisColumn: Partial | Partial; yAxisColumns: Array>; - breakdowns: string[]; defaultSeriesType: SeriesType; defaultFilters: Array; @@ -80,10 +79,11 @@ export interface SeriesUrl { breakdown?: string; filters?: UrlFilter[]; seriesType?: SeriesType; - reportType: ReportViewTypeId; + reportType: ReportViewType; operationType?: OperationType; dataType: AppDataType; reportDefinitions?: URLReportDefinition; + isNew?: boolean; } export interface UrlFilter { @@ -94,6 +94,7 @@ export interface UrlFilter { export interface ConfigProps { indexPattern: IIndexPattern; + series?: SeriesUrl; } export type AppDataType = 'synthetics' | 'ux' | 'infra_logs' | 'infra_metrics' | 'apm' | 'mobile'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/stringify_kueries.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/stringify_kueries.test.ts new file mode 100644 index 00000000000000..fe545fff5498d7 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/stringify_kueries.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { urlFiltersToKueryString } from './stringify_kueries'; +import { UrlFilter } from '../types'; +import { USER_AGENT_NAME } from '../configurations/constants/elasticsearch_fieldnames'; + +describe('stringifyKueries', () => { + let filters: UrlFilter[]; + beforeEach(() => { + filters = [ + { + field: USER_AGENT_NAME, + values: ['Chrome', 'Firefox'], + notValues: [], + }, + ]; + }); + + it('stringifies the current values', () => { + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot( + `"user_agent.name: (\\"Chrome\\" or \\"Firefox\\")"` + ); + }); + + it('correctly stringifies a single value', () => { + filters = [ + { + field: USER_AGENT_NAME, + values: ['Chrome'], + notValues: [], + }, + ]; + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot( + `"user_agent.name: (\\"Chrome\\")"` + ); + }); + + it('returns an empty string for an empty array', () => { + expect(urlFiltersToKueryString([])).toMatchInlineSnapshot(`""`); + }); + + it('returns an empty string for an empty value', () => { + filters = [ + { + field: USER_AGENT_NAME, + values: [], + notValues: [], + }, + ]; + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot(`""`); + }); + + it('adds quotations if the value contains a space', () => { + filters = [ + { + field: USER_AGENT_NAME, + values: ['Google Chrome'], + notValues: [], + }, + ]; + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot( + `"user_agent.name: (\\"Google Chrome\\")"` + ); + }); + + it('adds quotations inside parens if there are values containing spaces', () => { + filters = [ + { + field: USER_AGENT_NAME, + values: ['Google Chrome'], + notValues: ['Apple Safari'], + }, + ]; + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot( + `"user_agent.name: (\\"Google Chrome\\") and not (user_agent.name: (\\"Apple Safari\\"))"` + ); + }); + + it('handles parens for values with greater than 2 items', () => { + filters = [ + { + field: USER_AGENT_NAME, + values: ['Chrome', 'Firefox', 'Safari', 'Opera'], + notValues: ['Safari'], + }, + ]; + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot( + `"user_agent.name: (\\"Chrome\\" or \\"Firefox\\" or \\"Safari\\" or \\"Opera\\") and not (user_agent.name: (\\"Safari\\"))"` + ); + }); + + it('handles colon characters in values', () => { + filters = [ + { + field: 'url', + values: ['https://elastic.co', 'https://example.com'], + notValues: [], + }, + ]; + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot( + `"url: (\\"https://elastic.co\\" or \\"https://example.com\\")"` + ); + }); + + it('handles precending empty array', () => { + filters = [ + { + field: 'url', + values: ['https://elastic.co', 'https://example.com'], + notValues: [], + }, + { + field: USER_AGENT_NAME, + values: [], + }, + ]; + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot( + `"url: (\\"https://elastic.co\\" or \\"https://example.com\\")"` + ); + }); + + it('handles skipped empty arrays', () => { + filters = [ + { + field: 'url', + values: ['https://elastic.co', 'https://example.com'], + notValues: [], + }, + { + field: USER_AGENT_NAME, + values: [], + }, + { + field: 'url', + values: ['https://elastic.co', 'https://example.com'], + notValues: [], + }, + ]; + expect(urlFiltersToKueryString(filters)).toMatchInlineSnapshot( + `"url: (\\"https://elastic.co\\" or \\"https://example.com\\") and url: (\\"https://elastic.co\\" or \\"https://example.com\\")"` + ); + }); +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/stringify_kueries.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/stringify_kueries.ts new file mode 100644 index 00000000000000..8a92c724338ef2 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/stringify_kueries.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { UrlFilter } from '../types'; + +/** + * Extract a map's keys to an array, then map those keys to a string per key. + * The strings contain all of the values chosen for the given field (which is also the key value). + * Reduce the list of query strings to a singular string, with AND operators between. + */ +export const urlFiltersToKueryString = (urlFilters: UrlFilter[]): string => { + let kueryString = ''; + urlFilters.forEach(({ field, values, notValues }) => { + const valuesT = values?.map((val) => `"${val}"`); + const notValuesT = notValues?.map((val) => `"${val}"`); + + if (valuesT && valuesT?.length > 0) { + if (kueryString.length > 0) { + kueryString += ' and '; + } + kueryString += `${field}: (${valuesT.join(' or ')})`; + } + + if (notValuesT && notValuesT?.length > 0) { + if (kueryString.length > 0) { + kueryString += ' and '; + } + kueryString += `not (${field}: (${notValuesT.join(' or ')}))`; + } + }); + + return kueryString; +}; diff --git a/x-pack/plugins/observability/public/routes/index.tsx b/x-pack/plugins/observability/public/routes/index.tsx index 92f51aeff9bd63..f97e3fb996441d 100644 --- a/x-pack/plugins/observability/public/routes/index.tsx +++ b/x-pack/plugins/observability/public/routes/index.tsx @@ -112,4 +112,18 @@ export const routes = { }), }, }, + // enable this to test multi series architecture + // '/exploratory-view/multi': { + // handler: () => { + // return ; + // }, + // params: { + // query: t.partial({ + // rangeFrom: t.string, + // rangeTo: t.string, + // refreshPaused: jsonRt.pipe(t.boolean), + // refreshInterval: jsonRt.pipe(t.number), + // }), + // }, + // }, }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 75bf27f9617139..c6716a1fa77d48 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17256,7 +17256,6 @@ "xpack.observability.expView.seriesEditor.clearFilter": "フィルターを消去", "xpack.observability.expView.seriesEditor.filters": "フィルター", "xpack.observability.expView.seriesEditor.name": "名前", - "xpack.observability.expView.seriesEditor.notFound": "系列が見つかりません。系列を追加してください。", "xpack.observability.expView.seriesEditor.removeSeries": "クリックすると、系列を削除します", "xpack.observability.expView.seriesEditor.time": "時間", "xpack.observability.featureCatalogueDescription": "専用UIで、ログ、メトリック、アプリケーショントレース、システム可用性を連結します。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d3f5b9c4bce8b9..8b654a821d4dc2 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17492,7 +17492,6 @@ "xpack.observability.expView.seriesEditor.clearFilter": "清除筛选", "xpack.observability.expView.seriesEditor.filters": "筛选", "xpack.observability.expView.seriesEditor.name": "名称", - "xpack.observability.expView.seriesEditor.notFound": "未找到序列,请添加序列。", "xpack.observability.expView.seriesEditor.removeSeries": "单击移除序列", "xpack.observability.expView.seriesEditor.time": "时间", "xpack.observability.featureCatalogueDescription": "通过专用 UI 整合您的日志、指标、应用程序跟踪和系统可用性。", diff --git a/x-pack/plugins/uptime/public/components/common/charts/ping_histogram.tsx b/x-pack/plugins/uptime/public/components/common/charts/ping_histogram.tsx index 35161561a23fe2..1a53a2c9b64a0c 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/ping_histogram.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/ping_histogram.tsx @@ -191,7 +191,7 @@ export const PingHistogramComponent: React.FC = ({ { 'pings-over-time': { dataType: 'synthetics', - reportType: 'kpi', + reportType: 'kpi-over-time', time: { from: dateRangeStart, to: dateRangeEnd }, ...(monitorId ? { filters: [{ field: 'monitor.id', values: [monitorId] }] } : {}), }, diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx index da32ffd41853bc..479a512b7238a1 100644 --- a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx @@ -40,10 +40,11 @@ export function ActionMenuContent(): React.ReactElement { const syntheticExploratoryViewLink = createExploratoryViewUrl( { - 'synthetics-series': { + 'synthetics-series': ({ dataType: 'synthetics', + isNew: true, time: { from: dateRangeStart, to: dateRangeEnd }, - } as SeriesUrl, + } as unknown) as SeriesUrl, }, basePath ); diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx index 377d7a8fa35d44..1590e225f9ca84 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx @@ -56,7 +56,7 @@ export const MonitorDuration: React.FC = ({ monitorId }) => { const exploratoryViewLink = createExploratoryViewUrl( { [`monitor-duration`]: { - reportType: 'kpi', + reportType: 'kpi-over-time', time: { from: dateRangeStart, to: dateRangeEnd }, reportDefinitions: { 'monitor.id': [monitorId] as string[], From 3864fe1559281b4a2731e3a9439c501125e65669 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 23 Jun 2021 13:18:37 -0400 Subject: [PATCH 38/61] [Fleet] Add global component template to all fleet index templates (#102225) --- x-pack/plugins/fleet/common/types/index.ts | 1 + .../fleet/public/mock/plugin_configuration.ts | 1 + .../fleet_es_assets.ts} | 32 +++++++++++- .../plugins/fleet/server/constants/index.ts | 7 +++ x-pack/plugins/fleet/server/index.ts | 1 + x-pack/plugins/fleet/server/mocks/index.ts | 12 +++++ .../elasticsearch/ingest_pipeline/install.ts | 30 ++++++----- .../__snapshots__/template.test.ts.snap | 21 ++++---- .../epm/elasticsearch/template/install.ts | 37 ++++++++++++-- .../elasticsearch/template/template.test.ts | 6 ++- .../epm/elasticsearch/template/template.ts | 8 ++- .../fleet/server/services/epm/packages/get.ts | 2 + .../server/services/epm/packages/index.ts | 1 + x-pack/plugins/fleet/server/services/setup.ts | 51 ++++++++++++++++++- .../api_integration/apis/ml/modules/index.ts | 3 ++ .../apis/epm/final_pipeline.ts | 10 ++-- .../apis/epm/install_overrides.ts | 1 + 17 files changed, 182 insertions(+), 42 deletions(-) rename x-pack/plugins/fleet/server/{services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts => constants/fleet_es_assets.ts} (82%) diff --git a/x-pack/plugins/fleet/common/types/index.ts b/x-pack/plugins/fleet/common/types/index.ts index 95f91165aaf94e..59691bf32d0994 100644 --- a/x-pack/plugins/fleet/common/types/index.ts +++ b/x-pack/plugins/fleet/common/types/index.ts @@ -25,6 +25,7 @@ export interface FleetConfigType { }; agentPolicies?: PreconfiguredAgentPolicy[]; packages?: PreconfiguredPackage[]; + agentIdVerificationEnabled?: boolean; } // Calling Object.entries(PackagesGroupedByStatus) gave `status: string` diff --git a/x-pack/plugins/fleet/public/mock/plugin_configuration.ts b/x-pack/plugins/fleet/public/mock/plugin_configuration.ts index 097b6aa98c0674..5dad8ad5049796 100644 --- a/x-pack/plugins/fleet/public/mock/plugin_configuration.ts +++ b/x-pack/plugins/fleet/public/mock/plugin_configuration.ts @@ -12,6 +12,7 @@ export const createConfigurationMock = (): FleetConfigType => { enabled: true, registryUrl: '', registryProxyUrl: '', + agentIdVerificationEnabled: true, agents: { enabled: true, elasticsearch: { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts b/x-pack/plugins/fleet/server/constants/fleet_es_assets.ts similarity index 82% rename from x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts rename to x-pack/plugins/fleet/server/constants/fleet_es_assets.ts index f929a4f139981f..8e9dac11db799f 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts +++ b/x-pack/plugins/fleet/server/constants/fleet_es_assets.ts @@ -5,9 +5,37 @@ * 2.0. */ -export const FINAL_PIPELINE_ID = '.fleet_final_pipeline'; +export const FLEET_FINAL_PIPELINE_ID = '.fleet_final_pipeline-1'; -export const FINAL_PIPELINE = `--- +export const FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME = '.fleet_component_template-1'; + +export const FLEET_GLOBAL_COMPONENT_TEMPLATE_CONTENT = { + _meta: {}, + template: { + settings: { + index: { + final_pipeline: FLEET_FINAL_PIPELINE_ID, + }, + }, + mappings: { + properties: { + event: { + properties: { + ingested: { + type: 'date', + }, + agent_id_status: { + ignore_above: 1024, + type: 'keyword', + }, + }, + }, + }, + }, + }, +}; + +export const FLEET_FINAL_PIPELINE_CONTENT = `--- description: > Final pipeline for processing all incoming Fleet Agent documents. processors: diff --git a/x-pack/plugins/fleet/server/constants/index.ts b/x-pack/plugins/fleet/server/constants/index.ts index 16a92a2ffa1aaa..3aca5e8800dc5a 100644 --- a/x-pack/plugins/fleet/server/constants/index.ts +++ b/x-pack/plugins/fleet/server/constants/index.ts @@ -57,3 +57,10 @@ export { PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, PRECONFIGURATION_LATEST_KEYWORD, } from '../../common'; + +export { + FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME, + FLEET_GLOBAL_COMPONENT_TEMPLATE_CONTENT, + FLEET_FINAL_PIPELINE_ID, + FLEET_FINAL_PIPELINE_CONTENT, +} from './fleet_es_assets'; diff --git a/x-pack/plugins/fleet/server/index.ts b/x-pack/plugins/fleet/server/index.ts index 0a886ffedbd6c0..ab1cd9002d04a8 100644 --- a/x-pack/plugins/fleet/server/index.ts +++ b/x-pack/plugins/fleet/server/index.ts @@ -77,6 +77,7 @@ export const config: PluginConfigDescriptor = { }), packages: PreconfiguredPackagesSchema, agentPolicies: PreconfiguredAgentPoliciesSchema, + agentIdVerificationEnabled: schema.boolean({ defaultValue: true }), }), }; diff --git a/x-pack/plugins/fleet/server/mocks/index.ts b/x-pack/plugins/fleet/server/mocks/index.ts index a94f274b202adf..43a5a14b425b57 100644 --- a/x-pack/plugins/fleet/server/mocks/index.ts +++ b/x-pack/plugins/fleet/server/mocks/index.ts @@ -4,6 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { of } from 'rxjs'; + import { elasticsearchServiceMock, loggingSystemMock, @@ -22,6 +24,14 @@ import type { FleetAppContext } from '../plugin'; export * from '../services/artifacts/mocks'; export const createAppContextStartContractMock = (): FleetAppContext => { + const config = { + agents: { enabled: true, elasticsearch: {} }, + enabled: true, + agentIdVerificationEnabled: true, + }; + + const config$ = of(config); + return { elasticsearch: elasticsearchServiceMock.createStart(), data: dataPluginMock.createStartContract(), @@ -33,7 +43,9 @@ export const createAppContextStartContractMock = (): FleetAppContext => { configInitialValue: { agents: { enabled: true, elasticsearch: {} }, enabled: true, + agentIdVerificationEnabled: true, }, + config$, kibanaVersion: '8.0.0', kibanaBranch: 'master', }; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts index 1d212f188120f4..a6aa87c5ed0f54 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts @@ -14,9 +14,9 @@ import { getAsset, getPathParts } from '../../archive'; import type { ArchiveEntry } from '../../archive'; import { saveInstalledEsRefs } from '../../packages/install'; import { getInstallationObject } from '../../packages'; +import { FLEET_FINAL_PIPELINE_CONTENT, FLEET_FINAL_PIPELINE_ID } from '../../../../constants'; import { deletePipelineRefs } from './remove'; -import { FINAL_PIPELINE, FINAL_PIPELINE_ID } from './final_pipeline'; interface RewriteSubstitution { source: string; @@ -190,22 +190,24 @@ export async function ensureFleetFinalPipelineIsInstalled(esClient: Elasticsearc const esClientRequestOptions: TransportRequestOptions = { ignore: [404], }; - const res = await esClient.ingest.getPipeline({ id: FINAL_PIPELINE_ID }, esClientRequestOptions); + const res = await esClient.ingest.getPipeline( + { id: FLEET_FINAL_PIPELINE_ID }, + esClientRequestOptions + ); if (res.statusCode === 404) { - await esClient.ingest.putPipeline( - // @ts-ignore pipeline is define in yaml - { id: FINAL_PIPELINE_ID, body: FINAL_PIPELINE }, - { - headers: { - // pipeline is YAML - 'Content-Type': 'application/yaml', - // but we want JSON responses (to extract error messages, status code, or other metadata) - Accept: 'application/json', - }, - } - ); + await installPipeline({ + esClient, + pipeline: { + nameForInstallation: FLEET_FINAL_PIPELINE_ID, + contentForInstallation: FLEET_FINAL_PIPELINE_CONTENT, + extension: 'yml', + }, + }); + return { isCreated: true }; } + + return { isCreated: false }; } const isDirectory = ({ path }: ArchiveEntry) => path.endsWith('/'); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap index acf8ae742bf8f1..6a4476316bfa5b 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap @@ -25,8 +25,7 @@ exports[`EPM template tests loading base.yml: base.yml 1`] = ` "default_field": [ "long.nested.foo" ] - }, - "final_pipeline": ".fleet_final_pipeline" + } } }, "mappings": { @@ -99,7 +98,9 @@ exports[`EPM template tests loading base.yml: base.yml 1`] = ` } }, "data_stream": {}, - "composed_of": [], + "composed_of": [ + ".fleet_component_template-1" + ], "_meta": { "package": { "name": "nginx" @@ -140,8 +141,7 @@ exports[`EPM template tests loading coredns.logs.yml: coredns.logs.yml 1`] = ` "coredns.response.code", "coredns.response.flags" ] - }, - "final_pipeline": ".fleet_final_pipeline" + } } }, "mappings": { @@ -214,7 +214,9 @@ exports[`EPM template tests loading coredns.logs.yml: coredns.logs.yml 1`] = ` } }, "data_stream": {}, - "composed_of": [], + "composed_of": [ + ".fleet_component_template-1" + ], "_meta": { "package": { "name": "coredns" @@ -283,8 +285,7 @@ exports[`EPM template tests loading system.yml: system.yml 1`] = ` "system.users.scope", "system.users.remote_host" ] - }, - "final_pipeline": ".fleet_final_pipeline" + } } }, "mappings": { @@ -1741,7 +1742,9 @@ exports[`EPM template tests loading system.yml: system.yml 1`] = ` } }, "data_stream": {}, - "composed_of": [], + "composed_of": [ + ".fleet_component_template-1" + ], "_meta": { "package": { "name": "system" diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index db1fba1eedccde..e8dac60ddba1a9 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -20,6 +20,10 @@ import type { Field } from '../../fields/field'; import { getPipelineNameForInstallation } from '../ingest_pipeline/install'; import { getAsset, getPathParts } from '../../archive'; import { removeAssetTypesFromInstalledEs, saveInstalledEsRefs } from '../../packages/install'; +import { + FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME, + FLEET_GLOBAL_COMPONENT_TEMPLATE_CONTENT, +} from '../../../../constants'; import { generateMappings, @@ -164,7 +168,7 @@ export async function installTemplateForDataStream({ } interface TemplateMapEntry { - _meta: { package: { name: string } }; + _meta: { package?: { name: string } }; template: | { mappings: NonNullable; @@ -277,6 +281,28 @@ async function installDataStreamComponentTemplates(params: { return templateNames; } +export async function ensureDefaultComponentTemplate(esClient: ElasticsearchClient) { + const { body: getTemplateRes } = await esClient.cluster.getComponentTemplate( + { + name: FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME, + }, + { + ignore: [404], + } + ); + + const existingTemplate = getTemplateRes?.component_templates?.[0]; + if (!existingTemplate) { + await putComponentTemplate(esClient, { + name: FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME, + body: FLEET_GLOBAL_COMPONENT_TEMPLATE_CONTENT, + create: true, + }); + } + + return { isCreated: !existingTemplate }; +} + export async function installTemplate({ esClient, fields, @@ -378,12 +404,13 @@ export function getAllTemplateRefs(installedTemplates: IndexTemplateEntry[]) { type: ElasticsearchAssetType.indexTemplate, }, ]; - const componentTemplates = installedTemplate.indexTemplate.composed_of.map( - (componentTemplateId) => ({ + const componentTemplates = installedTemplate.indexTemplate.composed_of + // Filter global component template shared between integrations + .filter((componentTemplateId) => componentTemplateId !== FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME) + .map((componentTemplateId) => ({ id: componentTemplateId, type: ElasticsearchAssetType.componentTemplate, - }) - ); + })); return indexTemplates.concat(componentTemplates); }); } diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index ae7bff618dba2a..d1f806f67ca5c2 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -24,6 +24,8 @@ import { generateTemplateIndexPattern, } from './template'; +const FLEET_COMPONENT_TEMPLATE = '.fleet_component_template-1'; + // Add our own serialiser to just do JSON.stringify expect.addSnapshotSerializer({ print(val) { @@ -67,7 +69,7 @@ describe('EPM template', () => { composedOfTemplates, templatePriority: 200, }); - expect(template.composed_of).toStrictEqual(composedOfTemplates); + expect(template.composed_of).toStrictEqual([...composedOfTemplates, FLEET_COMPONENT_TEMPLATE]); }); it('adds empty composed_of correctly', () => { @@ -82,7 +84,7 @@ describe('EPM template', () => { composedOfTemplates, templatePriority: 200, }); - expect(template.composed_of).toStrictEqual(composedOfTemplates); + expect(template.composed_of).toStrictEqual([FLEET_COMPONENT_TEMPLATE]); }); it('adds hidden field correctly', () => { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 158996cc574d7a..6aa7680395bed8 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -16,7 +16,7 @@ import type { } from '../../../../types'; import { appContextService } from '../../../'; import { getRegistryDataStreamAssetBaseName } from '../index'; -import { FINAL_PIPELINE_ID } from '../ingest_pipeline/final_pipeline'; +import { FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME } from '../../../../constants'; interface Properties { [key: string]: any; @@ -90,7 +90,11 @@ export function getTemplate({ if (template.template.settings.index.final_pipeline) { throw new Error(`Error template for ${templateIndexPattern} contains a final_pipeline`); } - template.template.settings.index.final_pipeline = FINAL_PIPELINE_ID; + + if (appContextService.getConfig()?.agentIdVerificationEnabled) { + // Add fleet global assets + template.composed_of = [...(template.composed_of || []), FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME]; + } return template; } diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.ts index 28af2b563da792..6a5968441e6344 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.ts @@ -101,6 +101,8 @@ export async function getPackageSavedObjects( }); } +export const getInstallations = getPackageSavedObjects; + export async function getPackageInfo(options: { savedObjectsClient: SavedObjectsClientContract; pkgName: string; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/index.ts b/x-pack/plugins/fleet/server/services/epm/packages/index.ts index 608e157017e9b5..1f9113590f0f77 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/index.ts @@ -17,6 +17,7 @@ export { getFile, getInstallationObject, getInstallation, + getInstallations, getPackageInfo, getPackages, getLimitedPackages, diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index 45805bb066c3b0..cfef04846d92e6 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -24,7 +24,10 @@ import { awaitIfPending } from './setup_utils'; import { ensureAgentActionPolicyChangeExists } from './agents'; import { awaitIfFleetServerSetupPending } from './fleet_server'; import { ensureFleetFinalPipelineIsInstalled } from './epm/elasticsearch/ingest_pipeline/install'; +import { ensureDefaultComponentTemplate } from './epm/elasticsearch/template/install'; +import { getInstallations, installPackage } from './epm/packages'; import { isPackageInstalled } from './epm/packages/install'; +import { pkgToPkgKey } from './epm/registry'; export interface SetupStatus { isInitialized: boolean; @@ -47,9 +50,10 @@ async function createSetupSideEffects( settingsService.settingsSetup(soClient), ]); - await ensureFleetFinalPipelineIsInstalled(esClient); - await awaitIfFleetServerSetupPending(); + if (appContextService.getConfig()?.agentIdVerificationEnabled) { + await ensureFleetGlobalEsAssets(soClient, esClient); + } const { agentPolicies: policiesOrUndefined, packages: packagesOrUndefined } = appContextService.getConfig() ?? {}; @@ -95,6 +99,49 @@ async function createSetupSideEffects( }; } +/** + * Ensure ES assets shared by all Fleet index template are installed + */ +export async function ensureFleetGlobalEsAssets( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient +) { + const logger = appContextService.getLogger(); + // Ensure Global Fleet ES assets are installed + const globalAssetsRes = await Promise.all([ + ensureDefaultComponentTemplate(esClient), + ensureFleetFinalPipelineIsInstalled(esClient), + ]); + + if (globalAssetsRes.some((asset) => asset.isCreated)) { + // Update existing index template + const packages = await getInstallations(soClient); + + await Promise.all( + packages.saved_objects.map(async ({ attributes: installation }) => { + if (installation.install_source !== 'registry') { + logger.error( + `Package needs to be manually reinstalled ${installation.name} after installing Fleet global assets` + ); + return; + } + await installPackage({ + installSource: installation.install_source, + savedObjectsClient: soClient, + pkgkey: pkgToPkgKey({ name: installation.name, version: installation.version }), + esClient, + // Force install the pacakge will update the index template and the datastream write indices + force: true, + }).catch((err) => { + logger.error( + `Package needs to be manually reinstalled ${installation.name} after installing Fleet global assets: ${err.message}` + ); + }); + }) + ); + } +} + export async function ensureDefaultEnrollmentAPIKeysExists( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, diff --git a/x-pack/test/api_integration/apis/ml/modules/index.ts b/x-pack/test/api_integration/apis/ml/modules/index.ts index 1a0c532dc36fa1..3cf1c7f7878402 100644 --- a/x-pack/test/api_integration/apis/ml/modules/index.ts +++ b/x-pack/test/api_integration/apis/ml/modules/index.ts @@ -9,11 +9,14 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, loadTestFile }: FtrProviderContext) { const ml = getService('ml'); + const supertest = getService('supertest'); const fleetPackages = ['apache', 'nginx']; describe('modules', function () { before(async () => { + // Fleet need to be setup to be able to setup packages + await supertest.post(`/api/fleet/setup`).set({ 'kbn-xsrf': 'some-xsrf-token' }).expect(200); for (const fleetPackage of fleetPackages) { await ml.testResources.installFleetPackage(fleetPackage); } diff --git a/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts b/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts index 81f712e095c788..68a78dd842c4bc 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts @@ -12,7 +12,7 @@ import { skipIfNoDockerRegistry } from '../../helpers'; const TEST_INDEX = 'logs-log.log-test'; -const FINAL_PIPELINE_ID = '.fleet_final_pipeline'; +const FINAL_PIPELINE_ID = '.fleet_final_pipeline-1'; let pkgKey: string; @@ -43,7 +43,6 @@ export default function (providerContext: FtrProviderContext) { const { body: getPackagesRes } = await supertest.get( `/api/fleet/epm/packages?experimental=true` ); - const logPackage = getPackagesRes.response.find((p: any) => p.name === 'log'); if (!logPackage) { throw new Error('No log package'); @@ -85,12 +84,11 @@ export default function (providerContext: FtrProviderContext) { it('should correctly setup the final pipeline and apply to fleet managed index template', async () => { const pipelineRes = await es.ingest.getPipeline({ id: FINAL_PIPELINE_ID }); expect(pipelineRes.body).to.have.property(FINAL_PIPELINE_ID); - const res = await es.indices.getIndexTemplate({ name: 'logs-log.log' }); expect(res.body.index_templates.length).to.be(1); - expect( - res.body.index_templates[0]?.index_template?.template?.settings?.index?.final_pipeline - ).to.be(FINAL_PIPELINE_ID); + expect(res.body.index_templates[0]?.index_template?.composed_of).to.contain( + '.fleet_component_template-1' + ); }); it('For a doc written without api key should write the correct api key status', async () => { diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_overrides.ts b/x-pack/test/fleet_api_integration/apis/epm/install_overrides.ts index 204ee8508f468c..770502db49daeb 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_overrides.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_overrides.ts @@ -49,6 +49,7 @@ export default function (providerContext: FtrProviderContext) { `${templateName}@mappings`, `${templateName}@settings`, `${templateName}@custom`, + '.fleet_component_template-1', ]); ({ body } = await es.transport.request({ From 045a32b054ddd30672fd9c1802a3b5e482cb37bb Mon Sep 17 00:00:00 2001 From: Constance Date: Wed, 23 Jun 2021 10:22:04 -0700 Subject: [PATCH 39/61] [Enterprise Search] Support active nav links that have both subnav & non-subnav child routes (#103036) * Update generateNavlink to take an `items` subNav and use it to determine isSelected + change getNavLinkActive to early returns + tweak tests for readability * Update WS nav Sources link - to show active on creation routes but not on single source routes * Update AS nav Engines link - should eventually show active on creation routes but not on single engine routes * Update AS engine creation routing - so that it correctly shows as a child route of the Engines link + update breadcrumbs --- .../engine_creation/engine_creation.tsx | 3 +- .../engines/components/empty_state.test.tsx | 2 +- .../app_search/components/layout/nav.test.tsx | 2 +- .../app_search/components/layout/nav.tsx | 8 ++- .../meta_engine_creation.tsx | 3 +- .../public/applications/app_search/index.tsx | 6 +-- .../public/applications/app_search/routes.ts | 4 +- .../shared/layout/nav_link_helpers.test.ts | 53 ++++++++++++++++--- .../shared/layout/nav_link_helpers.ts | 23 +++++--- .../components/layout/nav.test.tsx | 2 +- .../components/layout/nav.tsx | 7 ++- 11 files changed, 85 insertions(+), 28 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx index 913aa4f0ec8452..18b8390081467e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx @@ -22,6 +22,7 @@ import { EuiButton, } from '@elastic/eui'; +import { ENGINES_TITLE } from '../engines'; import { AppSearchPageTemplate } from '../layout'; import { @@ -43,7 +44,7 @@ export const EngineCreation: React.FC = () => { return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx index 159a986096ae2a..9117fdd0be87dd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx @@ -53,7 +53,7 @@ describe('EmptyState', () => { }); it('sends a user to engine creation', () => { - expect(button.prop('to')).toEqual('/engine_creation'); + expect(button.prop('to')).toEqual('/engines/new'); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx index 80230394ce2a2f..c9f5452e254e16 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx @@ -8,7 +8,7 @@ import { setMockValues } from '../../../__mocks__/kea_logic'; jest.mock('../../../shared/layout', () => ({ - generateNavLink: jest.fn(({ to }) => ({ href: to })), + generateNavLink: jest.fn(({ to, items }) => ({ href: to, items })), })); jest.mock('../engine/engine_nav', () => ({ useEngineNav: () => [], diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx index 4737fbcf07e23c..c3b8ec642233bc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx @@ -28,8 +28,12 @@ export const useAppSearchNav = () => { { id: 'engines', name: ENGINES_TITLE, - ...generateNavLink({ to: ENGINES_PATH, isRoot: true }), - items: useEngineNav(), + ...generateNavLink({ + to: ENGINES_PATH, + isRoot: true, + shouldShowActiveForSubroutes: true, + items: useEngineNav(), + }), }, ]; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx index 325e557acec0cd..1455444ab2b4bb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx @@ -25,6 +25,7 @@ import { } from '@elastic/eui'; import { AppLogic } from '../../app_logic'; +import { ENGINES_TITLE } from '../engines'; import { AppSearchPageTemplate } from '../layout'; import { @@ -73,7 +74,7 @@ export const MetaEngineCreation: React.FC = () => { return ( > = (props) = - - - {canManageEngines && ( @@ -117,6 +114,9 @@ export const AppSearchConfigured: React.FC> = (props) = )} + + + {canViewSettings && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index bd5bdb7b2f6651..d9d1935c648f72 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -18,7 +18,7 @@ export const CREDENTIALS_PATH = '/credentials'; export const ROLE_MAPPINGS_PATH = '/role_mappings'; export const ENGINES_PATH = '/engines'; -export const ENGINE_CREATION_PATH = '/engine_creation'; +export const ENGINE_CREATION_PATH = `${ENGINES_PATH}/new`; // This is safe from conflicting with an :engineName path because new is a reserved name export const ENGINE_PATH = `${ENGINES_PATH}/:engineName`; export const ENGINE_ANALYTICS_PATH = `${ENGINE_PATH}/analytics`; @@ -39,7 +39,7 @@ export const ENGINE_REINDEX_JOB_PATH = `${ENGINE_SCHEMA_PATH}/reindex_job/:reind export const ENGINE_CRAWLER_PATH = `${ENGINE_PATH}/crawler`; export const ENGINE_CRAWLER_DOMAIN_PATH = `${ENGINE_CRAWLER_PATH}/domains/:domainId`; -export const META_ENGINE_CREATION_PATH = '/meta_engine_creation'; +export const META_ENGINE_CREATION_PATH = `${ENGINES_PATH}/new_meta_engine`; // This is safe from conflicting with an :engineName path because engine names cannot have underscores export const META_ENGINE_SOURCE_ENGINES_PATH = `${ENGINE_PATH}/engines`; export const ENGINE_RELEVANCE_TUNING_PATH = `${ENGINE_PATH}/relevance_tuning`; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.test.ts index b51416ac76ca78..8cfca3bade993f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.test.ts @@ -19,21 +19,23 @@ import { generateNavLink, getNavLinkActive } from './nav_link_helpers'; describe('generateNavLink', () => { beforeEach(() => { jest.clearAllMocks(); - mockKibanaValues.history.location.pathname = '/current_page'; + mockKibanaValues.history.location.pathname = '/'; }); - it('generates React Router props & isSelected (active) state for use within an EuiSideNavItem obj', () => { + it('generates React Router props for use within an EuiSideNavItem obj', () => { const navItem = generateNavLink({ to: '/test' }); - expect(navItem.href).toEqual('/app/enterprise_search/test'); + expect(navItem).toEqual({ + href: '/app/enterprise_search/test', + onClick: expect.any(Function), + isSelected: false, + }); navItem.onClick({} as any); expect(mockKibanaValues.navigateToUrl).toHaveBeenCalledWith('/test'); - - expect(navItem.isSelected).toEqual(false); }); - describe('getNavLinkActive', () => { + describe('isSelected / getNavLinkActive', () => { it('returns true when the current path matches the link path', () => { mockKibanaValues.history.location.pathname = '/test'; const isSelected = getNavLinkActive({ to: '/test' }); @@ -41,6 +43,13 @@ describe('generateNavLink', () => { expect(isSelected).toEqual(true); }); + it('return false when the current path does not match the link path', () => { + mockKibanaValues.history.location.pathname = '/hello'; + const isSelected = getNavLinkActive({ to: '/world' }); + + expect(isSelected).toEqual(false); + }); + describe('isRoot', () => { it('returns true if the current path is "/"', () => { mockKibanaValues.history.location.pathname = '/'; @@ -58,7 +67,31 @@ describe('generateNavLink', () => { expect(isSelected).toEqual(true); }); - it('returns false if not', () => { + /* NOTE: This logic is primarily used for the following routing scenario: + * 1. /item/{itemId} shows a child subnav, e.g. /items/{itemId}/settings + * - BUT when the child subnav is open, the parent `Item` nav link should not show as active - its child nav links should + * 2. /item/create_item (example) does *not* show a child subnav + * - BUT the parent `Item` nav link should highlight when on this non-subnav route + */ + it('returns false if subroutes already have their own items subnav (with active state)', () => { + mockKibanaValues.history.location.pathname = '/items/123/settings'; + const isSelected = getNavLinkActive({ + to: '/items', + shouldShowActiveForSubroutes: true, + items: [{ id: 'settings', name: 'Settings' }], + }); + + expect(isSelected).toEqual(false); + }); + + it('returns false if not a valid subroute', () => { + mockKibanaValues.history.location.pathname = '/hello/world'; + const isSelected = getNavLinkActive({ to: '/world', shouldShowActiveForSubroutes: true }); + + expect(isSelected).toEqual(false); + }); + + it('returns false for subroutes if the flag is not passed', () => { mockKibanaValues.history.location.pathname = '/hello/world'; const isSelected = getNavLinkActive({ to: '/hello' }); @@ -66,4 +99,10 @@ describe('generateNavLink', () => { }); }); }); + + it('optionally passes items', () => { + const navItem = generateNavLink({ to: '/test', items: [] }); + + expect(navItem.items).toEqual([]); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.ts b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.ts index 6124636af3f992..9caf58886c52ed 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { EuiSideNavItemType } from '@elastic/eui'; + import { stripTrailingSlash } from '../../../../common/strip_slashes'; import { KibanaLogic } from '../kibana'; @@ -14,12 +16,14 @@ interface Params { to: string; isRoot?: boolean; shouldShowActiveForSubroutes?: boolean; + items?: Array>; // Primarily passed if using `items` to determine isSelected - if not, you can just set `items` outside of this helper } -export const generateNavLink = ({ to, ...rest }: Params & ReactRouterProps) => { +export const generateNavLink = ({ to, items, ...rest }: Params & ReactRouterProps) => { return { ...generateReactRouterProps({ to, ...rest }), - isSelected: getNavLinkActive({ to, ...rest }), + isSelected: getNavLinkActive({ to, items, ...rest }), + items, }; }; @@ -27,14 +31,19 @@ export const getNavLinkActive = ({ to, isRoot = false, shouldShowActiveForSubroutes = false, + items = [], }: Params): boolean => { const { pathname } = KibanaLogic.values.history.location; const currentPath = stripTrailingSlash(pathname); - const isActive = - currentPath === to || - (shouldShowActiveForSubroutes && currentPath.startsWith(to)) || - (isRoot && currentPath === ''); + if (currentPath === to) return true; + + if (isRoot && currentPath === '') return true; + + if (shouldShowActiveForSubroutes) { + if (items.length) return false; // If a nav link has sub-nav items open, never show it as active + if (currentPath.startsWith(to)) return true; + } - return isActive; + return false; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx index 04b0880a7351cd..f2601ff98db1da 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx @@ -7,7 +7,7 @@ jest.mock('../../../shared/layout', () => ({ ...jest.requireActual('../../../shared/layout'), - generateNavLink: jest.fn(({ to }) => ({ href: to })), + generateNavLink: jest.fn(({ to, items }) => ({ href: to, items })), })); jest.mock('../../views/content_sources/components/source_sub_nav', () => ({ useSourceSubNav: () => [], diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index 99225bc36e892b..ce2f8bf7ef7e46 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -33,8 +33,11 @@ export const useWorkplaceSearchNav = () => { { id: 'sources', name: NAV.SOURCES, - ...generateNavLink({ to: SOURCES_PATH }), - items: useSourceSubNav(), + ...generateNavLink({ + to: SOURCES_PATH, + shouldShowActiveForSubroutes: true, + items: useSourceSubNav(), + }), }, { id: 'groups', From 524401973f1d0ac5403cd0fbb3ea82a63962a45a Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Wed, 23 Jun 2021 19:58:10 +0200 Subject: [PATCH 40/61] Add timeouts and setup enforcement for custom plugins statuses (#77965) --- ...ugin-core-server.statusservicesetup.set.md | 2 + packages/kbn-pm/dist/index.js | 1 + packages/kbn-pm/src/config.ts | 1 + src/core/server/server.test.ts | 2 + src/core/server/server.ts | 2 +- src/core/server/status/plugins_status.test.ts | 93 ++++++++++++++++++- src/core/server/status/plugins_status.ts | 46 +++++++-- src/core/server/status/status_service.ts | 6 +- src/core/server/status/types.ts | 3 + src/dev/typescript/projects.ts | 3 + .../test_suites/core_plugins/status.ts | 71 ++++++++++++++ test/scripts/test/server_integration.sh | 7 ++ .../plugins/status_plugin_a/kibana.json | 7 ++ .../plugins/status_plugin_a/package.json | 14 +++ .../plugins/status_plugin_a/server/index.ts | 11 +++ .../plugins/status_plugin_a/server/plugin.ts | 56 +++++++++++ .../plugins/status_plugin_a/tsconfig.json | 17 ++++ .../plugins/status_plugin_b/kibana.json | 8 ++ .../plugins/status_plugin_b/package.json | 14 +++ .../plugins/status_plugin_b/server/index.ts | 11 +++ .../plugins/status_plugin_b/server/plugin.ts | 15 +++ .../plugins/status_plugin_b/tsconfig.json | 17 ++++ .../http/platform/config.status.ts | 58 ++++++++++++ .../http/platform/status.ts | 69 ++++++++++++++ test/tsconfig.json | 9 +- 25 files changed, 529 insertions(+), 14 deletions(-) create mode 100644 test/plugin_functional/test_suites/core_plugins/status.ts create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_a/kibana.json create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_a/package.json create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_a/server/index.ts create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_a/server/plugin.ts create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_a/tsconfig.json create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_b/kibana.json create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_b/package.json create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_b/server/index.ts create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_b/server/plugin.ts create mode 100644 test/server_integration/__fixtures__/plugins/status_plugin_b/tsconfig.json create mode 100644 test/server_integration/http/platform/config.status.ts create mode 100644 test/server_integration/http/platform/status.ts diff --git a/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.set.md b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.set.md index 143cd397c40ae4..bf08ca1682f3b1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.set.md +++ b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.set.md @@ -24,5 +24,7 @@ set(status$: Observable): void; ## Remarks +The first emission from this Observable should occur within 30s, else this plugin's status will fallback to `unavailable` until the first emission. + See the [StatusServiceSetup.derivedStatus$](./kibana-plugin-core-server.statusservicesetup.derivedstatus_.md) API for leveraging the default status calculation that is provided by Core. diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index e455f487d13843..5be9dff630ed51 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -63827,6 +63827,7 @@ function getProjectPaths({ projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'test/plugin_functional/plugins/*')); projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'test/interpreter_functional/plugins/*')); + projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'test/server_integration/__fixtures__/plugins/*')); projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'examples/*')); if (!ossOnly) { diff --git a/packages/kbn-pm/src/config.ts b/packages/kbn-pm/src/config.ts index a11b2ad9c72c3a..666a2fed7a33c1 100644 --- a/packages/kbn-pm/src/config.ts +++ b/packages/kbn-pm/src/config.ts @@ -31,6 +31,7 @@ export function getProjectPaths({ rootPath, ossOnly, skipKibanaPlugins }: Option // correct and the expect behavior. projectPaths.push(resolve(rootPath, 'test/plugin_functional/plugins/*')); projectPaths.push(resolve(rootPath, 'test/interpreter_functional/plugins/*')); + projectPaths.push(resolve(rootPath, 'test/server_integration/__fixtures__/plugins/*')); projectPaths.push(resolve(rootPath, 'examples/*')); if (!ossOnly) { diff --git a/src/core/server/server.test.ts b/src/core/server/server.test.ts index 534d7df9d94666..e1986c5bf1d923 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -114,6 +114,7 @@ test('runs services on "start"', async () => { expect(mockSavedObjectsService.start).not.toHaveBeenCalled(); expect(mockUiSettingsService.start).not.toHaveBeenCalled(); expect(mockMetricsService.start).not.toHaveBeenCalled(); + expect(mockStatusService.start).not.toHaveBeenCalled(); await server.start(); @@ -121,6 +122,7 @@ test('runs services on "start"', async () => { expect(mockSavedObjectsService.start).toHaveBeenCalledTimes(1); expect(mockUiSettingsService.start).toHaveBeenCalledTimes(1); expect(mockMetricsService.start).toHaveBeenCalledTimes(1); + expect(mockStatusService.start).toHaveBeenCalledTimes(1); }); test('does not fail on "setup" if there are unused paths detected', async () => { diff --git a/src/core/server/server.ts b/src/core/server/server.ts index adf794c390338e..3f553dd90678ed 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -248,6 +248,7 @@ export class Server { savedObjects: savedObjectsStart, exposedConfigsToUsage: this.plugins.getExposedPluginConfigsToUsage(), }); + this.status.start(); this.coreStart = { capabilities: capabilitiesStart, @@ -261,7 +262,6 @@ export class Server { await this.plugins.start(this.coreStart); - this.status.start(); await this.http.start(); startTransaction?.end(); diff --git a/src/core/server/status/plugins_status.test.ts b/src/core/server/status/plugins_status.test.ts index b0d9e478769402..9dc1ddcddca3e8 100644 --- a/src/core/server/status/plugins_status.test.ts +++ b/src/core/server/status/plugins_status.test.ts @@ -8,7 +8,7 @@ import { PluginName } from '../plugins'; import { PluginsStatusService } from './plugins_status'; -import { of, Observable, BehaviorSubject } from 'rxjs'; +import { of, Observable, BehaviorSubject, ReplaySubject } from 'rxjs'; import { ServiceStatusLevels, CoreStatus, ServiceStatus } from './types'; import { first } from 'rxjs/operators'; import { ServiceStatusLevelSnapshotSerializer } from './test_utils'; @@ -34,6 +34,28 @@ describe('PluginStatusService', () => { ['c', ['a', 'b']], ]); + describe('set', () => { + it('throws an exception if called after registrations are blocked', () => { + const service = new PluginsStatusService({ + core$: coreAllAvailable$, + pluginDependencies, + }); + + service.blockNewRegistrations(); + expect(() => { + service.set( + 'a', + of({ + level: ServiceStatusLevels.available, + summary: 'fail!', + }) + ); + }).toThrowErrorMatchingInlineSnapshot( + `"Custom statuses cannot be registered after setup, plugin [a] attempted"` + ); + }); + }); + describe('getDerivedStatus$', () => { it(`defaults to core's most severe status`, async () => { const serviceAvailable = new PluginsStatusService({ @@ -231,6 +253,75 @@ describe('PluginStatusService', () => { { a: { level: ServiceStatusLevels.available, summary: 'a available' } }, ]); }); + + it('updates when a plugin status observable emits', async () => { + const service = new PluginsStatusService({ + core$: coreAllAvailable$, + pluginDependencies: new Map([['a', []]]), + }); + const statusUpdates: Array> = []; + const subscription = service + .getAll$() + .subscribe((pluginStatuses) => statusUpdates.push(pluginStatuses)); + + const aStatus$ = new BehaviorSubject({ + level: ServiceStatusLevels.degraded, + summary: 'a degraded', + }); + service.set('a', aStatus$); + aStatus$.next({ level: ServiceStatusLevels.unavailable, summary: 'a unavailable' }); + aStatus$.next({ level: ServiceStatusLevels.available, summary: 'a available' }); + subscription.unsubscribe(); + + expect(statusUpdates).toEqual([ + { a: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' } }, + { a: { level: ServiceStatusLevels.degraded, summary: 'a degraded' } }, + { a: { level: ServiceStatusLevels.unavailable, summary: 'a unavailable' } }, + { a: { level: ServiceStatusLevels.available, summary: 'a available' } }, + ]); + }); + + it('emits an unavailable status if first emission times out, then continues future emissions', async () => { + jest.useFakeTimers(); + const service = new PluginsStatusService({ + core$: coreAllAvailable$, + pluginDependencies: new Map([ + ['a', []], + ['b', ['a']], + ]), + }); + + const pluginA$ = new ReplaySubject(1); + service.set('a', pluginA$); + const firstEmission = service.getAll$().pipe(first()).toPromise(); + jest.runAllTimers(); + + expect(await firstEmission).toEqual({ + a: { level: ServiceStatusLevels.unavailable, summary: 'Status check timed out after 30s' }, + b: { + level: ServiceStatusLevels.unavailable, + summary: '[a]: Status check timed out after 30s', + detail: 'See the status page for more information', + meta: { + affectedServices: { + a: { + level: ServiceStatusLevels.unavailable, + summary: 'Status check timed out after 30s', + }, + }, + }, + }, + }); + + pluginA$.next({ level: ServiceStatusLevels.available, summary: 'a available' }); + const secondEmission = service.getAll$().pipe(first()).toPromise(); + jest.runAllTimers(); + expect(await secondEmission).toEqual({ + a: { level: ServiceStatusLevels.available, summary: 'a available' }, + b: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' }, + }); + jest.useRealTimers(); + }); }); describe('getDependenciesStatus$', () => { diff --git a/src/core/server/status/plugins_status.ts b/src/core/server/status/plugins_status.ts index 1aacbf3be56db5..6a8ef1081e1659 100644 --- a/src/core/server/status/plugins_status.ts +++ b/src/core/server/status/plugins_status.ts @@ -7,13 +7,22 @@ */ import { BehaviorSubject, Observable, combineLatest, of } from 'rxjs'; -import { map, distinctUntilChanged, switchMap, debounceTime } from 'rxjs/operators'; +import { + map, + distinctUntilChanged, + switchMap, + debounceTime, + timeoutWith, + startWith, +} from 'rxjs/operators'; import { isDeepStrictEqual } from 'util'; import { PluginName } from '../plugins'; -import { ServiceStatus, CoreStatus } from './types'; +import { ServiceStatus, CoreStatus, ServiceStatusLevels } from './types'; import { getSummaryStatus } from './get_summary_status'; +const STATUS_TIMEOUT_MS = 30 * 1000; // 30 seconds + interface Deps { core$: Observable; pluginDependencies: ReadonlyMap; @@ -23,6 +32,7 @@ export class PluginsStatusService { private readonly pluginStatuses = new Map>(); private readonly update$ = new BehaviorSubject(true); private readonly defaultInheritedStatus$: Observable; + private newRegistrationsAllowed = true; constructor(private readonly deps: Deps) { this.defaultInheritedStatus$ = this.deps.core$.pipe( @@ -35,10 +45,19 @@ export class PluginsStatusService { } public set(plugin: PluginName, status$: Observable) { + if (!this.newRegistrationsAllowed) { + throw new Error( + `Custom statuses cannot be registered after setup, plugin [${plugin}] attempted` + ); + } this.pluginStatuses.set(plugin, status$); this.update$.next(true); // trigger all existing Observables to update from the new source Observable } + public blockNewRegistrations() { + this.newRegistrationsAllowed = false; + } + public getAll$(): Observable> { return this.getPluginStatuses$([...this.deps.pluginDependencies.keys()]); } @@ -86,13 +105,22 @@ export class PluginsStatusService { return this.update$.pipe( switchMap(() => { const pluginStatuses = plugins - .map( - (depName) => - [depName, this.pluginStatuses.get(depName) ?? this.getDerivedStatus$(depName)] as [ - PluginName, - Observable - ] - ) + .map((depName) => { + const pluginStatus = this.pluginStatuses.get(depName) + ? this.pluginStatuses.get(depName)!.pipe( + timeoutWith( + STATUS_TIMEOUT_MS, + this.pluginStatuses.get(depName)!.pipe( + startWith({ + level: ServiceStatusLevels.unavailable, + summary: `Status check timed out after ${STATUS_TIMEOUT_MS / 1000}s`, + }) + ) + ) + ) + : this.getDerivedStatus$(depName); + return [depName, pluginStatus] as [PluginName, Observable]; + }) .map(([pName, status$]) => status$.pipe(map((status) => [pName, status] as [PluginName, ServiceStatus])) ); diff --git a/src/core/server/status/status_service.ts b/src/core/server/status/status_service.ts index b8c19508a5d618..d4dc8ed3d4d724 100644 --- a/src/core/server/status/status_service.ts +++ b/src/core/server/status/status_service.ts @@ -135,9 +135,11 @@ export class StatusService implements CoreService { } public start() { - if (!this.overall$) { - throw new Error('cannot call `start` before `setup`'); + if (!this.pluginsStatus || !this.overall$) { + throw new Error(`StatusService#setup must be called before #start`); } + this.pluginsStatus.blockNewRegistrations(); + getOverallStatusChanges(this.overall$, this.stop$).subscribe((message) => { this.logger.info(message); }); diff --git a/src/core/server/status/types.ts b/src/core/server/status/types.ts index 411b942c8eb33c..bfca4c74d93654 100644 --- a/src/core/server/status/types.ts +++ b/src/core/server/status/types.ts @@ -196,6 +196,9 @@ export interface StatusServiceSetup { * Completely overrides the default inherited status. * * @remarks + * The first emission from this Observable should occur within 30s, else this plugin's status will fallback to + * `unavailable` until the first emission. + * * See the {@link StatusServiceSetup.derivedStatus$} API for leveraging the default status * calculation that is provided by Core. */ diff --git a/src/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index f372cf052d3683..2c54bb8dba1794 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -58,6 +58,9 @@ export const PROJECTS = [ ...glob .sync('test/interpreter_functional/plugins/*/tsconfig.json', { cwd: REPO_ROOT }) .map((path) => new Project(resolve(REPO_ROOT, path))), + ...glob + .sync('test/server_integration/__fixtures__/plugins/*/tsconfig.json', { cwd: REPO_ROOT }) + .map((path) => new Project(resolve(REPO_ROOT, path))), ]; export function filterProjectsByFlag(projectFlag?: string) { diff --git a/test/plugin_functional/test_suites/core_plugins/status.ts b/test/plugin_functional/test_suites/core_plugins/status.ts new file mode 100644 index 00000000000000..2b0f15cb392738 --- /dev/null +++ b/test/plugin_functional/test_suites/core_plugins/status.ts @@ -0,0 +1,71 @@ +/* + * Copyright 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 { ServiceStatusLevels } from '../../../../src/core/server'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const log = getService('log'); + + const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const getStatus = async (pluginName?: string) => { + const resp = await supertest.get('/api/status?v8format=true'); + + if (pluginName) { + return resp.body.status.plugins[pluginName]; + } else { + return resp.body.status.overall; + } + }; + + const setStatus = async (level: T) => + supertest + .post(`/internal/core_plugin_a/status/set?level=${level}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + describe('status service', () => { + // This test must comes first because the timeout only applies to the initial emission + it("returns a timeout for status check that doesn't emit after 30s", async () => { + let aStatus = await getStatus('corePluginA'); + expect(aStatus.level).to.eql('unavailable'); + + // Status will remain in unavailable due to core services until custom status timesout + // Keep polling until that condition ends, up to a timeout + const start = Date.now(); + while ('elasticsearch' in (aStatus.meta?.affectedServices ?? {})) { + aStatus = await getStatus('corePluginA'); + expect(aStatus.level).to.eql('unavailable'); + + // If it's been more than 40s, break out of this loop + if (Date.now() - start >= 40_000) { + throw new Error(`Timed out waiting for status timeout after 40s`); + } + + log.info('Waiting for status check to timeout...'); + await delay(2000); + } + + expect(aStatus.summary).to.eql('Status check timed out after 30s'); + }); + + it('propagates status issues to dependencies', async () => { + await setStatus('degraded'); + await delay(1000); + expect((await getStatus('corePluginA')).level).to.eql('degraded'); + expect((await getStatus('corePluginB')).level).to.eql('degraded'); + + await setStatus('available'); + await delay(1000); + expect((await getStatus('corePluginA')).level).to.eql('available'); + expect((await getStatus('corePluginB')).level).to.eql('available'); + }); + }); +} diff --git a/test/scripts/test/server_integration.sh b/test/scripts/test/server_integration.sh index 1ff4a772bb6e09..6ec08c7727e205 100755 --- a/test/scripts/test/server_integration.sh +++ b/test/scripts/test/server_integration.sh @@ -12,3 +12,10 @@ checks-reporter-with-killswitch "Server Integration Tests" \ --bail \ --debug \ --kibana-install-dir $KIBANA_INSTALL_DIR + +# Tests that must be run against source in order to build test plugins +checks-reporter-with-killswitch "Status Integration Tests" \ + node scripts/functional_tests \ + --config test/server_integration/http/platform/config.status.ts \ + --bail \ + --debug \ diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_a/kibana.json b/test/server_integration/__fixtures__/plugins/status_plugin_a/kibana.json new file mode 100644 index 00000000000000..36981d446c9f97 --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_a/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "statusPluginA", + "version": "0.0.1", + "kibanaVersion": "kibana", + "server": true, + "ui": false +} diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_a/package.json b/test/server_integration/__fixtures__/plugins/status_plugin_a/package.json new file mode 100644 index 00000000000000..5c73bca024f4ea --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_a/package.json @@ -0,0 +1,14 @@ +{ + "name": "status_plugin_a", + "version": "1.0.0", + "main": "target/test/server_integration/__fixtures__/plugins/status_plugin_a", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "SSPL-1.0 OR Elastic License 2.0", + "scripts": { + "kbn": "node ../../../../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../../../../node_modules/.bin/tsc" + } +} \ No newline at end of file diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_a/server/index.ts b/test/server_integration/__fixtures__/plugins/status_plugin_a/server/index.ts new file mode 100644 index 00000000000000..cf221c00e32b02 --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_a/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { StatusPluginAPlugin } from './plugin'; + +export const plugin = () => new StatusPluginAPlugin(); diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_a/server/plugin.ts b/test/server_integration/__fixtures__/plugins/status_plugin_a/server/plugin.ts new file mode 100644 index 00000000000000..b2e4f0dd322c4f --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_a/server/plugin.ts @@ -0,0 +1,56 @@ +/* + * Copyright 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 { schema } from '@kbn/config-schema'; +import { Subject } from 'rxjs'; +import { + Plugin, + CoreSetup, + ServiceStatus, + ServiceStatusLevels, +} from '../../../../../../src/core/server'; + +export class StatusPluginAPlugin implements Plugin { + private status$ = new Subject(); + + public setup(core: CoreSetup, deps: {}) { + // Set a custom status that will not emit immediately to force a timeout + core.status.set(this.status$); + + const router = core.http.createRouter(); + + router.post( + { + path: '/internal/status_plugin_a/status/set', + validate: { + query: schema.object({ + level: schema.oneOf([ + schema.literal('available'), + schema.literal('degraded'), + schema.literal('unavailable'), + schema.literal('critical'), + ]), + }), + }, + }, + (context, req, res) => { + const { level } = req.query; + + this.status$.next({ + level: ServiceStatusLevels[level], + summary: `statusPluginA is ${level}`, + }); + + return res.ok(); + } + ); + } + + public start() {} + public stop() {} +} diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_a/tsconfig.json b/test/server_integration/__fixtures__/plugins/status_plugin_a/tsconfig.json new file mode 100644 index 00000000000000..5069db62589c7d --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_a/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true, + "composite": true + }, + "include": [ + "index.ts", + "server/**/*.ts", + "../../../../../../typings/**/*", + ], + "exclude": [], + "references": [ + { "path": "../../../../../src/core/tsconfig.json" } + ] +} diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_b/kibana.json b/test/server_integration/__fixtures__/plugins/status_plugin_b/kibana.json new file mode 100644 index 00000000000000..fa02f42d500afd --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_b/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "statusPluginB", + "version": "0.0.1", + "kibanaVersion": "kibana", + "server": true, + "ui": false, + "requiredPlugins": ["statusPluginA"] +} diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_b/package.json b/test/server_integration/__fixtures__/plugins/status_plugin_b/package.json new file mode 100644 index 00000000000000..3799d5d470754e --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_b/package.json @@ -0,0 +1,14 @@ +{ + "name": "status_plugin_b", + "version": "1.0.0", + "main": "target/test/server_integration/__fixtures__/plugins/status_plugin_b", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "SSPL-1.0 OR Elastic License 2.0", + "scripts": { + "kbn": "node ../../../../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../../../../node_modules/.bin/tsc" + } +} \ No newline at end of file diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_b/server/index.ts b/test/server_integration/__fixtures__/plugins/status_plugin_b/server/index.ts new file mode 100644 index 00000000000000..2002d234827b94 --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_b/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { StatusPluginBPlugin } from './plugin'; + +export const plugin = () => new StatusPluginBPlugin(); diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_b/server/plugin.ts b/test/server_integration/__fixtures__/plugins/status_plugin_b/server/plugin.ts new file mode 100644 index 00000000000000..191e8135f69a99 --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_b/server/plugin.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Plugin } from 'kibana/server'; + +export class StatusPluginBPlugin implements Plugin { + public setup() {} + public start() {} + public stop() {} +} diff --git a/test/server_integration/__fixtures__/plugins/status_plugin_b/tsconfig.json b/test/server_integration/__fixtures__/plugins/status_plugin_b/tsconfig.json new file mode 100644 index 00000000000000..224aa42ef68d23 --- /dev/null +++ b/test/server_integration/__fixtures__/plugins/status_plugin_b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true, + "composite": true + }, + "include": [ + "index.ts", + "server/**/*.ts", + "../../../../../typings/**/*", + ], + "exclude": [], + "references": [ + { "path": "../../../../../src/core/tsconfig.json" } + ] +} diff --git a/test/server_integration/http/platform/config.status.ts b/test/server_integration/http/platform/config.status.ts new file mode 100644 index 00000000000000..8cc76c901f47c8 --- /dev/null +++ b/test/server_integration/http/platform/config.status.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 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 fs from 'fs'; +import path from 'path'; +import { FtrConfigProviderContext } from '@kbn/test'; + +/* + * These tests exist in a separate configuration because: + * 1) It must run as the first test after Kibana launches to clear the unavailable status. A separate config makes this + * easier to manage and prevent from breaking. + * 2) The other server_integration tests run against a built distributable, however the FTR does not support building + * and installing plugins against built Kibana. This test must be run against source only in order to build the + * fixture plugins + */ +// eslint-disable-next-line import/no-default-export +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const httpConfig = await readConfigFile(require.resolve('../../config')); + + // Find all folders in __fixtures__/plugins since we treat all them as plugin folder + const allFiles = fs.readdirSync(path.resolve(__dirname, '../../__fixtures__/plugins')); + const plugins = allFiles.filter((file) => + fs.statSync(path.resolve(__dirname, '../../__fixtures__/plugins', file)).isDirectory() + ); + + return { + testFiles: [ + // Status test should be first to resolve manually created "unavailable" plugin + require.resolve('./status'), + ], + services: httpConfig.get('services'), + servers: httpConfig.get('servers'), + junit: { + reportName: 'Kibana Platform Status Integration Tests', + }, + esTestCluster: httpConfig.get('esTestCluster'), + kbnTestServer: { + ...httpConfig.get('kbnTestServer'), + serverArgs: [ + ...httpConfig.get('kbnTestServer.serverArgs'), + ...plugins.map( + (pluginDir) => + `--plugin-path=${path.resolve(__dirname, '../../__fixtures__/plugins', pluginDir)}` + ), + ], + runOptions: { + ...httpConfig.get('kbnTestServer.runOptions'), + // Don't wait for Kibana to be completely ready so that we can test the status timeouts + wait: /\[Kibana\]\[http\] http server running/, + }, + }, + }; +} diff --git a/test/server_integration/http/platform/status.ts b/test/server_integration/http/platform/status.ts new file mode 100644 index 00000000000000..0dcf82c9bea9eb --- /dev/null +++ b/test/server_integration/http/platform/status.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 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 { ServiceStatus, ServiceStatusLevels } from '../../../../src/core/server'; +import { FtrProviderContext } from '../../services/types'; + +type ServiceStatusSerialized = Omit & { level: string }; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + + const getStatus = async (pluginName: string): Promise => { + const resp = await supertest.get('/api/status?v8format=true'); + + return resp.body.status.plugins[pluginName]; + }; + + const setStatus = async (level: T) => + supertest + .post(`/internal/status_plugin_a/status/set?level=${level}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + describe('status service', () => { + // This test must comes first because the timeout only applies to the initial emission + it("returns a timeout for status check that doesn't emit after 30s", async () => { + let aStatus = await getStatus('statusPluginA'); + expect(aStatus.level).to.eql('unavailable'); + + // Status will remain in unavailable until the custom status check times out + // Keep polling until that condition ends, up to a timeout + await retry.waitForWithTimeout(`Status check to timeout`, 40_000, async () => { + aStatus = await getStatus('statusPluginA'); + return aStatus.summary === 'Status check timed out after 30s'; + }); + + expect(aStatus.level).to.eql('unavailable'); + expect(aStatus.summary).to.eql('Status check timed out after 30s'); + }); + + it('propagates status issues to dependencies', async () => { + await setStatus('degraded'); + await retry.waitForWithTimeout( + `statusPluginA status to update`, + 5_000, + async () => (await getStatus('statusPluginA')).level === 'degraded' + ); + expect((await getStatus('statusPluginA')).level).to.eql('degraded'); + expect((await getStatus('statusPluginB')).level).to.eql('degraded'); + + await setStatus('available'); + await retry.waitForWithTimeout( + `statusPluginA status to update`, + 5_000, + async () => (await getStatus('statusPluginA')).level === 'available' + ); + expect((await getStatus('statusPluginA')).level).to.eql('available'); + expect((await getStatus('statusPluginB')).level).to.eql('available'); + }); + }); +} diff --git a/test/tsconfig.json b/test/tsconfig.json index 3e022839460803..8cf33d93a40674 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -17,7 +17,12 @@ "api_integration/apis/telemetry/fixtures/*.json", "api_integration/apis/telemetry/fixtures/*.json", ], - "exclude": ["target/**/*", "plugin_functional/plugins/**/*", "interpreter_functional/plugins/**/*"], + "exclude": [ + "target/**/*", + "interpreter_functional/plugins/**/*", + "plugin_functional/plugins/**/*", + "server_integration/__fixtures__/plugins/**/*", + ], "references": [ { "path": "../src/core/tsconfig.json" }, { "path": "../src/plugins/telemetry_management_section/tsconfig.json" }, @@ -52,5 +57,7 @@ { "path": "../src/plugins/visualize/tsconfig.json" }, { "path": "plugin_functional/plugins/core_app_status/tsconfig.json" }, { "path": "plugin_functional/plugins/core_provider_plugin/tsconfig.json" }, + { "path": "server_integration/__fixtures__/plugins/status_plugin_a/tsconfig.json" }, + { "path": "server_integration/__fixtures__/plugins/status_plugin_b/tsconfig.json" }, ] } From 4d514c6db61dfd7d84d2792362f24ec906506700 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Wed, 23 Jun 2021 14:20:50 -0400 Subject: [PATCH 41/61] [Lens] Escape field names in formula (#102588) * [Lens] Escape field names in formula * Fix handling of partially typed fields with invalid chars Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-tinymath/grammar/grammar.peggy | 4 +-- packages/kbn-tinymath/test/library.test.js | 3 ++ .../formula/editor/formula_editor.tsx | 12 +++++-- .../formula/editor/math_completion.test.ts | 31 ++++++++++++++++ .../formula/editor/math_completion.ts | 30 ++++++++++++---- .../definitions/formula/generate.ts | 4 +++ .../operations/definitions/formula/util.ts | 2 ++ x-pack/test/functional/apps/lens/formula.ts | 36 +++++++++++++++++++ 8 files changed, 111 insertions(+), 11 deletions(-) diff --git a/packages/kbn-tinymath/grammar/grammar.peggy b/packages/kbn-tinymath/grammar/grammar.peggy index 1c6f8c3334c234..414bc2fa11cb74 100644 --- a/packages/kbn-tinymath/grammar/grammar.peggy +++ b/packages/kbn-tinymath/grammar/grammar.peggy @@ -43,7 +43,7 @@ Literal "literal" // Quoted variables are interpreted as strings // but unquoted variables are more restrictive Variable - = _ [\'] chars:(ValidChar / Space / [\"])* [\'] _ { + = _ '"' chars:("\\\"" { return "\""; } / [^"])* '"' _ { return { type: 'variable', value: chars.join(''), @@ -51,7 +51,7 @@ Variable text: text() }; } - / _ [\"] chars:(ValidChar / Space / [\'])* [\"] _ { + / _ "'" chars:("\\\'" { return "\'"; } / [^'])* "'" _ { return { type: 'variable', value: chars.join(''), diff --git a/packages/kbn-tinymath/test/library.test.js b/packages/kbn-tinymath/test/library.test.js index bbc8503684fd40..9d87919c4f1acf 100644 --- a/packages/kbn-tinymath/test/library.test.js +++ b/packages/kbn-tinymath/test/library.test.js @@ -92,6 +92,7 @@ describe('Parser', () => { expect(parse('@foo0')).toEqual(variableEqual('@foo0')); expect(parse('.foo0')).toEqual(variableEqual('.foo0')); expect(parse('-foo0')).toEqual(variableEqual('-foo0')); + expect(() => parse(`foo😀\t')`)).toThrow('Failed to parse'); }); }); @@ -103,6 +104,7 @@ describe('Parser', () => { expect(parse('"foo bar fizz buzz"')).toEqual(variableEqual('foo bar fizz buzz')); expect(parse('"foo bar baby"')).toEqual(variableEqual('foo bar baby')); expect(parse(`"f'oo"`)).toEqual(variableEqual(`f'oo`)); + expect(parse(`"foo😀\t"`)).toEqual(variableEqual(`foo😀\t`)); }); it('strings with single quotes', () => { @@ -119,6 +121,7 @@ describe('Parser', () => { expect(parse("'foo bar '")).toEqual(variableEqual("foo bar ")); expect(parse("'0foo'")).toEqual(variableEqual("0foo")); expect(parse(`'f"oo'`)).toEqual(variableEqual(`f"oo`)); + expect(parse(`'foo😀\t'`)).toEqual(variableEqual(`foo😀\t`)); /* eslint-enable prettier/prettier */ }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx index 654a93374703d3..d1b0ec8876feb6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx @@ -29,7 +29,7 @@ import { ParamEditorProps } from '../../index'; import { getManagedColumnsFrom } from '../../../layer_helpers'; import { ErrorWrapper, runASTValidation, tryToParse } from '../validation'; import { - LensMathSuggestion, + LensMathSuggestions, SUGGESTION_TYPE, suggest, getSuggestion, @@ -329,7 +329,7 @@ export function FormulaEditor({ context: monaco.languages.CompletionContext ) => { const innerText = model.getValue(); - let aSuggestions: { list: LensMathSuggestion[]; type: SUGGESTION_TYPE } = { + let aSuggestions: LensMathSuggestions = { list: [], type: SUGGESTION_TYPE.FIELD, }; @@ -367,7 +367,13 @@ export function FormulaEditor({ return { suggestions: aSuggestions.list.map((s) => - getSuggestion(s, aSuggestions.type, visibleOperationsMap, context.triggerCharacter) + getSuggestion( + s, + aSuggestions.type, + visibleOperationsMap, + context.triggerCharacter, + aSuggestions.range + ) ), }; }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.test.ts index 9cd748f5759c98..c55f22dd682d08 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.test.ts @@ -18,6 +18,7 @@ import { getHover, suggest, monacoPositionToOffset, + offsetToRowColumn, getInfoAtZeroIndexedPosition, } from './math_completion'; @@ -363,6 +364,36 @@ describe('math completion', () => { }); }); + describe('offsetToRowColumn', () => { + it('should work with single-line strings', () => { + const input = `0123456`; + expect(offsetToRowColumn(input, 5)).toEqual( + expect.objectContaining({ + lineNumber: 1, + column: 6, + }) + ); + }); + + it('should work with multi-line strings accounting for newline characters', () => { + const input = `012 +456 +89')`; + expect(offsetToRowColumn(input, 0)).toEqual( + expect.objectContaining({ + lineNumber: 1, + column: 1, + }) + ); + expect(offsetToRowColumn(input, 9)).toEqual( + expect.objectContaining({ + lineNumber: 3, + column: 2, + }) + ); + }); + }); + describe('monacoPositionToOffset', () => { it('should work with multi-line strings accounting for newline characters', () => { const input = `012 diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.ts index 815df943cdba36..28e762e7dff0fa 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/math_completion.ts @@ -13,6 +13,7 @@ import { TinymathLocation, TinymathAST, TinymathFunction, + TinymathVariable, TinymathNamedArgument, } from '@kbn/tinymath'; import type { @@ -21,7 +22,7 @@ import type { } from '../../../../../../../../../src/plugins/data/public'; import { IndexPattern } from '../../../../types'; import { memoizedGetAvailableOperationsByMetadata } from '../../../operations'; -import { tinymathFunctions, groupArgsByType } from '../util'; +import { tinymathFunctions, groupArgsByType, unquotedStringRegex } from '../util'; import type { GenericOperationDefinition } from '../..'; import { getFunctionSignatureLabel, getHelpTextContent } from './formula_help'; import { hasFunctionFieldArgument } from '../validation'; @@ -47,6 +48,7 @@ export type LensMathSuggestion = export interface LensMathSuggestions { list: LensMathSuggestion[]; type: SUGGESTION_TYPE; + range?: monaco.IRange; } function inLocation(cursorPosition: number, location: TinymathLocation) { @@ -92,7 +94,7 @@ export function offsetToRowColumn(expression: string, offset: number): monaco.Po let lineNumber = 1; for (const line of lines) { if (line.length >= remainingChars) { - return new monaco.Position(lineNumber, remainingChars); + return new monaco.Position(lineNumber, remainingChars + 1); } remainingChars -= line.length + 1; lineNumber++; @@ -128,7 +130,7 @@ export async function suggest({ operationDefinitionMap: Record; data: DataPublicPluginStart; dateHistogramInterval?: number; -}): Promise<{ list: LensMathSuggestion[]; type: SUGGESTION_TYPE }> { +}): Promise { const text = expression.substr(0, zeroIndexedOffset) + MARKER + expression.substr(zeroIndexedOffset); try { @@ -154,6 +156,7 @@ export async function suggest({ return getArgumentSuggestions( tokenInfo.parent, tokenInfo.parent.args.findIndex((a) => a === tokenAst), + text, indexPattern, operationDefinitionMap ); @@ -210,6 +213,7 @@ function getFunctionSuggestions( function getArgumentSuggestions( ast: TinymathFunction, position: number, + expression: string, indexPattern: IndexPattern, operationDefinitionMap: Record ) { @@ -280,7 +284,16 @@ function getArgumentSuggestions( .filter((op) => op.operationType === operation.type) .map((op) => ('field' in op ? op.field : undefined)) .filter((field) => field); - return { list: fields as string[], type: SUGGESTION_TYPE.FIELD }; + const fieldArg = ast.args[0]; + const location = typeof fieldArg !== 'string' && (fieldArg as TinymathVariable).location; + let range: monaco.IRange | undefined; + if (location) { + const start = offsetToRowColumn(expression, location.min); + // This accounts for any characters that the user has already typed + const end = offsetToRowColumn(expression, location.max - MARKER.length); + range = monaco.Range.fromPositions(start, end); + } + return { list: fields as string[], type: SUGGESTION_TYPE.FIELD, range }; } else { return { list: [], type: SUGGESTION_TYPE.FIELD }; } @@ -375,7 +388,8 @@ export function getSuggestion( suggestion: LensMathSuggestion, type: SUGGESTION_TYPE, operationDefinitionMap: Record, - triggerChar: string | undefined + triggerChar: string | undefined, + range?: monaco.IRange ): monaco.languages.CompletionItem { let kind: monaco.languages.CompletionItemKind = monaco.languages.CompletionItemKind.Method; let label: string = @@ -397,6 +411,10 @@ export function getSuggestion( break; case SUGGESTION_TYPE.FIELD: kind = monaco.languages.CompletionItemKind.Value; + // Look for unsafe characters + if (unquotedStringRegex.test(label)) { + insertText = `'${label.replaceAll(`'`, "\\'")}'`; + } break; case SUGGESTION_TYPE.FUNCTIONS: insertText = `${label}($0)`; @@ -450,7 +468,7 @@ export function getSuggestion( command, additionalTextEdits: [], // @ts-expect-error Monaco says this type is required, but provides a default value - range: undefined, + range, sortText, filterText, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts index a5c19c537aceef..589f547434b91f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts @@ -13,6 +13,7 @@ import { } from '../index'; import { ReferenceBasedIndexPatternColumn } from '../column_types'; import { IndexPatternLayer } from '../../../types'; +import { unquotedStringRegex } from './util'; // Just handle two levels for now type OperationParams = Record>; @@ -25,6 +26,9 @@ export function getSafeFieldName({ if (!fieldName || operationType === 'count') { return ''; } + if (unquotedStringRegex.test(fieldName)) { + return `'${fieldName.replaceAll(`'`, "\\'")}'`; + } return fieldName; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts index d29682eafa329d..9806cdaad637ed 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts @@ -16,6 +16,8 @@ import type { import type { OperationDefinition, IndexPatternColumn, GenericOperationDefinition } from '../index'; import type { GroupedNodes } from './types'; +export const unquotedStringRegex = /[^0-9A-Za-z._@\[\]/]/; + export function groupArgsByType(args: TinymathAST[]) { const { namedArgument, variable, function: functions } = groupBy( args, diff --git a/x-pack/test/functional/apps/lens/formula.ts b/x-pack/test/functional/apps/lens/formula.ts index e9e5051c006f02..38d1f63e946d41 100644 --- a/x-pack/test/functional/apps/lens/formula.ts +++ b/x-pack/test/functional/apps/lens/formula.ts @@ -14,6 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const listingTable = getService('listingTable'); const browser = getService('browser'); const testSubjects = getService('testSubjects'); + const fieldEditor = getService('fieldEditor'); describe('lens formula', () => { it('should transition from count to formula', async () => { @@ -88,6 +89,41 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await element.getVisibleText()).to.equal(`count(kql='Men\\'s Clothing')`); }); + it('should insert single quotes and escape when needed to create valid field name', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('lnsDatatable'); + await PageObjects.lens.clickAddField(); + await fieldEditor.setName(`*' "'`); + await fieldEditor.enableValue(); + await fieldEditor.typeScript("emit('abc')"); + await fieldEditor.save(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsDatatable_metrics > lns-empty-dimension', + operation: 'unique_count', + field: `*`, + keepOpen: true, + }); + + await PageObjects.lens.switchToFormula(); + let element = await find.byCssSelector('.monaco-editor'); + expect(await element.getVisibleText()).to.equal(`unique_count('*\\' "\\'')`); + + const input = await find.activeElement(); + await input.clearValueWithKeyboard({ charByChar: true }); + await input.type('unique_count('); + await PageObjects.common.sleep(100); + await input.type('*'); + await input.pressKeys(browser.keys.ENTER); + + await PageObjects.common.sleep(100); + + element = await find.byCssSelector('.monaco-editor'); + expect(await element.getVisibleText()).to.equal(`unique_count('*\\' "\\'')`); + }); + it('should persist a broken formula on close', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); From 391d0eca27704f8884d77224f49b1632724f5a9d Mon Sep 17 00:00:00 2001 From: Constance Date: Wed, 23 Jun 2021 11:30:36 -0700 Subject: [PATCH 42/61] [App Search] Remove external "Launch App Search" button (#100815) * Remove markup * Remove i18n translations * Remove telemetry metric --- .../components/engines/components/index.ts | 1 - .../components/launch_as_button.test.tsx | 27 ------------ .../engines/components/launch_as_button.tsx | 41 ------------------- .../components/engines/engines_overview.tsx | 7 +--- .../collectors/app_search/telemetry.test.ts | 7 +--- .../server/collectors/app_search/telemetry.ts | 4 -- .../schema/xpack_plugins.json | 3 -- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 9 files changed, 4 insertions(+), 88 deletions(-) delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.test.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/index.ts index 1d8e578e0edf20..63235f8a992f09 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/index.ts @@ -5,6 +5,5 @@ * 2.0. */ -export { LaunchAppSearchButton } from './launch_as_button'; export { EmptyState } from './empty_state'; export { EmptyMetaEnginesState } from './empty_meta_engines_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.test.tsx deleted file mode 100644 index 93c91cc3830f4e..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import '../../../../__mocks__/enterprise_search_url.mock'; -import { mockTelemetryActions } from '../../../../__mocks__/kea_logic'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { LaunchAppSearchButton } from './'; - -describe('LaunchAppSearchButton', () => { - it('renders a launch app search button that sends telemetry on click', () => { - const button = shallow(); - - expect(button.prop('href')).toBe('http://localhost:3002/as'); - expect(button.prop('isDisabled')).toBeFalsy(); - - button.simulate('click'); - expect(mockTelemetryActions.sendAppSearchTelemetry).toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.tsx deleted file mode 100644 index 41102cb4fba2ec..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/launch_as_button.tsx +++ /dev/null @@ -1,41 +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 { useActions } from 'kea'; - -import { EuiButton } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -import { getAppSearchUrl } from '../../../../shared/enterprise_search_url'; -import { TelemetryLogic } from '../../../../shared/telemetry'; - -export const LaunchAppSearchButton: React.FC = () => { - const { sendAppSearchTelemetry } = useActions(TelemetryLogic); - - return ( - // eslint-disable-next-line @elastic/eui/href-or-on-click - - sendAppSearchTelemetry({ - action: 'clicked', - metric: 'header_launch_button', - }) - } - data-test-subj="launchButton" - > - {i18n.translate('xpack.enterpriseSearch.appSearch.productCta', { - defaultMessage: 'Launch App Search', - })} - - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx index 4dff2460521388..d1dd5514757d7c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx @@ -20,7 +20,7 @@ import { ENGINE_CREATION_PATH, META_ENGINE_CREATION_PATH } from '../../routes'; import { DataPanel } from '../data_panel'; import { AppSearchPageTemplate } from '../layout'; -import { LaunchAppSearchButton, EmptyState, EmptyMetaEnginesState } from './components'; +import { EmptyState, EmptyMetaEnginesState } from './components'; import { EnginesTable } from './components/tables/engines_table'; import { MetaEnginesTable } from './components/tables/meta_engines_table'; import { @@ -65,10 +65,7 @@ export const EnginesOverview: React.FC = () => { ], - }} + pageHeader={{ pageTitle: ENGINES_OVERVIEW_TITLE }} isLoading={dataLoading} isEmptyState={!engines.length} emptyState={} diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts index 350c27fa43cd33..5580c3dac5996f 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts @@ -25,8 +25,7 @@ describe('App Search Telemetry Usage Collector', () => { 'ui_error.cannot_connect': 3, 'ui_error.not_found': 7, 'ui_clicked.create_first_engine_button': 40, - 'ui_clicked.header_launch_button': 50, - 'ui_clicked.engine_table_link': 60, + 'ui_clicked.engine_table_link': 50, }, }), incrementCounter: jest.fn(), @@ -66,8 +65,7 @@ describe('App Search Telemetry Usage Collector', () => { }, ui_clicked: { create_first_engine_button: 40, - header_launch_button: 50, - engine_table_link: 60, + engine_table_link: 50, }, }); }); @@ -93,7 +91,6 @@ describe('App Search Telemetry Usage Collector', () => { }, ui_clicked: { create_first_engine_button: 0, - header_launch_button: 0, engine_table_link: 0, }, }); diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts index 36ba2976f929a6..4dca6ed58e0c5e 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts @@ -23,7 +23,6 @@ interface Telemetry { }; ui_clicked: { create_first_engine_button: number; - header_launch_button: number; engine_table_link: number; }; } @@ -54,7 +53,6 @@ export const registerTelemetryUsageCollector = ( }, ui_clicked: { create_first_engine_button: { type: 'long' }, - header_launch_button: { type: 'long' }, engine_table_link: { type: 'long' }, }, }, @@ -85,7 +83,6 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log }, ui_clicked: { create_first_engine_button: 0, - header_launch_button: 0, engine_table_link: 0, }, }; @@ -110,7 +107,6 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log 'ui_clicked.create_first_engine_button', 0 ), - header_launch_button: get(savedObjectAttributes, 'ui_clicked.header_launch_button', 0), engine_table_link: get(savedObjectAttributes, 'ui_clicked.engine_table_link', 0), }, } as Telemetry; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index fa387ddc151fc7..9230b4d8298537 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -1924,9 +1924,6 @@ "create_first_engine_button": { "type": "long" }, - "header_launch_button": { - "type": "long" - }, "engine_table_link": { "type": "long" } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c6716a1fa77d48..e246cd06810535 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7857,7 +7857,6 @@ "xpack.enterpriseSearch.appSearch.multiInputRows.removeValueButtonLabel": "値を削除", "xpack.enterpriseSearch.appSearch.ownerRoleTypeDescription": "所有者はすべての操作を実行できます。アカウントには複数の所有者がいる場合がありますが、一度に少なくとも1人の所有者が必要です。", "xpack.enterpriseSearch.appSearch.productCardDescription": "Elastic App Search には、強力な検索を設計し、Web サイトや Web/モバイルアプリケーションにデプロイするための使いやすいツールがあります。", - "xpack.enterpriseSearch.appSearch.productCta": "App Searchの起動", "xpack.enterpriseSearch.appSearch.productDescription": "ダッシュボード、分析、APIを活用し、高度なアプリケーション検索をシンプルにします。", "xpack.enterpriseSearch.appSearch.productName": "App Search", "xpack.enterpriseSearch.appSearch.result.documentDetailLink": "ドキュメントの詳細を表示", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8b654a821d4dc2..6a96769e2da1eb 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7924,7 +7924,6 @@ "xpack.enterpriseSearch.appSearch.multiInputRows.removeValueButtonLabel": "删除值", "xpack.enterpriseSearch.appSearch.ownerRoleTypeDescription": "所有者可以执行任何操作。该帐户可以有很多所有者,但任何时候必须至少有一个所有者。", "xpack.enterpriseSearch.appSearch.productCardDescription": "Elastic App Search 提供用户友好的工具,用于设计强大的搜索功能,并将其部署到您的网站或 Web/移动应用程序。", - "xpack.enterpriseSearch.appSearch.productCta": "启动 App Search", "xpack.enterpriseSearch.appSearch.productDescription": "利用仪表板、分析和 API 执行高级应用程序搜索简单易行。", "xpack.enterpriseSearch.appSearch.productName": "App Search", "xpack.enterpriseSearch.appSearch.result.documentDetailLink": "访问文档详情", From 73382cebafabe60e50d3f66841fcb7c61934b844 Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Wed, 23 Jun 2021 13:36:26 -0500 Subject: [PATCH 43/61] [ML] Add Index Pattern Management to Index Data Visualizer (#101316) * [ML] Add index pattern editor flyout * [ML] Add indexPatternField editor plugin as opt dependency * [ML] Remove lens from ML's dependency * [ML] Fix custom display name cause field to be missing * [ML] Add delete option * [ML] Fix aggregatableFields logic * [ML] Add functional tests * [ML] Fix labels & consolidate addRuntimeFields * [ML] Add tooltip to show or hide distributions * Consolidate refreshPage * [ML] Fix tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/field_editor/field_editor.tsx | 6 +- x-pack/plugins/data_visualizer/kibana.json | 3 +- .../date_picker_wrapper.tsx | 4 +- .../field_data_row/action_menu/actions.ts | 84 +++++- .../data_visualizer_stats_table.tsx | 57 ++-- .../stats_table/types/field_vis_config.ts | 3 + .../index_data_visualizer_view.tsx | 86 ++++-- .../index_pattern_management/index.ts | 8 + .../index_pattern_management.tsx | 128 ++++++++ .../data_loader/data_loader.ts | 4 +- .../index_data_visualizer.tsx | 12 +- .../services/timefilter_refresh_service.ts | 3 +- .../public/application/kibana_context.ts | 3 +- .../plugins/data_visualizer/public/plugin.ts | 2 + x-pack/plugins/ml/kibana.json | 1 - .../ml/public/__mocks__/ml_start_deps.ts | 2 - x-pack/plugins/ml/public/application/app.tsx | 1 - .../contexts/kibana/kibana_context.ts | 2 - x-pack/plugins/ml/public/plugin.ts | 3 - x-pack/plugins/ml/tsconfig.json | 3 +- .../data_visualizer/file_data_visualizer.ts | 1 + .../apps/ml/data_visualizer/index.ts | 1 + ...ata_visualizer_index_pattern_management.ts | 274 ++++++++++++++++++ ...ata_visualizer_index_pattern_management.ts | 118 ++++++++ .../services/ml/data_visualizer_table.ts | 144 ++++++++- x-pack/test/functional/services/ml/index.ts | 6 + 26 files changed, 886 insertions(+), 73 deletions(-) create mode 100644 x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_pattern_management/index.ts create mode 100644 x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_pattern_management/index_pattern_management.tsx create mode 100644 x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_index_pattern_management.ts create mode 100644 x-pack/test/functional/services/ml/data_visualizer_index_pattern_management.ts diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx index fc25879b128ec0..77ef0903bc6fcd 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx @@ -216,7 +216,11 @@ const FieldEditorComponent = ({ Boolean(field?.type) && field?.type !== (updatedType && updatedType[0].value); return ( -
+ {/* Name */} diff --git a/x-pack/plugins/data_visualizer/kibana.json b/x-pack/plugins/data_visualizer/kibana.json index b024a52e647218..00eb3d7bf142ca 100644 --- a/x-pack/plugins/data_visualizer/kibana.json +++ b/x-pack/plugins/data_visualizer/kibana.json @@ -16,7 +16,8 @@ "security", "maps", "home", - "lens" + "lens", + "indexPatternFieldEditor" ], "requiredBundles": [ "home", diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/date_picker_wrapper/date_picker_wrapper.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/date_picker_wrapper/date_picker_wrapper.tsx index f6f53f40d6b9eb..52ae5e685316dc 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/date_picker_wrapper/date_picker_wrapper.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/date_picker_wrapper/date_picker_wrapper.tsx @@ -18,7 +18,7 @@ import { import { useUrlState } from '../../util/url_state'; import { useDataVisualizerKibana } from '../../../kibana_context'; -import { dataVisualizerTimefilterRefresh$ } from '../../../index_data_visualizer/services/timefilter_refresh_service'; +import { dataVisualizerRefresh$ } from '../../../index_data_visualizer/services/timefilter_refresh_service'; interface TimePickerQuickRange { from: string; @@ -50,7 +50,7 @@ function getRecentlyUsedRangesFactory(timeHistory: TimeHistoryContract) { } function updateLastRefresh(timeRange: OnRefreshProps) { - dataVisualizerTimefilterRefresh$.next({ lastRefresh: Date.now(), timeRange }); + dataVisualizerRefresh$.next({ lastRefresh: Date.now(), timeRange }); } export const DatePickerWrapper: FC = () => { diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/actions.ts b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/actions.ts index 414c72c33f057d..a77ca1d5893497 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/actions.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/actions.ts @@ -7,19 +7,37 @@ import { i18n } from '@kbn/i18n'; import { Action } from '@elastic/eui/src/components/basic_table/action_types'; +import { MutableRefObject } from 'react'; import { getCompatibleLensDataType, getLensAttributes } from './lens_utils'; import { IndexPattern } from '../../../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; import { CombinedQuery } from '../../../../index_data_visualizer/types/combined_query'; import { FieldVisConfig } from '../../stats_table/types'; -import { LensPublicStart } from '../../../../../../../lens/public'; +import { DataVisualizerKibanaReactContextValue } from '../../../../kibana_context'; +import { + dataVisualizerRefresh$, + Refresh, +} from '../../../../index_data_visualizer/services/timefilter_refresh_service'; + export function getActions( indexPattern: IndexPattern, - lensPlugin: LensPublicStart, - combinedQuery: CombinedQuery + services: DataVisualizerKibanaReactContextValue['services'], + combinedQuery: CombinedQuery, + actionFlyoutRef: MutableRefObject<(() => void | undefined) | undefined> ): Array> { - const canUseLensEditor = lensPlugin.canUseEditor(); - return [ - { + const { lens: lensPlugin, indexPatternFieldEditor } = services; + + const actions: Array> = []; + + const refreshPage = () => { + const refresh: Refresh = { + lastRefresh: Date.now(), + }; + dataVisualizerRefresh$.next(refresh); + }; + // Navigate to Lens with prefilled chart for data field + if (lensPlugin !== undefined) { + const canUseLensEditor = lensPlugin?.canUseEditor(); + actions.push({ name: i18n.translate('xpack.dataVisualizer.index.dataGrid.exploreInLensTitle', { defaultMessage: 'Explore in Lens', }), @@ -40,6 +58,56 @@ export function getActions( } }, 'data-test-subj': 'dataVisualizerActionViewInLensButton', - }, - ]; + }); + } + + // Allow to edit index pattern field + if (indexPatternFieldEditor?.userPermissions.editIndexPattern()) { + actions.push({ + name: i18n.translate('xpack.dataVisualizer.index.dataGrid.editIndexPatternFieldTitle', { + defaultMessage: 'Edit index pattern field', + }), + description: i18n.translate( + 'xpack.dataVisualizer.index.dataGrid.editIndexPatternFieldDescription', + { + defaultMessage: 'Edit index pattern field', + } + ), + type: 'icon', + icon: 'indexEdit', + onClick: (item: FieldVisConfig) => { + actionFlyoutRef.current = indexPatternFieldEditor?.openEditor({ + ctx: { indexPattern }, + fieldName: item.fieldName, + onSave: refreshPage, + }); + }, + 'data-test-subj': 'dataVisualizerActionEditIndexPatternFieldButton', + }); + actions.push({ + name: i18n.translate('xpack.dataVisualizer.index.dataGrid.deleteIndexPatternFieldTitle', { + defaultMessage: 'Delete index pattern field', + }), + description: i18n.translate( + 'xpack.dataVisualizer.index.dataGrid.deleteIndexPatternFieldDescription', + { + defaultMessage: 'Delete index pattern field', + } + ), + type: 'icon', + icon: 'trash', + available: (item: FieldVisConfig) => { + return item.deletable === true; + }, + onClick: (item: FieldVisConfig) => { + actionFlyoutRef.current = indexPatternFieldEditor?.openDeleteModal({ + ctx: { indexPattern }, + fieldName: item.fieldName!, + onDelete: refreshPage, + }); + }, + 'data-test-subj': 'dataVisualizerActionDeleteIndexPatternFieldButton', + }); + } + return actions; } diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx index afadc5c5ae4a48..02e4e29dcc05e3 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx @@ -15,6 +15,7 @@ import { EuiIcon, EuiInMemoryTable, EuiText, + EuiToolTip, HorizontalAlignment, LEFT_ALIGNMENT, RIGHT_ALIGNMENT, @@ -111,6 +112,7 @@ export const DataVisualizerTable = ({ width: '40px', isExpander: true, render: (item: DataVisualizerTableItem) => { + const displayName = item.displayName ?? item.fieldName; if (item.fieldName === undefined) return null; const direction = expandedRowItemIds.includes(item.fieldName) ? 'arrowUp' : 'arrowDown'; return ( @@ -121,11 +123,11 @@ export const DataVisualizerTable = ({ expandedRowItemIds.includes(item.fieldName) ? i18n.translate('xpack.dataVisualizer.dataGrid.rowCollapse', { defaultMessage: 'Hide details for {fieldName}', - values: { fieldName: item.fieldName }, + values: { fieldName: displayName }, }) : i18n.translate('xpack.dataVisualizer.dataGrid.rowExpand', { defaultMessage: 'Show details for {fieldName}', - values: { fieldName: item.fieldName }, + values: { fieldName: displayName }, }) } iconType={direction} @@ -157,11 +159,15 @@ export const DataVisualizerTable = ({ }), sortable: true, truncateText: true, - render: (fieldName: string) => ( - - {fieldName} - - ), + render: (fieldName: string, item: DataVisualizerTableItem) => { + const displayName = item.displayName ?? item.fieldName; + + return ( + + {displayName} + + ); + }, align: LEFT_ALIGNMENT as HorizontalAlignment, 'data-test-subj': 'dataVisualizerTableColumnName', }, @@ -194,18 +200,33 @@ export const DataVisualizerTable = ({ {i18n.translate('xpack.dataVisualizer.dataGrid.distributionsColumnName', { defaultMessage: 'Distributions', })} - toggleShowDistribution()} - aria-label={i18n.translate( - 'xpack.dataVisualizer.dataGrid.showDistributionsAriaLabel', - { - defaultMessage: 'Show distributions', + + toggleShowDistribution()} + aria-label={ + !showDistributions + ? i18n.translate('xpack.dataVisualizer.dataGrid.showDistributionsAriaLabel', { + defaultMessage: 'Show distributions', + }) + : i18n.translate('xpack.dataVisualizer.dataGrid.hideDistributionsAriaLabel', { + defaultMessage: 'Hide distributions', + }) } - )} - /> + /> +
), render: (item: DataVisualizerTableItem) => { diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/types/field_vis_config.ts b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/types/field_vis_config.ts index d58497f6cd7cc2..eeb9fe12692fda 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/types/field_vis_config.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/types/field_vis_config.ts @@ -24,17 +24,20 @@ export interface MetricFieldVisStats { export interface FieldVisConfig { type: JobFieldType; fieldName?: string; + displayName?: string; existsInDocs: boolean; aggregatable: boolean; loading: boolean; stats?: FieldVisStats; fieldFormat?: any; isUnsupportedType?: boolean; + deletable?: boolean; } export interface FileBasedFieldVisConfig { type: JobFieldType; fieldName?: string; + displayName?: string; stats?: FieldVisStats; format?: string; } diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx index 12441bcfbbb235..b116b25670ad27 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FC, Fragment, useEffect, useMemo, useState, useCallback } from 'react'; +import React, { FC, Fragment, useEffect, useMemo, useState, useCallback, useRef } from 'react'; import { merge } from 'rxjs'; import { EuiFlexGroup, @@ -62,10 +62,11 @@ import { kbnTypeToJobType } from '../../../common/util/field_types_utils'; import { SearchPanel } from '../search_panel'; import { ActionsPanel } from '../actions_panel'; import { DatePickerWrapper } from '../../../common/components/date_picker_wrapper'; -import { dataVisualizerTimefilterRefresh$ } from '../../services/timefilter_refresh_service'; +import { dataVisualizerRefresh$ } from '../../services/timefilter_refresh_service'; import { HelpMenu } from '../../../common/components/help_menu'; import { TimeBuckets } from '../../services/time_buckets'; import { extractSearchData } from '../../utils/saved_search_utils'; +import { DataVisualizerIndexPatternManagement } from '../index_pattern_management'; interface DataVisualizerPageState { overallStats: OverallStats; @@ -123,9 +124,8 @@ export interface IndexDataVisualizerViewProps { const restorableDefaults = getDefaultDataVisualizerListState(); export const IndexDataVisualizerView: FC = (dataVisualizerProps) => { - const { - services: { lens: lensPlugin, docLinks, notifications, uiSettings }, - } = useDataVisualizerKibana(); + const { services } = useDataVisualizerKibana(); + const { docLinks, notifications, uiSettings } = services; const { toasts } = notifications; const [dataVisualizerListState, setDataVisualizerListState] = usePageUrlState( @@ -299,7 +299,7 @@ export const IndexDataVisualizerView: FC = (dataVi useEffect(() => { const timeUpdateSubscription = merge( timefilter.getTimeUpdate$(), - dataVisualizerTimefilterRefresh$ + dataVisualizerRefresh$ ).subscribe(() => { setGlobalState({ time: timefilter.getTime(), @@ -533,7 +533,7 @@ export const IndexDataVisualizerView: FC = (dataVi }); const metricExistsFields = allMetricFields.filter((f) => { return aggregatableExistsFields.find((existsF) => { - return existsF.fieldName === f.displayName; + return existsF.fieldName === f.spec.name; }); }); @@ -562,7 +562,7 @@ export const IndexDataVisualizerView: FC = (dataVi metricFieldsToShow.forEach((field) => { const fieldData = aggregatableFields.find((f) => { - return f.fieldName === field.displayName; + return f.fieldName === field.spec.name; }); const metricConfig: FieldVisConfig = { @@ -571,7 +571,11 @@ export const IndexDataVisualizerView: FC = (dataVi type: JOB_FIELD_TYPES.NUMBER, loading: true, aggregatable: true, + deletable: field.runtimeField !== undefined, }; + if (field.displayName !== metricConfig.fieldName) { + metricConfig.displayName = field.displayName; + } configs.push(metricConfig); }); @@ -607,7 +611,7 @@ export const IndexDataVisualizerView: FC = (dataVi allNonMetricFields.forEach((f) => { const checkAggregatableField = aggregatableExistsFields.find( - (existsField) => existsField.fieldName === f.displayName + (existsField) => existsField.fieldName === f.spec.name ); if (checkAggregatableField !== undefined) { @@ -615,7 +619,7 @@ export const IndexDataVisualizerView: FC = (dataVi nonMetricFieldData.push(checkAggregatableField); } else { const checkNonAggregatableField = nonAggregatableExistsFields.find( - (existsField) => existsField.fieldName === f.displayName + (existsField) => existsField.fieldName === f.spec.name ); if (checkNonAggregatableField !== undefined) { @@ -643,7 +647,7 @@ export const IndexDataVisualizerView: FC = (dataVi const configs: FieldVisConfig[] = []; nonMetricFieldsToShow.forEach((field) => { - const fieldData = nonMetricFieldData.find((f) => f.fieldName === field.displayName); + const fieldData = nonMetricFieldData.find((f) => f.fieldName === field.spec.name); const nonMetricConfig = { ...fieldData, @@ -651,6 +655,7 @@ export const IndexDataVisualizerView: FC = (dataVi aggregatable: field.aggregatable, scripted: field.scripted, loading: fieldData.existsInDocs, + deletable: field.runtimeField !== undefined, }; // Map the field type from the Kibana index pattern to the field type @@ -665,6 +670,10 @@ export const IndexDataVisualizerView: FC = (dataVi nonMetricConfig.isUnsupportedType = true; } + if (field.displayName !== nonMetricConfig.fieldName) { + nonMetricConfig.displayName = field.displayName; + } + configs.push(nonMetricConfig); }); @@ -735,13 +744,33 @@ export const IndexDataVisualizerView: FC = (dataVi [currentIndexPattern, searchQueryLanguage, searchString] ); + // Some actions open up fly-out or popup + // This variable is used to keep track of them and clean up when unmounting + const actionFlyoutRef = useRef<() => void | undefined>(); + useEffect(() => { + const ref = actionFlyoutRef; + return () => { + // Clean up any of the flyout/editor opened from the actions + if (ref.current) { + ref.current(); + } + }; + }, []); + // Inject custom action column for the index based visualizer + // Hide the column completely if no access to any of the plugins const extendedColumns = useMemo(() => { - if (lensPlugin === undefined) { - // eslint-disable-next-line no-console - console.error('Lens plugin not available'); - return; - } + const actions = getActions( + currentIndexPattern, + services, + { + searchQueryLanguage, + searchString, + }, + actionFlyoutRef + ); + if (!Array.isArray(actions) || actions.length < 1) return; + const actionColumn: EuiTableActionsColumnType = { name: ( = (dataVi defaultMessage="Actions" /> ), - actions: getActions(currentIndexPattern, lensPlugin, { searchQueryLanguage, searchString }), + actions, width: '100px', }; return [actionColumn]; - }, [currentIndexPattern, lensPlugin, searchQueryLanguage, searchString]); + }, [currentIndexPattern, services, searchQueryLanguage, searchString]); const helpLink = docLinks.links.ml.guide; + return ( @@ -765,10 +795,24 @@ export const IndexDataVisualizerView: FC = (dataVi - -

{currentIndexPattern.title}

-
+
+ +

{currentIndexPattern.title}

+
+ +
+ {currentIndexPattern.timeFieldName !== undefined && ( diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_pattern_management/index.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_pattern_management/index.ts new file mode 100644 index 00000000000000..c26f84a4c22fc4 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_pattern_management/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 { DataVisualizerIndexPatternManagement } from './index_pattern_management'; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_pattern_management/index_pattern_management.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_pattern_management/index_pattern_management.tsx new file mode 100644 index 00000000000000..cb81640f328c58 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_pattern_management/index_pattern_management.tsx @@ -0,0 +1,128 @@ +/* + * Copyright 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, { useEffect, useRef, useState } from 'react'; +import { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { IndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; +import { useDataVisualizerKibana } from '../../../kibana_context'; +import { dataVisualizerRefresh$, Refresh } from '../../services/timefilter_refresh_service'; + +export interface DataVisualizerIndexPatternManagementProps { + /** + * Currently selected index pattern + */ + currentIndexPattern?: IndexPattern; + /** + * Read from the Fields API + */ + useNewFieldsApi?: boolean; +} + +export function DataVisualizerIndexPatternManagement( + props: DataVisualizerIndexPatternManagementProps +) { + const { + services: { indexPatternFieldEditor, application }, + } = useDataVisualizerKibana(); + + const { useNewFieldsApi, currentIndexPattern } = props; + const indexPatternFieldEditPermission = indexPatternFieldEditor?.userPermissions.editIndexPattern(); + const canEditIndexPatternField = !!indexPatternFieldEditPermission && useNewFieldsApi; + const [isAddIndexPatternFieldPopoverOpen, setIsAddIndexPatternFieldPopoverOpen] = useState(false); + + const closeFieldEditor = useRef<() => void | undefined>(); + useEffect(() => { + return () => { + // Make sure to close the editor when unmounting + if (closeFieldEditor.current) { + closeFieldEditor.current(); + } + }; + }, []); + + if (indexPatternFieldEditor === undefined || !currentIndexPattern || !canEditIndexPatternField) { + return null; + } + + const addField = () => { + closeFieldEditor.current = indexPatternFieldEditor.openEditor({ + ctx: { + indexPattern: currentIndexPattern, + }, + onSave: () => { + const refresh: Refresh = { + lastRefresh: Date.now(), + }; + dataVisualizerRefresh$.next(refresh); + }, + }); + }; + + return ( + { + setIsAddIndexPatternFieldPopoverOpen(false); + }} + ownFocus + data-test-subj="dataVisualizerIndexPatternManagementPopover" + button={ + { + setIsAddIndexPatternFieldPopoverOpen(!isAddIndexPatternFieldPopoverOpen); + }} + /> + } + > + { + setIsAddIndexPatternFieldPopoverOpen(false); + addField(); + }} + > + {i18n.translate('xpack.dataVisualizer.index.indexPatternManagement.addFieldButton', { + defaultMessage: 'Add field to index pattern', + })} + , + { + setIsAddIndexPatternFieldPopoverOpen(false); + application.navigateToApp('management', { + path: `/kibana/indexPatterns/patterns/${props.currentIndexPattern?.id}`, + }); + }} + > + {i18n.translate('xpack.dataVisualizer.index.indexPatternManagement.manageFieldButton', { + defaultMessage: 'Manage index pattern fields', + })} + , + ]} + /> + + ); +} diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/data_loader/data_loader.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/data_loader/data_loader.ts index 3cb0d4d672f485..468bd3a2bd7ee1 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/data_loader/data_loader.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/data_loader/data_loader.ts @@ -50,9 +50,9 @@ export class DataLoader { const fieldName = field.displayName !== undefined ? field.displayName : field.name; if (this.isDisplayField(fieldName) === true) { if (field.aggregatable === true && field.type !== KBN_FIELD_TYPES.GEO_SHAPE) { - aggregatableFields.push(fieldName); + aggregatableFields.push(field.name); } else { - nonAggregatableFields.push(fieldName); + nonAggregatableFields.push(field.name); } } }); diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx index 82a9b93b31a712..f9e9aece48a060 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx @@ -178,7 +178,16 @@ export const DataVisualizerUrlStateContextProvider: FC { const coreStart = getCoreStart(); - const { data, maps, embeddable, share, security, fileUpload, lens } = getPluginsStart(); + const { + data, + maps, + embeddable, + share, + security, + fileUpload, + lens, + indexPatternFieldEditor, + } = getPluginsStart(); const services = { data, maps, @@ -187,6 +196,7 @@ export const IndexDataVisualizer: FC = () => { security, fileUpload, lens, + indexPatternFieldEditor, ...coreStart, }; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/services/timefilter_refresh_service.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/services/timefilter_refresh_service.ts index 49ef9107c3ecea..11f286e7812195 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/services/timefilter_refresh_service.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/services/timefilter_refresh_service.ts @@ -6,11 +6,10 @@ */ import { Subject } from 'rxjs'; -import { Required } from 'utility-types'; export interface Refresh { lastRefresh: number; timeRange?: { start: string; end: string }; } -export const dataVisualizerTimefilterRefresh$ = new Subject>(); +export const dataVisualizerRefresh$ = new Subject(); diff --git a/x-pack/plugins/data_visualizer/public/application/kibana_context.ts b/x-pack/plugins/data_visualizer/public/application/kibana_context.ts index f7ce13d2fd48d8..58d0ac021ff224 100644 --- a/x-pack/plugins/data_visualizer/public/application/kibana_context.ts +++ b/x-pack/plugins/data_visualizer/public/application/kibana_context.ts @@ -6,8 +6,9 @@ */ import { CoreStart } from 'kibana/public'; -import { useKibana } from '../../../../../src/plugins/kibana_react/public'; +import { KibanaReactContextValue, useKibana } from '../../../../../src/plugins/kibana_react/public'; import type { DataVisualizerStartDependencies } from '../plugin'; export type StartServices = CoreStart & DataVisualizerStartDependencies; +export type DataVisualizerKibanaReactContextValue = KibanaReactContextValue; export const useDataVisualizerKibana = () => useKibana(); diff --git a/x-pack/plugins/data_visualizer/public/plugin.ts b/x-pack/plugins/data_visualizer/public/plugin.ts index 66109de1b14636..4b71b08e9cf270 100644 --- a/x-pack/plugins/data_visualizer/public/plugin.ts +++ b/x-pack/plugins/data_visualizer/public/plugin.ts @@ -17,6 +17,7 @@ import type { FileUploadPluginStart } from '../../file_upload/public'; import type { MapsStartApi } from '../../maps/public'; import type { SecurityPluginSetup } from '../../security/public'; import type { LensPublicStart } from '../../lens/public'; +import type { IndexPatternFieldEditorStart } from '../../../../src/plugins/index_pattern_field_editor/public'; import { getFileDataVisualizerComponent, getIndexDataVisualizerComponent } from './api'; import { getMaxBytesFormatted } from './application/common/util/get_max_bytes'; import { registerHomeAddData, registerHomeFeatureCatalogue } from './register_home'; @@ -32,6 +33,7 @@ export interface DataVisualizerStartDependencies { security?: SecurityPluginSetup; share: SharePluginStart; lens?: LensPublicStart; + indexPatternFieldEditor?: IndexPatternFieldEditorStart; } export type DataVisualizerPluginSetup = ReturnType; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index e3bcf307e6f009..7b3f4571060338 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -27,7 +27,6 @@ "management", "licenseManagement", "maps", - "lens", "usageCollection" ], "server": true, diff --git a/x-pack/plugins/ml/public/__mocks__/ml_start_deps.ts b/x-pack/plugins/ml/public/__mocks__/ml_start_deps.ts index 0907cce832bf85..f16ba275246704 100644 --- a/x-pack/plugins/ml/public/__mocks__/ml_start_deps.ts +++ b/x-pack/plugins/ml/public/__mocks__/ml_start_deps.ts @@ -9,7 +9,6 @@ import { uiActionsPluginMock } from '../../../../../src/plugins/ui_actions/publi import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { kibanaLegacyPluginMock } from '../../../../../src/plugins/kibana_legacy/public/mocks'; import { embeddablePluginMock } from '../../../../../src/plugins/embeddable/public/mocks'; -import { lensPluginMock } from '../../../lens/public/mocks'; import { triggersActionsUiMock } from '../../../triggers_actions_ui/public/mocks'; export const createMlStartDepsMock = () => ({ @@ -22,7 +21,6 @@ export const createMlStartDepsMock = () => ({ spaces: jest.fn(), embeddable: embeddablePluginMock.createStartContract(), maps: jest.fn(), - lens: lensPluginMock.createStartContract(), triggersActionsUi: triggersActionsUiMock.createStart(), dataVisualizer: jest.fn(), }); diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 8be513f372e56c..222d23acb40a74 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -77,7 +77,6 @@ const App: FC = ({ coreStart, deps, appMountParams }) => { data: deps.data, security: deps.security, licenseManagement: deps.licenseManagement, - lens: deps.lens, storage: localStorage, embeddable: deps.embeddable, maps: deps.maps, diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index 841f0d03fa21c4..1ade617fa60a52 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -19,7 +19,6 @@ import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/p import type { EmbeddableStart } from '../../../../../../../src/plugins/embeddable/public'; import type { MapsStartApi } from '../../../../../maps/public'; import type { DataVisualizerPluginStart } from '../../../../../data_visualizer/public'; -import type { LensPublicStart } from '../../../../../lens/public'; import { TriggersAndActionsUIPublicPluginStart } from '../../../../../triggers_actions_ui/public'; interface StartPlugins { @@ -29,7 +28,6 @@ interface StartPlugins { share: SharePluginStart; embeddable: EmbeddableStart; maps?: MapsStartApi; - lens?: LensPublicStart; triggersActionsUi?: TriggersAndActionsUIPublicPluginStart; dataVisualizer?: DataVisualizerPluginStart; } diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index e3a4a8348ebc10..917619a67fea92 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -44,7 +44,6 @@ import { registerFeature } from './register_feature'; // Not importing from `ml_url_generator/index` here to avoid importing unnecessary code import { registerUrlGenerator } from './ml_url_generator/ml_url_generator'; import type { MapsStartApi } from '../../maps/public'; -import { LensPublicStart } from '../../lens/public'; import { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, @@ -62,7 +61,6 @@ export interface MlStartDependencies { spaces?: SpacesPluginStart; embeddable: EmbeddableStart; maps?: MapsStartApi; - lens?: LensPublicStart; triggersActionsUi?: TriggersAndActionsUIPublicPluginStart; dataVisualizer: DataVisualizerPluginStart; } @@ -119,7 +117,6 @@ export class MlPlugin implements Plugin { embeddable: { ...pluginsSetup.embeddable, ...pluginsStart.embeddable }, maps: pluginsStart.maps, uiActions: pluginsStart.uiActions, - lens: pluginsStart.lens, kibanaVersion, triggersActionsUi: pluginsStart.triggersActionsUi, dataVisualizer: pluginsStart.dataVisualizer, diff --git a/x-pack/plugins/ml/tsconfig.json b/x-pack/plugins/ml/tsconfig.json index 221718d4233833..8e859c35e3f853 100644 --- a/x-pack/plugins/ml/tsconfig.json +++ b/x-pack/plugins/ml/tsconfig.json @@ -16,7 +16,7 @@ "../../../typings/**/*", // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 "public/**/*.json", - "server/**/*.json", + "server/**/*.json" ], "references": [ { "path": "../../../src/core/tsconfig.json" }, @@ -28,7 +28,6 @@ { "path": "../license_management/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, { "path": "../maps/tsconfig.json" }, - { "path": "../lens/tsconfig.json" }, { "path": "../security/tsconfig.json" }, { "path": "../spaces/tsconfig.json" }, { "path": "../alerting/tsconfig.json" }, diff --git a/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts b/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts index a8fed205a9e56a..3867ed6f7dfea0 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts @@ -223,6 +223,7 @@ export default function ({ getService }: FtrProviderContext) { fieldRow.docCountFormatted, fieldRow.topValuesCount, false, + false, false ); } diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index.ts b/x-pack/test/functional/apps/ml/data_visualizer/index.ts index 65f7033b5bd66a..3e6b644a0b494a 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index.ts @@ -13,6 +13,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./index_data_visualizer')); loadTestFile(require.resolve('./index_data_visualizer_actions_panel')); + loadTestFile(require.resolve('./index_data_visualizer_index_pattern_management')); loadTestFile(require.resolve('./file_data_visualizer')); }); } diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_index_pattern_management.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_index_pattern_management.ts new file mode 100644 index 00000000000000..0d9163a872043a --- /dev/null +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_index_pattern_management.ts @@ -0,0 +1,274 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { ML_JOB_FIELD_TYPES } from '../../../../../plugins/ml/common/constants/field_types'; +import { FieldVisConfig } from '../../../../../plugins/data_visualizer/public/application/common/components/stats_table/types'; + +interface MetricFieldVisConfig extends FieldVisConfig { + statsMaxDecimalPlaces: number; + docCountFormatted: string; + topValuesCount: number; + viewableInLens: boolean; + hasActionMenu: boolean; +} + +interface NonMetricFieldVisConfig extends FieldVisConfig { + docCountFormatted: string; + exampleCount: number; + viewableInLens: boolean; + hasActionMenu: boolean; +} + +interface TestData { + suiteTitle: string; + sourceIndexOrSavedSearch: string; + rowsPerPage?: 10 | 25 | 50; + newFields?: Array<{ fieldName: string; type: string; script: string }>; + fieldsToRename?: Array<{ originalName: string; newName: string }>; + expected: { + totalDocCountFormatted: string; + metricFields?: MetricFieldVisConfig[]; + nonMetricFields?: NonMetricFieldVisConfig[]; + visibleMetricFieldsCount: number; + totalMetricFieldsCount: number; + populatedFieldsCount: number; + totalFieldsCount: number; + }; +} + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + + const originalTestData: TestData = { + suiteTitle: 'original index pattern', + sourceIndexOrSavedSearch: 'ft_farequote', + expected: { + totalDocCountFormatted: '86,274', + metricFields: [], + nonMetricFields: [], + visibleMetricFieldsCount: 1, + totalMetricFieldsCount: 1, + populatedFieldsCount: 7, + totalFieldsCount: 8, + }, + }; + const addDeleteFieldTestData: TestData = { + suiteTitle: 'add field', + sourceIndexOrSavedSearch: 'ft_farequote', + newFields: [ + { + fieldName: 'rt_airline_lowercase', + type: 'Keyword', + script: 'emit(params._source.airline.toLowerCase())', + }, + ], + expected: { + totalDocCountFormatted: '86,274', + metricFields: [], + nonMetricFields: [ + { + fieldName: 'rt_airline_lowercase', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 10, + docCountFormatted: '5000 (100%)', + viewableInLens: true, + hasActionMenu: true, + }, + ], + visibleMetricFieldsCount: 2, + totalMetricFieldsCount: 2, + populatedFieldsCount: 9, + totalFieldsCount: 10, + }, + }; + const customLabelTestData: TestData = { + suiteTitle: 'custom label', + sourceIndexOrSavedSearch: 'ft_farequote', + fieldsToRename: [ + { + originalName: 'responsetime', + newName: 'new_responsetime', + }, + ], + expected: { + totalDocCountFormatted: '86,274', + metricFields: [ + { + fieldName: 'new_responsetime', + type: ML_JOB_FIELD_TYPES.NUMBER, + existsInDocs: true, + aggregatable: true, + loading: false, + docCountFormatted: '5000 (100%)', + statsMaxDecimalPlaces: 3, + topValuesCount: 10, + viewableInLens: true, + hasActionMenu: false, + }, + ], + nonMetricFields: [], + visibleMetricFieldsCount: 1, + totalMetricFieldsCount: 1, + populatedFieldsCount: 7, + totalFieldsCount: 8, + }, + }; + + async function navigateToIndexDataVisualizer(testData: TestData) { + // Start navigation from the base of the ML app. + await ml.testExecution.logTestStep( + `${testData.suiteTitle} loads the data visualizer selector page` + ); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToDataVisualizer(); + + await ml.testExecution.logTestStep( + `${testData.suiteTitle} loads the saved search selection page` + ); + await ml.dataVisualizer.navigateToIndexPatternSelection(); + + await ml.testExecution.logTestStep( + `${testData.suiteTitle} loads the index data visualizer page` + ); + await ml.jobSourceSelection.selectSourceForIndexBasedDataVisualizer( + testData.sourceIndexOrSavedSearch + ); + + await ml.testExecution.logTestStep(`${testData.suiteTitle} displays the time range step`); + await ml.dataVisualizerIndexBased.assertTimeRangeSelectorSectionExists(); + + await ml.testExecution.logTestStep(`${testData.suiteTitle} loads data for full time range`); + await ml.dataVisualizerIndexBased.clickUseFullDataButton( + testData.expected.totalDocCountFormatted + ); + } + + async function checkPageDetails(testData: TestData) { + await ml.testExecution.logTestStep( + `${testData.suiteTitle} displays elements in the doc count panel correctly` + ); + await ml.dataVisualizerIndexBased.assertTotalDocCountHeaderExist(); + await ml.dataVisualizerIndexBased.assertTotalDocCountChartExist(); + + await ml.testExecution.logTestStep( + `${testData.suiteTitle} displays elements in the data visualizer table correctly` + ); + await ml.dataVisualizerIndexBased.assertDataVisualizerTableExist(); + + if (testData.rowsPerPage) { + await ml.dataVisualizerTable.ensureNumRowsPerPage(testData.rowsPerPage); + } + + await ml.dataVisualizerTable.assertSearchPanelExist(); + await ml.dataVisualizerTable.assertSampleSizeInputExists(); + await ml.dataVisualizerTable.assertFieldTypeInputExists(); + await ml.dataVisualizerTable.assertFieldNameInputExists(); + + await ml.dataVisualizerIndexBased.assertFieldCountPanelExist(); + await ml.dataVisualizerIndexBased.assertMetricFieldsSummaryExist(); + await ml.dataVisualizerIndexBased.assertFieldsSummaryExist(); + await ml.dataVisualizerIndexBased.assertVisibleMetricFieldsCount( + testData.expected.visibleMetricFieldsCount + ); + await ml.dataVisualizerIndexBased.assertTotalMetricFieldsCount( + testData.expected.totalMetricFieldsCount + ); + await ml.dataVisualizerIndexBased.assertVisibleFieldsCount( + testData.expected.populatedFieldsCount + ); + await ml.dataVisualizerIndexBased.assertTotalFieldsCount(testData.expected.totalFieldsCount); + } + + describe('index pattern management', function () { + this.tags(['mlqa']); + const indexPatternTitle = 'ft_farequote'; + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); + + await ml.testResources.setKibanaTimeZoneToUTC(); + await ml.securityUI.loginAsMlPowerUser(); + }); + + beforeEach(async () => { + await ml.testResources.createIndexPatternIfNeeded(indexPatternTitle, '@timestamp'); + await navigateToIndexDataVisualizer(originalTestData); + }); + + afterEach(async () => { + await ml.testResources.deleteIndexPatternByTitle(indexPatternTitle); + }); + + it(`adds new field`, async () => { + await ml.testExecution.logTestStep('adds new runtime fields'); + for (const newField of addDeleteFieldTestData.newFields!) { + await ml.dataVisualizerIndexPatternManagement.addRuntimeField( + newField.fieldName, + newField.script, + newField.type + ); + } + + await ml.testExecution.logTestStep('displays details for added runtime metric fields'); + for (const fieldRow of addDeleteFieldTestData.expected.metricFields as Array< + Required + >) { + await ml.dataVisualizerTable.assertNumberFieldContents( + fieldRow.fieldName, + fieldRow.docCountFormatted, + fieldRow.topValuesCount, + fieldRow.viewableInLens, + fieldRow.hasActionMenu + ); + } + await ml.testExecution.logTestStep('displays details for added runtime non metric fields'); + for (const fieldRow of addDeleteFieldTestData.expected.nonMetricFields!) { + await ml.dataVisualizerTable.assertNonMetricFieldContents( + fieldRow.type, + fieldRow.fieldName!, + fieldRow.docCountFormatted, + fieldRow.exampleCount, + fieldRow.viewableInLens, + fieldRow.hasActionMenu + ); + } + await checkPageDetails(addDeleteFieldTestData); + }); + + it(`sets custom label for existing field`, async () => { + for (const field of customLabelTestData.fieldsToRename!) { + await ml.dataVisualizerIndexPatternManagement.renameField( + field.originalName, + field.newName + ); + await ml.dataVisualizerTable.assertDisplayName(field.originalName, field.newName); + } + }); + + it(`deletes existing field`, async () => { + await ml.testExecution.logTestStep('adds new runtime fields'); + for (const newField of addDeleteFieldTestData.newFields!) { + await ml.dataVisualizerIndexPatternManagement.addRuntimeField( + newField.fieldName, + newField.script, + newField.type + ); + } + await ml.testExecution.logTestStep('deletes newly added runtime fields'); + for (const fieldToDelete of addDeleteFieldTestData.newFields!) { + await ml.dataVisualizerIndexPatternManagement.deleteField(fieldToDelete.fieldName); + } + + await ml.testExecution.logTestStep('displays page details without the deleted fields'); + await checkPageDetails(originalTestData); + }); + }); +} diff --git a/x-pack/test/functional/services/ml/data_visualizer_index_pattern_management.ts b/x-pack/test/functional/services/ml/data_visualizer_index_pattern_management.ts new file mode 100644 index 00000000000000..e5d884b22514b3 --- /dev/null +++ b/x-pack/test/functional/services/ml/data_visualizer_index_pattern_management.ts @@ -0,0 +1,118 @@ +/* + * Copyright 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'; +import { MlDataVisualizerTable } from './data_visualizer_table'; + +export function MachineLearningDataVisualizerIndexPatternManagementProvider( + { getService }: FtrProviderContext, + dataVisualizerTable: MlDataVisualizerTable +) { + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const fieldEditor = getService('fieldEditor'); + const comboBox = getService('comboBox'); + + return { + async assertIndexPatternManagementButtonExists() { + await testSubjects.existOrFail('dataVisualizerIndexPatternManagementButton'); + }, + async assertIndexPatternManagementMenuExists() { + await testSubjects.existOrFail('dataVisualizerIndexPatternManagementMenu'); + }, + async assertIndexPatternFieldEditorExists() { + await testSubjects.existOrFail('indexPatternFieldEditorForm'); + }, + + async assertIndexPatternFieldEditorNotExist() { + await testSubjects.missingOrFail('indexPatternFieldEditorForm'); + }, + + async clickIndexPatternManagementButton() { + await retry.tryForTime(5000, async () => { + await testSubjects.clickWhenNotDisabled('dataVisualizerIndexPatternManagementButton'); + await this.assertIndexPatternManagementMenuExists(); + }); + }, + async clickAddIndexPatternFieldAction() { + await retry.tryForTime(5000, async () => { + await this.assertIndexPatternManagementMenuExists(); + await testSubjects.clickWhenNotDisabled('dataVisualizerAddIndexPatternFieldAction'); + await this.assertIndexPatternFieldEditorExists(); + }); + }, + + async clickManageIndexPatternAction() { + await retry.tryForTime(5000, async () => { + await this.assertIndexPatternManagementMenuExists(); + await testSubjects.clickWhenNotDisabled('dataVisualizerManageIndexPatternAction'); + await testSubjects.existOrFail('editIndexPattern'); + }); + }, + + async assertIndexPatternFieldEditorFieldType(expectedIdentifier: string) { + await retry.tryForTime(2000, async () => { + const comboBoxSelectedOptions = await comboBox.getComboBoxSelectedOptions( + 'typeField > comboBoxInput' + ); + expect(comboBoxSelectedOptions).to.eql( + expectedIdentifier === '' ? [] : [expectedIdentifier], + `Expected type field to be '${expectedIdentifier}' (got '${comboBoxSelectedOptions}')` + ); + }); + }, + + async setIndexPatternFieldEditorFieldType(type: string) { + await comboBox.set('typeField > comboBoxInput', type); + + await this.assertIndexPatternFieldEditorFieldType(type); + }, + + async addRuntimeField(name: string, script: string, fieldType: string) { + await retry.tryForTime(5000, async () => { + await this.clickIndexPatternManagementButton(); + await this.clickAddIndexPatternFieldAction(); + + await this.assertIndexPatternFieldEditorExists(); + await fieldEditor.setName(name); + await fieldEditor.enableValue(); + await fieldEditor.typeScript(script); + await this.setIndexPatternFieldEditorFieldType(fieldType); + + await fieldEditor.save(); + await this.assertIndexPatternFieldEditorNotExist(); + }); + }, + + async renameField(originalName: string, newName: string) { + await retry.tryForTime(5000, async () => { + await dataVisualizerTable.clickEditIndexPatternFieldButton(originalName); + await this.assertIndexPatternFieldEditorExists(); + await fieldEditor.enableCustomLabel(); + await fieldEditor.setCustomLabel(newName); + await fieldEditor.save(); + await this.assertIndexPatternFieldEditorNotExist(); + }); + }, + + async confirmDeleteField() { + await testSubjects.existOrFail('deleteModalConfirmText'); + await testSubjects.setValue('deleteModalConfirmText', 'remove'); + await testSubjects.click('confirmModalConfirmButton'); + await testSubjects.missingOrFail('deleteModalConfirmText'); + }, + + async deleteField(fieldName: string) { + await retry.tryForTime(5000, async () => { + await dataVisualizerTable.clickActionMenuDeleteIndexPatternFieldButton(fieldName); + await this.confirmDeleteField(); + await dataVisualizerTable.assertRowNotExists(fieldName); + }); + }, + }; +} diff --git a/x-pack/test/functional/services/ml/data_visualizer_table.ts b/x-pack/test/functional/services/ml/data_visualizer_table.ts index 1eb0edbe01c8ef..2f67a9b75e3d6f 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_table.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_table.ts @@ -18,6 +18,8 @@ export function MachineLearningDataVisualizerTableProvider( ) { const retry = getService('retry'); const testSubjects = getService('testSubjects'); + const find = getService('find'); + const browser = getService('browser'); return new (class DataVisualizerTable { public async parseDataVisualizerTable() { @@ -79,6 +81,25 @@ export function MachineLearningDataVisualizerTableProvider( await testSubjects.existOrFail(this.rowSelector(fieldName)); } + public async assertRowNotExists(fieldName: string) { + await retry.tryForTime(1000, async () => { + await testSubjects.missingOrFail(this.rowSelector(fieldName)); + }); + } + + public async assertDisplayName(fieldName: string, expectedDisplayName: string) { + await retry.tryForTime(10000, async () => { + const subj = await testSubjects.find( + this.rowSelector(fieldName, `dataVisualizerDisplayName-${fieldName}`) + ); + const displayName = await subj.getVisibleText(); + expect(displayName).to.eql( + expectedDisplayName, + `Expected display name of ${fieldName} to be '${expectedDisplayName}' (got '${displayName}')` + ); + }); + } + public detailsSelector(fieldName: string, subSelector?: string) { const row = `~dataVisualizerTable > ~dataVisualizerFieldExpandedRow-${fieldName}`; return !subSelector ? row : `${row} > ${subSelector}`; @@ -133,10 +154,85 @@ export function MachineLearningDataVisualizerTableProvider( ); } - public async assertViewInLensActionEnabled(fieldName: string) { + public async ensureAllMenuPopoversClosed() { + await retry.tryForTime(5000, async () => { + await browser.pressKeys(browser.keys.ESCAPE); + const popoverExists = await find.existsByCssSelector('euiContextMenuPanel'); + expect(popoverExists).to.eql(false, 'All popovers should be closed'); + }); + } + + public async ensureActionsMenuOpen(fieldName: string) { + await retry.tryForTime(30 * 1000, async () => { + await this.ensureAllMenuPopoversClosed(); + await testSubjects.click(this.rowSelector(fieldName, 'euiCollapsedItemActionsButton')); + await find.existsByCssSelector('euiContextMenuPanel'); + }); + } + + public async assertActionsMenuClosed(fieldName: string, action: string) { + await retry.tryForTime(30 * 1000, async () => { + await testSubjects.missingOrFail(action, { timeout: 5000 }); + }); + } + + public async assertActionMenuViewInLensEnabled(fieldName: string, expectedValue: boolean) { + await retry.tryForTime(30 * 1000, async () => { + await this.ensureActionsMenuOpen(fieldName); + const actionMenuViewInLensButton = await find.byCssSelector( + '[data-test-subj="dataVisualizerActionViewInLensButton"][class="euiContextMenuItem"]' + ); + const isEnabled = await actionMenuViewInLensButton.isEnabled(); + expect(isEnabled).to.eql( + expectedValue, + `Expected "Explore in lens" action menu button for '${fieldName}' to be '${ + expectedValue ? 'enabled' : 'disabled' + }' (got '${isEnabled ? 'enabled' : 'disabled'}')` + ); + }); + } + + public async assertActionMenuDeleteIndexPatternFieldButtonEnabled( + fieldName: string, + expectedValue: boolean + ) { + await this.ensureActionsMenuOpen(fieldName); + const actionMenuViewInLensButton = await find.byCssSelector( + '[data-test-subj="dataVisualizerActionDeleteIndexPatternFieldButton"][class="euiContextMenuItem"]' + ); + const isEnabled = await actionMenuViewInLensButton.isEnabled(); + expect(isEnabled).to.eql( + expectedValue, + `Expected "Delete index pattern field" action menu button for '${fieldName}' to be '${ + expectedValue ? 'enabled' : 'disabled' + }' (got '${isEnabled ? 'enabled' : 'disabled'}')` + ); + } + + public async clickActionMenuDeleteIndexPatternFieldButton(fieldName: string) { + const testSubj = 'dataVisualizerActionDeleteIndexPatternFieldButton'; + await retry.tryForTime(5000, async () => { + await this.ensureActionsMenuOpen(fieldName); + + const button = await find.byCssSelector( + `[data-test-subj="${testSubj}"][class="euiContextMenuItem"]` + ); + await button.click(); + await this.assertActionsMenuClosed(fieldName, testSubj); + await testSubjects.existOrFail('runtimeFieldDeleteConfirmModal'); + }); + } + + public async assertViewInLensActionEnabled(fieldName: string, expectedValue: boolean) { const actionButton = this.rowSelector(fieldName, 'dataVisualizerActionViewInLensButton'); await testSubjects.existOrFail(actionButton); - await testSubjects.isEnabled(actionButton); + const isEnabled = await testSubjects.isEnabled(actionButton); + expect(isEnabled).to.eql( + expectedValue, + `Expected "Explore in lens" button for '${fieldName}' to be '${ + expectedValue ? 'enabled' : 'disabled' + }' (got '${isEnabled ? 'enabled' : 'disabled'}')` + ); } public async assertViewInLensActionNotExists(fieldName: string) { @@ -144,6 +240,34 @@ export function MachineLearningDataVisualizerTableProvider( await testSubjects.missingOrFail(actionButton); } + public async assertEditIndexPatternFieldButtonEnabled( + fieldName: string, + expectedValue: boolean + ) { + const selector = this.rowSelector( + fieldName, + 'dataVisualizerActionEditIndexPatternFieldButton' + ); + await testSubjects.existOrFail(selector); + const isEnabled = await testSubjects.isEnabled(selector); + expect(isEnabled).to.eql( + expectedValue, + `Expected "Edit index pattern" button for '${fieldName}' to be '${ + expectedValue ? 'enabled' : 'disabled' + }' (got '${isEnabled ? 'enabled' : 'disabled'}')` + ); + } + + public async clickEditIndexPatternFieldButton(fieldName: string) { + await retry.tryForTime(5000, async () => { + await this.assertEditIndexPatternFieldButtonEnabled(fieldName, true); + await testSubjects.click( + this.rowSelector(fieldName, 'dataVisualizerActionEditIndexPatternFieldButton') + ); + await testSubjects.existOrFail('indexPatternFieldEditorForm'); + }); + } + public async assertFieldDistinctValuesExist(fieldName: string) { const selector = this.rowSelector(fieldName, 'dataVisualizerTableColumnDistinctValues'); await testSubjects.existOrFail(selector); @@ -263,6 +387,7 @@ export function MachineLearningDataVisualizerTableProvider( docCountFormatted: string, topValuesCount: number, viewableInLens: boolean, + hasActionMenu = false, checkDistributionPreviewExist = true ) { await this.assertRowExists(fieldName); @@ -282,7 +407,11 @@ export function MachineLearningDataVisualizerTableProvider( await this.assertDistributionPreviewExist(fieldName); } if (viewableInLens) { - await this.assertViewInLensActionEnabled(fieldName); + if (hasActionMenu) { + await this.assertActionMenuViewInLensEnabled(fieldName, true); + } else { + await this.assertViewInLensActionEnabled(fieldName, true); + } } else { await this.assertViewInLensActionNotExists(fieldName); } @@ -378,7 +507,8 @@ export function MachineLearningDataVisualizerTableProvider( fieldName: string, docCountFormatted: string, exampleCount: number, - viewableInLens: boolean + viewableInLens: boolean, + hasActionMenu?: boolean ) { // Currently the data used in the data visualizer tests only contains these field types. if (fieldType === ML_JOB_FIELD_TYPES.DATE) { @@ -394,7 +524,11 @@ export function MachineLearningDataVisualizerTableProvider( } if (viewableInLens) { - await this.assertViewInLensActionEnabled(fieldName); + if (hasActionMenu) { + await this.assertActionMenuViewInLensEnabled(fieldName, true); + } else { + await this.assertViewInLensActionEnabled(fieldName, true); + } } else { await this.assertViewInLensActionNotExists(fieldName); } diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts index 64298bbdedd63c..2cc9a3afa442b3 100644 --- a/x-pack/test/functional/services/ml/index.ts +++ b/x-pack/test/functional/services/ml/index.ts @@ -23,6 +23,7 @@ import { MachineLearningDataFrameAnalyticsTableProvider } from './data_frame_ana import { MachineLearningDataVisualizerProvider } from './data_visualizer'; import { MachineLearningDataVisualizerFileBasedProvider } from './data_visualizer_file_based'; import { MachineLearningDataVisualizerIndexBasedProvider } from './data_visualizer_index_based'; +import { MachineLearningDataVisualizerIndexPatternManagementProvider } from './data_visualizer_index_pattern_management'; import { MachineLearningJobManagementProvider } from './job_management'; import { MachineLearningJobSelectionProvider } from './job_selection'; import { MachineLearningJobSourceSelectionProvider } from './job_source_selection'; @@ -86,6 +87,10 @@ export function MachineLearningProvider(context: FtrProviderContext) { const dataVisualizerFileBased = MachineLearningDataVisualizerFileBasedProvider(context, commonUI); const dataVisualizerIndexBased = MachineLearningDataVisualizerIndexBasedProvider(context); + const dataVisualizerIndexPatternManagement = MachineLearningDataVisualizerIndexPatternManagementProvider( + context, + dataVisualizerTable + ); const jobManagement = MachineLearningJobManagementProvider(context, api); const jobSelection = MachineLearningJobSelectionProvider(context); @@ -131,6 +136,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { dataVisualizer, dataVisualizerFileBased, dataVisualizerIndexBased, + dataVisualizerIndexPatternManagement, dataVisualizerTable, jobManagement, jobSelection, From 874dfc62f41e604369ba906f82036342c9b7ddfe Mon Sep 17 00:00:00 2001 From: ymao1 Date: Wed, 23 Jun 2021 14:37:31 -0400 Subject: [PATCH 44/61] [Actions] Rename `tls.*` configs to `ssl.*` (#102902) * Changing tls to ssl * Changing tls to ssl * Updating docs Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/settings/alert-action-settings.asciidoc | 34 ++++++++--------- .../resources/base/bin/kibana-docker | 4 +- .../actions/server/actions_client.test.ts | 2 +- .../actions/server/actions_config.mock.ts | 2 +- .../actions/server/actions_config.test.ts | 38 +++++++++---------- .../plugins/actions/server/actions_config.ts | 14 +++---- .../server/builtin_action_types/email.test.ts | 4 +- .../lib/axios_utils.test.ts | 12 +++--- .../lib/axios_utils_connection.test.ts | 22 +++++------ .../lib/get_custom_agents.test.ts | 38 +++++++++---------- .../lib/get_custom_agents.ts | 38 +++++++++---------- ...s.test.ts => get_node_ssl_options.test.ts} | 28 +++++++------- ...tls_options.ts => get_node_ssl_options.ts} | 8 ++-- .../lib/send_email.test.ts | 18 ++++----- .../builtin_action_types/lib/send_email.ts | 24 ++++++------ .../server/builtin_action_types/slack.test.ts | 10 ++--- .../server/builtin_action_types/teams.test.ts | 4 +- .../builtin_action_types/webhook.test.ts | 4 +- x-pack/plugins/actions/server/config.test.ts | 6 +-- x-pack/plugins/actions/server/config.ts | 8 ++-- x-pack/plugins/actions/server/index.ts | 10 ++--- .../server/lib/custom_host_settings.test.ts | 26 ++++++------- .../server/lib/custom_host_settings.ts | 14 +++---- x-pack/plugins/actions/server/types.ts | 4 +- .../alerting_api_integration/common/config.ts | 22 +++++------ .../tests/actions/get_all.ts | 24 ++++++------ .../spaces_only/config.ts | 2 +- .../actions/builtin_action_types/webhook.ts | 16 ++++---- .../spaces_only/tests/actions/get_all.ts | 24 ++++++------ .../spaces_only_legacy/config.ts | 2 +- .../actions/builtin_action_types/webhook.ts | 16 ++++---- 31 files changed, 239 insertions(+), 239 deletions(-) rename x-pack/plugins/actions/server/builtin_action_types/lib/{get_node_tls_options.test.ts => get_node_ssl_options.test.ts} (67%) rename x-pack/plugins/actions/server/builtin_action_types/lib/{get_node_tls_options.ts => get_node_ssl_options.ts} (92%) diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 71f141d1ed5d6e..d1d283ca60fbbb 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -69,7 +69,7 @@ You can configure the following settings in the `kibana.yml` file. -- xpack.actions.customHostSettings: - url: smtp://mail.example.com:465 - tls: + ssl: verificationMode: 'full' certificateAuthoritiesFiles: [ 'one.crt' ] certificateAuthoritiesData: | @@ -79,7 +79,7 @@ xpack.actions.customHostSettings: smtp: requireTLS: true - url: https://webhook.example.com - tls: + ssl: // legacy rejectUnauthorized: false verificationMode: 'none' @@ -97,8 +97,8 @@ xpack.actions.customHostSettings: server, and the `https` URLs are used for actions which use `https` to connect to services. + + - Entries with `https` URLs can use the `tls` options, and entries with `smtp` - URLs can use both the `tls` and `smtp` options. + + Entries with `https` URLs can use the `ssl` options, and entries with `smtp` + URLs can use both the `ssl` and `smtp` options. + + No other URL values should be part of this URL, including paths, query strings, and authentication information. When an http or smtp request @@ -117,24 +117,24 @@ xpack.actions.customHostSettings: The options `smtp.ignoreTLS` and `smtp.requireTLS` can not both be set to true. | `xpack.actions.customHostSettings[n]` -`.tls.rejectUnauthorized` {ess-icon} - | Deprecated. Use <> instead. A boolean value indicating whether to bypass server certificate validation. +`.ssl.rejectUnauthorized` {ess-icon} + | Deprecated. Use <> instead. A boolean value indicating whether to bypass server certificate validation. Overrides the general `xpack.actions.rejectUnauthorized` configuration for requests made for this hostname/port. |[[action-config-custom-host-verification-mode]] `xpack.actions.customHostSettings[n]` -`.tls.verificationMode` +`.ssl.verificationMode` | Controls the verification of the server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection to the host server. Valid values are `full`, `certificate`, and `none`. - Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. Overrides the general `xpack.actions.tls.verificationMode` configuration + Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. Overrides the general `xpack.actions.ssl.verificationMode` configuration for requests made for this hostname/port. | `xpack.actions.customHostSettings[n]` -`.tls.certificateAuthoritiesFiles` +`.ssl.certificateAuthoritiesFiles` | A file name or list of file names of PEM-encoded certificate files to use to validate the server. | `xpack.actions.customHostSettings[n]` -`.tls.certificateAuthoritiesData` {ess-icon} +`.ssl.certificateAuthoritiesData` {ess-icon} | The contents of a PEM-encoded certificate file, or multiple files appended into a single string. This configuration can be used for environments where the files cannot be made available. @@ -165,28 +165,28 @@ xpack.actions.customHostSettings: a|`xpack.actions.` `proxyRejectUnauthorizedCertificates` {ess-icon} - | Deprecated. Use <> instead. Set to `false` to bypass certificate validation for the proxy, if using a proxy for actions. Default: `true`. + | Deprecated. Use <> instead. Set to `false` to bypass certificate validation for the proxy, if using a proxy for actions. Default: `true`. |[[action-config-proxy-verification-mode]] `xpack.actions[n]` -`.tls.proxyVerificationMode` {ess-icon} +`.ssl.proxyVerificationMode` {ess-icon} | Controls the verification for the proxy server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection to the proxy server. Valid values are `full`, `certificate`, and `none`. Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. | `xpack.actions.rejectUnauthorized` {ess-icon} - | Deprecated. Use <> instead. Set to `false` to bypass certificate validation for actions. Default: `true`. + + | Deprecated. Use <> instead. Set to `false` to bypass certificate validation for actions. Default: `true`. + + As an alternative to setting `xpack.actions.rejectUnauthorized`, you can use the setting - `xpack.actions.customHostSettings` to set TLS options for specific servers. + `xpack.actions.customHostSettings` to set SSL options for specific servers. |[[action-config-verification-mode]] `xpack.actions[n]` -`.tls.verificationMode` {ess-icon} +`.ssl.verificationMode` {ess-icon} | Controls the verification for the server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection for actions. Valid values are `full`, `certificate`, and `none`. Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. + + - As an alternative to setting `xpack.actions.tls.verificationMode`, you can use the setting - `xpack.actions.customHostSettings` to set TLS options for specific servers. + As an alternative to setting `xpack.actions.ssl.verificationMode`, you can use the setting + `xpack.actions.customHostSettings` to set SSL options for specific servers. diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 9ea6e8960e3734..d109a824ca81de 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -203,8 +203,8 @@ kibana_vars=( xpack.actions.proxyUrl xpack.actions.rejectUnauthorized xpack.actions.responseTimeout - xpack.actions.tls.proxyVerificationMode - xpack.actions.tls.verificationMode + xpack.actions.ssl.proxyVerificationMode + xpack.actions.ssl.verificationMode xpack.alerting.healthCheck.interval xpack.alerting.invalidateApiKeysTask.interval xpack.alerting.invalidateApiKeysTask.removalDelay diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 16388b2faf52e1..012cd1a58de7e1 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -429,7 +429,7 @@ describe('create()', () => { idleInterval: schema.duration().validate('1h'), pageSize: 100, }, - tls: { + ssl: { verificationMode: 'full', proxyVerificationMode: 'full', }, diff --git a/x-pack/plugins/actions/server/actions_config.mock.ts b/x-pack/plugins/actions/server/actions_config.mock.ts index 19a43951377b67..36298d84acabcf 100644 --- a/x-pack/plugins/actions/server/actions_config.mock.ts +++ b/x-pack/plugins/actions/server/actions_config.mock.ts @@ -15,7 +15,7 @@ const createActionsConfigMock = () => { ensureHostnameAllowed: jest.fn().mockReturnValue({}), ensureUriAllowed: jest.fn().mockReturnValue({}), ensureActionTypeEnabled: jest.fn().mockReturnValue({}), - getTLSSettings: jest.fn().mockReturnValue({ + getSSLSettings: jest.fn().mockReturnValue({ verificationMode: 'full', }), getProxySettings: jest.fn().mockReturnValue(undefined), diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index 93dad226e0c99b..51cd9e55994729 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -37,7 +37,7 @@ const defaultActionsConfig: ActionsConfig = { idleInterval: schema.duration().validate('1h'), pageSize: 100, }, - tls: { + ssl: { proxyVerificationMode: 'full', verificationMode: 'full', }, @@ -316,38 +316,38 @@ describe('getProxySettings', () => { proxyRejectUnauthorizedCertificates: true, }; let proxySettings = getActionsConfigurationUtilities(configTrue).getProxySettings(); - expect(proxySettings?.proxyTLSSettings.verificationMode).toBe('full'); + expect(proxySettings?.proxySSLSettings.verificationMode).toBe('full'); const configFalse: ActionsConfig = { ...defaultActionsConfig, proxyUrl: 'https://proxy.elastic.co', proxyRejectUnauthorizedCertificates: false, - tls: {}, + ssl: {}, }; proxySettings = getActionsConfigurationUtilities(configFalse).getProxySettings(); - expect(proxySettings?.proxyTLSSettings.verificationMode).toBe('none'); + expect(proxySettings?.proxySSLSettings.verificationMode).toBe('none'); }); - test('returns proper verificationMode value, based on the TLS proxy configuration', () => { + test('returns proper verificationMode value, based on the SSL proxy configuration', () => { const configTrue: ActionsConfig = { ...defaultActionsConfig, proxyUrl: 'https://proxy.elastic.co', - tls: { + ssl: { proxyVerificationMode: 'full', }, }; let proxySettings = getActionsConfigurationUtilities(configTrue).getProxySettings(); - expect(proxySettings?.proxyTLSSettings.verificationMode).toBe('full'); + expect(proxySettings?.proxySSLSettings.verificationMode).toBe('full'); const configFalse: ActionsConfig = { ...defaultActionsConfig, proxyUrl: 'https://proxy.elastic.co', - tls: { + ssl: { proxyVerificationMode: 'none', }, }; proxySettings = getActionsConfigurationUtilities(configFalse).getProxySettings(); - expect(proxySettings?.proxyTLSSettings.verificationMode).toBe('none'); + expect(proxySettings?.proxySSLSettings.verificationMode).toBe('none'); }); test('returns proxy headers', () => { @@ -432,13 +432,13 @@ describe('getProxySettings', () => { customHostSettings: [ { url: 'https://elastic.co', - tls: { + ssl: { verificationMode: 'full', }, }, { url: 'smtp://elastic.co:123', - tls: { + ssl: { verificationMode: 'none', }, smtp: { @@ -465,24 +465,24 @@ describe('getProxySettings', () => { }); }); -describe('getTLSSettings', () => { - test('returns proper verificationMode value, based on the TLS proxy configuration', () => { +describe('getSSLSettings', () => { + test('returns proper verificationMode value, based on the SSL proxy configuration', () => { const configTrue: ActionsConfig = { ...defaultActionsConfig, - tls: { + ssl: { verificationMode: 'full', }, }; - let tlsSettings = getActionsConfigurationUtilities(configTrue).getTLSSettings(); - expect(tlsSettings.verificationMode).toBe('full'); + let sslSettings = getActionsConfigurationUtilities(configTrue).getSSLSettings(); + expect(sslSettings.verificationMode).toBe('full'); const configFalse: ActionsConfig = { ...defaultActionsConfig, - tls: { + ssl: { verificationMode: 'none', }, }; - tlsSettings = getActionsConfigurationUtilities(configFalse).getTLSSettings(); - expect(tlsSettings.verificationMode).toBe('none'); + sslSettings = getActionsConfigurationUtilities(configFalse).getSSLSettings(); + expect(sslSettings.verificationMode).toBe('none'); }); }); diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index d25101f8279f88..9ce9439b726d4c 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -14,8 +14,8 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { ActionsConfig, AllowedHosts, EnabledActionTypes, CustomHostSettings } from './config'; import { getCanonicalCustomHostUrl } from './lib/custom_host_settings'; import { ActionTypeDisabledError } from './lib'; -import { ProxySettings, ResponseSettings, TLSSettings } from './types'; -import { getTLSSettingsFromConfig } from './builtin_action_types/lib/get_node_tls_options'; +import { ProxySettings, ResponseSettings, SSLSettings } from './types'; +import { getSSLSettingsFromConfig } from './builtin_action_types/lib/get_node_ssl_options'; export { AllowedHosts, EnabledActionTypes } from './config'; @@ -31,7 +31,7 @@ export interface ActionsConfigurationUtilities { ensureHostnameAllowed: (hostname: string) => void; ensureUriAllowed: (uri: string) => void; ensureActionTypeEnabled: (actionType: string) => void; - getTLSSettings: () => TLSSettings; + getSSLSettings: () => SSLSettings; getProxySettings: () => undefined | ProxySettings; getResponseSettings: () => ResponseSettings; getCustomHostSettings: (targetUrl: string) => CustomHostSettings | undefined; @@ -94,8 +94,8 @@ function getProxySettingsFromConfig(config: ActionsConfig): undefined | ProxySet proxyBypassHosts: arrayAsSet(config.proxyBypassHosts), proxyOnlyHosts: arrayAsSet(config.proxyOnlyHosts), proxyHeaders: config.proxyHeaders, - proxyTLSSettings: getTLSSettingsFromConfig( - config.tls?.proxyVerificationMode, + proxySSLSettings: getSSLSettingsFromConfig( + config.ssl?.proxyVerificationMode, config.proxyRejectUnauthorizedCertificates ), }; @@ -146,8 +146,8 @@ export function getActionsConfigurationUtilities( isActionTypeEnabled, getProxySettings: () => getProxySettingsFromConfig(config), getResponseSettings: () => getResponseSettingsFromConfig(config), - getTLSSettings: () => - getTLSSettingsFromConfig(config.tls?.verificationMode, config.rejectUnauthorized), + getSSLSettings: () => + getSSLSettingsFromConfig(config.ssl?.verificationMode, config.rejectUnauthorized), ensureUriAllowed(uri: string) { if (!isUriAllowed(uri)) { throw new Error(allowListErrorMessage(AllowListingField.URL, uri)); diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 98ea436b17f3e1..8e9ea1c5e4aa9f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -285,7 +285,7 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], - "getTLSSettings": [MockFunction], + "getSSLSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], "isUriAllowed": [MockFunction], @@ -346,7 +346,7 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], - "getTLSSettings": [MockFunction], + "getSSLSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], "isUriAllowed": [MockFunction], diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts index ccd5a044971dfc..292471aaf9b6dd 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts @@ -75,7 +75,7 @@ describe('request', () => { test('it have been called with proper proxy agent for a valid url', async () => { configurationUtilities.getProxySettings.mockReturnValue({ - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'full', }, proxyUrl: 'https://localhost:1212', @@ -110,7 +110,7 @@ describe('request', () => { test('it have been called with proper proxy agent for an invalid url', async () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: ':nope:', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -141,7 +141,7 @@ describe('request', () => { test('it bypasses with proxyBypassHosts when expected', async () => { configurationUtilities.getProxySettings.mockReturnValue({ - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'full', }, proxyUrl: 'https://elastic.proxy.co', @@ -164,7 +164,7 @@ describe('request', () => { test('it does not bypass with proxyBypassHosts when expected', async () => { configurationUtilities.getProxySettings.mockReturnValue({ - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'full', }, proxyUrl: 'https://elastic.proxy.co', @@ -187,7 +187,7 @@ describe('request', () => { test('it proxies with proxyOnlyHosts when expected', async () => { configurationUtilities.getProxySettings.mockReturnValue({ - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'full', }, proxyUrl: 'https://elastic.proxy.co', @@ -210,7 +210,7 @@ describe('request', () => { test('it does not proxy with proxyOnlyHosts when expected', async () => { configurationUtilities.getProxySettings.mockReturnValue({ - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'full', }, proxyUrl: 'https://elastic.proxy.co', diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts index 235fca005e225f..4ed9485e923a76 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts @@ -86,7 +86,7 @@ describe('axios connections', () => { testServer = server; const configurationUtilities = getACUfromConfig({ - tls: { + ssl: { verificationMode: 'none', }, }); @@ -99,7 +99,7 @@ describe('axios connections', () => { testServer = server; const configurationUtilities = getACUfromConfig({ - customHostSettings: [{ url, tls: { verificationMode: 'none' } }], + customHostSettings: [{ url, ssl: { verificationMode: 'none' } }], }); const res = await request({ axios, url, logger, configurationUtilities }); expect(res.status).toBe(200); @@ -110,7 +110,7 @@ describe('axios connections', () => { testServer = server; const configurationUtilities = getACUfromConfig({ - customHostSettings: [{ url, tls: { certificateAuthoritiesData: CA } }], + customHostSettings: [{ url, ssl: { certificateAuthoritiesData: CA } }], }); const res = await request({ axios, url, logger, configurationUtilities }); expect(res.status).toBe(200); @@ -121,7 +121,7 @@ describe('axios connections', () => { testServer = server; const configurationUtilities = getACUfromConfig({ - customHostSettings: [{ url, tls: { certificateAuthoritiesData: KIBANA_CRT } }], + customHostSettings: [{ url, ssl: { certificateAuthoritiesData: KIBANA_CRT } }], }); const fn = async () => await request({ axios, url, logger, configurationUtilities }); await expect(fn()).rejects.toThrow('certificate'); @@ -135,7 +135,7 @@ describe('axios connections', () => { customHostSettings: [ { url, - tls: { + ssl: { certificateAuthoritiesData: CA, verificationMode: 'none', }, @@ -151,13 +151,13 @@ describe('axios connections', () => { testServer = server; const configurationUtilities = getACUfromConfig({ - tls: { + ssl: { verificationMode: 'none', }, customHostSettings: [ { url, - tls: { + ssl: { certificateAuthoritiesData: CA, }, }, @@ -173,7 +173,7 @@ describe('axios connections', () => { testServer = server; const configurationUtilities = getACUfromConfig({ - customHostSettings: [{ url: otherUrl, tls: { verificationMode: 'none' } }], + customHostSettings: [{ url: otherUrl, ssl: { verificationMode: 'none' } }], }); const fn = async () => await request({ axios, url, logger, configurationUtilities }); await expect(fn()).rejects.toThrow('certificate'); @@ -184,7 +184,7 @@ describe('axios connections', () => { testServer = server; const configurationUtilities = getACUfromConfig({ - customHostSettings: [{ url, tls: { certificateAuthoritiesData: 'garbage' } }], + customHostSettings: [{ url, ssl: { certificateAuthoritiesData: 'garbage' } }], }); const fn = async () => await request({ axios, url, logger, configurationUtilities }); await expect(fn()).rejects.toThrow('certificate'); @@ -196,7 +196,7 @@ describe('axios connections', () => { const ca = '-----BEGIN CERTIFICATE-----\ngarbage\n-----END CERTIFICATE-----\n'; const configurationUtilities = getACUfromConfig({ - customHostSettings: [{ url, tls: { certificateAuthoritiesData: ca } }], + customHostSettings: [{ url, ssl: { certificateAuthoritiesData: ca } }], }); const fn = async () => await request({ axios, url, logger, configurationUtilities }); await expect(fn()).rejects.toThrow('certificate'); @@ -255,7 +255,7 @@ const BaseActionsConfig: ActionsConfig = { proxyUrl: undefined, proxyHeaders: undefined, proxyRejectUnauthorizedCertificates: true, - tls: { + ssl: { proxyVerificationMode: 'full', verificationMode: 'full', }, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts index 8b4abe86e271ac..0c1112da5909f5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts @@ -30,7 +30,7 @@ describe('getCustomAgents', () => { test('get agents for valid proxy URL', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -44,7 +44,7 @@ describe('getCustomAgents', () => { test('return default agents for invalid proxy URL', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: ':nope: not a valid URL', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -64,7 +64,7 @@ describe('getCustomAgents', () => { test('returns non-proxy agents for matching proxyBypassHosts', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: new Set([targetHost]), @@ -78,7 +78,7 @@ describe('getCustomAgents', () => { test('returns proxy agents for non-matching proxyBypassHosts', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: new Set([targetHost]), @@ -96,7 +96,7 @@ describe('getCustomAgents', () => { test('returns proxy agents for matching proxyOnlyHosts', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -110,7 +110,7 @@ describe('getCustomAgents', () => { test('returns non-proxy agents for non-matching proxyOnlyHosts', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -128,7 +128,7 @@ describe('getCustomAgents', () => { test('handles custom host settings', () => { configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, - tls: { + ssl: { verificationMode: 'none', certificateAuthoritiesData: 'ca data here', }, @@ -141,7 +141,7 @@ describe('getCustomAgents', () => { test('handles custom host settings with proxy', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -149,7 +149,7 @@ describe('getCustomAgents', () => { }); configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, - tls: { + ssl: { verificationMode: 'none', certificateAuthoritiesData: 'ca data here', }, @@ -163,12 +163,12 @@ describe('getCustomAgents', () => { }); test('handles overriding global verificationMode "none"', () => { - configurationUtilities.getTLSSettings.mockReturnValue({ + configurationUtilities.getSSLSettings.mockReturnValue({ verificationMode: 'none', }); configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, - tls: { + ssl: { verificationMode: 'certificate', }, }); @@ -181,12 +181,12 @@ describe('getCustomAgents', () => { }); test('handles overriding global verificationMode "full"', () => { - configurationUtilities.getTLSSettings.mockReturnValue({ + configurationUtilities.getSSLSettings.mockReturnValue({ verificationMode: 'full', }); configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, - tls: { + ssl: { verificationMode: 'none', }, }); @@ -199,12 +199,12 @@ describe('getCustomAgents', () => { }); test('handles overriding global verificationMode "none" with a proxy', () => { - configurationUtilities.getTLSSettings.mockReturnValue({ + configurationUtilities.getSSLSettings.mockReturnValue({ verificationMode: 'none', }); configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, - tls: { + ssl: { verificationMode: 'full', }, }); @@ -212,7 +212,7 @@ describe('getCustomAgents', () => { proxyUrl: 'https://someproxyhost', // note: this setting doesn't come into play, it's for the connection to // the proxy, not the target url - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -226,12 +226,12 @@ describe('getCustomAgents', () => { }); test('handles overriding global verificationMode "full" with a proxy', () => { - configurationUtilities.getTLSSettings.mockReturnValue({ + configurationUtilities.getSSLSettings.mockReturnValue({ verificationMode: 'full', }); configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, - tls: { + ssl: { verificationMode: 'none', }, }); @@ -239,7 +239,7 @@ describe('getCustomAgents', () => { proxyUrl: 'https://someproxyhost', // note: this setting doesn't come into play, it's for the connection to // the proxy, not the target url - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts index a327ee3ffe931f..83d31ae1355d36 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts @@ -11,7 +11,7 @@ import HttpProxyAgent from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { Logger } from '../../../../../../src/core/server'; import { ActionsConfigurationUtilities } from '../../actions_config'; -import { getNodeTLSOptions, getTLSSettingsFromConfig } from './get_node_tls_options'; +import { getNodeSSLOptions, getSSLSettingsFromConfig } from './get_node_ssl_options'; interface GetCustomAgentsResponse { httpAgent: HttpAgent | undefined; @@ -23,14 +23,14 @@ export function getCustomAgents( logger: Logger, url: string ): GetCustomAgentsResponse { - const generalTLSSettings = configurationUtilities.getTLSSettings(); - const agentTLSOptions = getNodeTLSOptions(logger, generalTLSSettings.verificationMode); + const generalSSLSettings = configurationUtilities.getSSLSettings(); + const agentSSLOptions = getNodeSSLOptions(logger, generalSSLSettings.verificationMode); // the default for rejectUnauthorized is the global setting, which can // be overridden (below) with a custom host setting const defaultAgents = { httpAgent: undefined, httpsAgent: new HttpsAgent({ - ...agentTLSOptions, + ...agentSSLOptions, }), }; @@ -43,28 +43,28 @@ export function getCustomAgents( } // update the defaultAgents.httpsAgent if configured - const tlsSettings = customHostSettings?.tls; + const sslSettings = customHostSettings?.ssl; let agentOptions: AgentOptions | undefined; - if (tlsSettings) { + if (sslSettings) { logger.debug(`Creating customized connection settings for: ${url}`); agentOptions = defaultAgents.httpsAgent.options; - if (tlsSettings.certificateAuthoritiesData) { - agentOptions.ca = tlsSettings.certificateAuthoritiesData; + if (sslSettings.certificateAuthoritiesData) { + agentOptions.ca = sslSettings.certificateAuthoritiesData; } - const tlsSettingsFromConfig = getTLSSettingsFromConfig( - tlsSettings.verificationMode, - tlsSettings.rejectUnauthorized + const sslSettingsFromConfig = getSSLSettingsFromConfig( + sslSettings.verificationMode, + sslSettings.rejectUnauthorized ); // see: src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts // This is where the global rejectUnauthorized is overridden by a custom host - const customHostNodeTLSOptions = getNodeTLSOptions( + const customHostNodeSSLOptions = getNodeSSLOptions( logger, - tlsSettingsFromConfig.verificationMode + sslSettingsFromConfig.verificationMode ); - if (customHostNodeTLSOptions.rejectUnauthorized !== undefined) { - agentOptions.rejectUnauthorized = customHostNodeTLSOptions.rejectUnauthorized; + if (customHostNodeSSLOptions.rejectUnauthorized !== undefined) { + agentOptions.rejectUnauthorized = customHostNodeSSLOptions.rejectUnauthorized; } } @@ -107,12 +107,12 @@ export function getCustomAgents( return defaultAgents; } - const proxyNodeTLSOptions = getNodeTLSOptions( + const proxyNodeSSLOptions = getNodeSSLOptions( logger, - proxySettings.proxyTLSSettings.verificationMode + proxySettings.proxySSLSettings.verificationMode ); // At this point, we are going to use a proxy, so we need new agents. - // We will though, copy over the calculated tls options from above, into + // We will though, copy over the calculated ssl options from above, into // the https agent. const httpAgent = new HttpProxyAgent(proxySettings.proxyUrl); const httpsAgent = (new HttpsProxyAgent({ @@ -121,7 +121,7 @@ export function getCustomAgents( protocol: proxyUrl.protocol, headers: proxySettings.proxyHeaders, // do not fail on invalid certs if value is false - ...proxyNodeTLSOptions, + ...proxyNodeSSLOptions, }) as unknown) as HttpsAgent; // vsCode wasn't convinced HttpsProxyAgent is an https.Agent, so we convinced it diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_ssl_options.test.ts similarity index 67% rename from x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.test.ts rename to x-pack/plugins/actions/server/builtin_action_types/lib/get_node_ssl_options.test.ts index 7d131985053f17..893191b2ca2b41 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_ssl_options.test.ts @@ -4,35 +4,35 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { getNodeTLSOptions, getTLSSettingsFromConfig } from './get_node_tls_options'; +import { getNodeSSLOptions, getSSLSettingsFromConfig } from './get_node_ssl_options'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; const logger = loggingSystemMock.create().get() as jest.Mocked; -describe('getNodeTLSOptions', () => { - test('get node.js TLS options: rejectUnauthorized eql true for the verification mode "full"', () => { - const nodeOption = getNodeTLSOptions(logger, 'full'); +describe('getNodeSSLOptions', () => { + test('get node.js SSL options: rejectUnauthorized eql true for the verification mode "full"', () => { + const nodeOption = getNodeSSLOptions(logger, 'full'); expect(nodeOption).toMatchObject({ rejectUnauthorized: true, }); }); - test('get node.js TLS options: rejectUnauthorized eql true for the verification mode "certificate"', () => { - const nodeOption = getNodeTLSOptions(logger, 'certificate'); + test('get node.js SSL options: rejectUnauthorized eql true for the verification mode "certificate"', () => { + const nodeOption = getNodeSSLOptions(logger, 'certificate'); expect(nodeOption.checkServerIdentity).not.toBeNull(); expect(nodeOption.rejectUnauthorized).toBeTruthy(); }); - test('get node.js TLS options: rejectUnauthorized eql false for the verification mode "none"', () => { - const nodeOption = getNodeTLSOptions(logger, 'none'); + test('get node.js SSL options: rejectUnauthorized eql false for the verification mode "none"', () => { + const nodeOption = getNodeSSLOptions(logger, 'none'); expect(nodeOption).toMatchObject({ rejectUnauthorized: false, }); }); - test('get node.js TLS options: rejectUnauthorized eql true for the verification mode value which does not exist, the logger called with the proper warning message', () => { - const nodeOption = getNodeTLSOptions(logger, 'notexist'); + test('get node.js SSL options: rejectUnauthorized eql true for the verification mode value which does not exist, the logger called with the proper warning message', () => { + const nodeOption = getNodeSSLOptions(logger, 'notexist'); expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ @@ -46,23 +46,23 @@ describe('getNodeTLSOptions', () => { }); }); -describe('getTLSSettingsFromConfig', () => { +describe('getSSLSettingsFromConfig', () => { test('get verificationMode eql "none" if legacy rejectUnauthorized eql false', () => { - const nodeOption = getTLSSettingsFromConfig(undefined, false); + const nodeOption = getSSLSettingsFromConfig(undefined, false); expect(nodeOption).toMatchObject({ verificationMode: 'none', }); }); test('get verificationMode eql "none" if legacy rejectUnauthorized eql true', () => { - const nodeOption = getTLSSettingsFromConfig(undefined, true); + const nodeOption = getSSLSettingsFromConfig(undefined, true); expect(nodeOption).toMatchObject({ verificationMode: 'full', }); }); test('get verificationMode eql "certificate", ignore rejectUnauthorized', () => { - const nodeOption = getTLSSettingsFromConfig('certificate', false); + const nodeOption = getSSLSettingsFromConfig('certificate', false); expect(nodeOption).toMatchObject({ verificationMode: 'certificate', }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_ssl_options.ts similarity index 92% rename from x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.ts rename to x-pack/plugins/actions/server/builtin_action_types/lib/get_node_ssl_options.ts index 423e9756b13f8c..46e90ec3be697f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_ssl_options.ts @@ -6,10 +6,10 @@ */ import { PeerCertificate } from 'tls'; -import { TLSSettings } from '../../types'; +import { SSLSettings } from '../../types'; import { Logger } from '../../../../../../src/core/server'; -export function getNodeTLSOptions( +export function getNodeSSLOptions( logger: Logger, verificationMode?: string ): { @@ -44,10 +44,10 @@ export function getNodeTLSOptions( return agentOptions; } -export function getTLSSettingsFromConfig( +export function getSSLSettingsFromConfig( verificationMode?: 'none' | 'certificate' | 'full', rejectUnauthorized?: boolean -): TLSSettings { +): SSLSettings { if (verificationMode) { return { verificationMode }; } else if (rejectUnauthorized !== undefined) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index 9bdb2d94811424..3719dd8cd737c7 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -76,7 +76,7 @@ describe('send_email module', () => { }, { proxyUrl: 'https://example.com', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -238,7 +238,7 @@ describe('send_email module', () => { }, { proxyUrl: 'https://proxy.com', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: new Set(['example.com']), @@ -272,7 +272,7 @@ describe('send_email module', () => { }, { proxyUrl: 'https://proxy.com', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: new Set(['not-example.com']), @@ -308,7 +308,7 @@ describe('send_email module', () => { }, { proxyUrl: 'https://proxy.com', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -344,7 +344,7 @@ describe('send_email module', () => { }, { proxyUrl: 'https://proxy.com', - proxyTLSSettings: {}, + proxySSLSettings: {}, proxyBypassHosts: undefined, proxyOnlyHosts: new Set(['not-example.com']), } @@ -377,7 +377,7 @@ describe('send_email module', () => { undefined, { url: 'smtp://example.com:1025', - tls: { + ssl: { certificateAuthoritiesData: 'ca cert data goes here', }, smtp: { @@ -419,7 +419,7 @@ describe('send_email module', () => { undefined, { url: 'smtp://example.com:1025', - tls: { + ssl: { certificateAuthoritiesData: 'ca cert data goes here', rejectUnauthorized: true, }, @@ -461,13 +461,13 @@ describe('send_email module', () => { }, { proxyUrl: 'https://proxy.com', - proxyTLSSettings: {}, + proxySSLSettings: {}, proxyBypassHosts: undefined, proxyOnlyHosts: undefined, }, { url: 'smtp://example.com:1025', - tls: { + ssl: { certificateAuthoritiesData: 'ca cert data goes here', rejectUnauthorized: true, }, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts index 9f601840bc9824..b32ea7d74f0258 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -12,7 +12,7 @@ import { default as MarkdownIt } from 'markdown-it'; import { Logger } from '../../../../../../src/core/server'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { CustomHostSettings } from '../../config'; -import { getNodeTLSOptions, getTLSSettingsFromConfig } from './get_node_tls_options'; +import { getNodeSSLOptions, getSSLSettingsFromConfig } from './get_node_ssl_options'; // an email "service" which doesn't actually send, just returns what it would send export const JSON_TRANSPORT_SERVICE = '__json'; @@ -59,7 +59,7 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom // eslint-disable-next-line @typescript-eslint/no-explicit-any const transportConfig: Record = {}; const proxySettings = configurationUtilities.getProxySettings(); - const generalTLSSettings = configurationUtilities.getTLSSettings(); + const generalSSLSettings = configurationUtilities.getSSLSettings(); if (hasAuth && user != null && password != null) { transportConfig.auth = { @@ -92,9 +92,9 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom customHostSettings = configurationUtilities.getCustomHostSettings(`smtp://${host}:${port}`); if (proxySettings && useProxy) { - transportConfig.tls = getNodeTLSOptions( + transportConfig.tls = getNodeSSLOptions( logger, - proxySettings?.proxyTLSSettings.verificationMode + proxySettings?.proxySSLSettings.verificationMode ); transportConfig.proxy = proxySettings.proxyUrl; transportConfig.headers = proxySettings.proxyHeaders; @@ -104,25 +104,25 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom // authenticate rarely have valid certs; eg cloud proxy, and npm maildev transportConfig.tls = { rejectUnauthorized: false }; } else { - transportConfig.tls = getNodeTLSOptions(logger, generalTLSSettings.verificationMode); + transportConfig.tls = getNodeSSLOptions(logger, generalSSLSettings.verificationMode); } // finally, allow customHostSettings to override some of the settings // see: https://nodemailer.com/smtp/ if (customHostSettings) { const tlsConfig: Record = {}; - const tlsSettings = customHostSettings.tls; + const sslSettings = customHostSettings.ssl; const smtpSettings = customHostSettings.smtp; - if (tlsSettings?.certificateAuthoritiesData) { - tlsConfig.ca = tlsSettings?.certificateAuthoritiesData; + if (sslSettings?.certificateAuthoritiesData) { + tlsConfig.ca = sslSettings?.certificateAuthoritiesData; } - const tlsSettingsFromConfig = getTLSSettingsFromConfig( - tlsSettings?.verificationMode, - tlsSettings?.rejectUnauthorized + const sslSettingsFromConfig = getSSLSettingsFromConfig( + sslSettings?.verificationMode, + sslSettings?.rejectUnauthorized ); - const nodeTLSOptions = getNodeTLSOptions(logger, tlsSettingsFromConfig.verificationMode); + const nodeTLSOptions = getNodeSSLOptions(logger, sslSettingsFromConfig.verificationMode); if (!transportConfig.tls) { transportConfig.tls = { ...tlsConfig, ...nodeTLSOptions }; } else { diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts index 4108424e26ac40..7953f0ab365e84 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts @@ -194,7 +194,7 @@ describe('execute()', () => { const configurationUtilities = actionsConfigMock.create(); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -221,7 +221,7 @@ describe('execute()', () => { const configurationUtilities = actionsConfigMock.create(); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: new Set(['example.com']), @@ -248,7 +248,7 @@ describe('execute()', () => { const configurationUtilities = actionsConfigMock.create(); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: new Set(['not-example.com']), @@ -275,7 +275,7 @@ describe('execute()', () => { const configurationUtilities = actionsConfigMock.create(); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, @@ -302,7 +302,7 @@ describe('execute()', () => { const configurationUtilities = actionsConfigMock.create(); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyTLSSettings: { + proxySSLSettings: { verificationMode: 'none', }, proxyBypassHosts: undefined, diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts index bf34789e03fae1..497300b86bdea9 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts @@ -170,7 +170,7 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], - "getTLSSettings": [MockFunction], + "getSSLSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], "isUriAllowed": [MockFunction], @@ -234,7 +234,7 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], - "getTLSSettings": [MockFunction], + "getSSLSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], "isUriAllowed": [MockFunction], diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index b2c865c2f5374c..c04c79075abdc4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -293,7 +293,7 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], - "getTLSSettings": [MockFunction], + "getSSLSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], "isUriAllowed": [MockFunction], @@ -386,7 +386,7 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], - "getTLSSettings": [MockFunction], + "getSSLSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], "isUriAllowed": [MockFunction], diff --git a/x-pack/plugins/actions/server/config.test.ts b/x-pack/plugins/actions/server/config.test.ts index 9774bfb05d4ff4..d99b9349e977bb 100644 --- a/x-pack/plugins/actions/server/config.test.ts +++ b/x-pack/plugins/actions/server/config.test.ts @@ -178,9 +178,9 @@ describe('config validation', () => { ); }); - test('action with tls configuration', () => { + test('action with ssl configuration', () => { const config: Record = { - tls: { + ssl: { verificationMode: 'none', proxyVerificationMode: 'none', }, @@ -208,7 +208,7 @@ describe('config validation', () => { "proxyRejectUnauthorizedCertificates": true, "rejectUnauthorized": true, "responseTimeout": "PT1M", - "tls": Object { + "ssl": Object { "proxyVerificationMode": "none", "verificationMode": "none", }, diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index 8859a2d8881a25..1ae196c25a756e 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -31,7 +31,7 @@ const customHostSettingsSchema = schema.object({ requireTLS: schema.maybe(schema.boolean()), }) ), - tls: schema.maybe( + ssl: schema.maybe( schema.object({ /** * @deprecated in favor of `verificationMode` @@ -78,16 +78,16 @@ export const configSchema = schema.object({ proxyUrl: schema.maybe(schema.string()), proxyHeaders: schema.maybe(schema.recordOf(schema.string(), schema.string())), /** - * @deprecated in favor of `tls.proxyVerificationMode` + * @deprecated in favor of `ssl.proxyVerificationMode` **/ proxyRejectUnauthorizedCertificates: schema.boolean({ defaultValue: true }), proxyBypassHosts: schema.maybe(schema.arrayOf(schema.string({ hostname: true }))), proxyOnlyHosts: schema.maybe(schema.arrayOf(schema.string({ hostname: true }))), /** - * @deprecated in favor of `tls.verificationMode` + * @deprecated in favor of `ssl.verificationMode` **/ rejectUnauthorized: schema.boolean({ defaultValue: true }), - tls: schema.maybe( + ssl: schema.maybe( schema.object({ verificationMode: schema.maybe( schema.oneOf( diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index 692ff6fa0a5084..bcfc91d673bcc4 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -64,19 +64,19 @@ export const config: PluginConfigDescriptor = { if ( customHostSettings.find( (customHostSchema: CustomHostSettings) => - !!customHostSchema.tls && !!customHostSchema.tls.rejectUnauthorized + !!customHostSchema.ssl && !!customHostSchema.ssl.rejectUnauthorized ) ) { addDeprecation({ message: - `"xpack.actions.customHostSettings[].tls.rejectUnauthorized" is deprecated.` + - `Use "xpack.actions.customHostSettings[].tls.verificationMode" instead, ` + + `"xpack.actions.customHostSettings[].ssl.rejectUnauthorized" is deprecated.` + + `Use "xpack.actions.customHostSettings[].ssl.verificationMode" instead, ` + `with the setting "verificationMode:full" eql to "rejectUnauthorized:true", ` + `and "verificationMode:none" eql to "rejectUnauthorized:false".`, correctiveActions: { manualSteps: [ - `Remove "xpack.actions.customHostSettings[].tls.rejectUnauthorized" from your kibana configs.`, - `Use "xpack.actions.customHostSettings[].tls.verificationMode" ` + + `Remove "xpack.actions.customHostSettings[].ssl.rejectUnauthorized" from your kibana configs.`, + `Use "xpack.actions.customHostSettings[].ssl.verificationMode" ` + `with the setting "verificationMode:full" eql to "rejectUnauthorized:true", ` + `and "verificationMode:none" eql to "rejectUnauthorized:false".`, ], diff --git a/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts index ad07ea21d79178..ec7b46e545112b 100644 --- a/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts +++ b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts @@ -112,14 +112,14 @@ describe('custom_host_settings', () => { customHostSettings: [ { url: 'https://elastic.co:443', - tls: { + ssl: { certificateAuthoritiesData: 'xyz', rejectUnauthorized: false, }, }, { url: 'smtp://mail.elastic.com:25', - tls: { + ssl: { certificateAuthoritiesData: 'abc', rejectUnauthorized: true, }, @@ -338,7 +338,7 @@ describe('custom_host_settings', () => { customHostSettings: [ { url: 'https://almost.purrfect.com/', - tls: { + ssl: { certificateAuthoritiesFiles: 'this-file-does-not-exist', }, }, @@ -350,7 +350,7 @@ describe('custom_host_settings', () => { customHostSettings: [ { url: 'https://almost.purrfect.com:443', - tls: { + ssl: { certificateAuthoritiesFiles: 'this-file-does-not-exist', }, }, @@ -371,7 +371,7 @@ describe('custom_host_settings', () => { customHostSettings: [ { url: 'https://almost.purrfect.com/', - tls: { + ssl: { certificateAuthoritiesFiles: CA_FILE1, }, }, @@ -380,7 +380,7 @@ describe('custom_host_settings', () => { const resConfig = resolveCustomHosts(mockLogger, config); // not checking the full structure anymore, just ca bits - expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe(CA_CONTENTS1); + expect(resConfig?.customHostSettings?.[0].ssl?.certificateAuthoritiesData).toBe(CA_CONTENTS1); expect(warningLogs()).toEqual([]); }); @@ -390,7 +390,7 @@ describe('custom_host_settings', () => { customHostSettings: [ { url: 'https://almost.purrfect.com/', - tls: { + ssl: { certificateAuthoritiesFiles: [CA_FILE1, CA_FILE2], }, }, @@ -399,7 +399,7 @@ describe('custom_host_settings', () => { const resConfig = resolveCustomHosts(mockLogger, config); // not checking the full structure anymore, just ca bits - expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe( + expect(resConfig?.customHostSettings?.[0].ssl?.certificateAuthoritiesData).toBe( `${CA_CONTENTS1}\n${CA_CONTENTS2}` ); expect(warningLogs()).toEqual([]); @@ -411,7 +411,7 @@ describe('custom_host_settings', () => { customHostSettings: [ { url: 'https://almost.purrfect.com/', - tls: { + ssl: { certificateAuthoritiesFiles: [CA_FILE2], certificateAuthoritiesData: CA_CONTENTS1, }, @@ -421,7 +421,7 @@ describe('custom_host_settings', () => { const resConfig = resolveCustomHosts(mockLogger, config); // not checking the full structure anymore, just ca bits - expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe( + expect(resConfig?.customHostSettings?.[0].ssl?.certificateAuthoritiesData).toBe( `${CA_CONTENTS1}\n${CA_CONTENTS2}` ); expect(warningLogs()).toEqual([]); @@ -468,13 +468,13 @@ describe('custom_host_settings', () => { customHostSettings: [ { url: 'https://almost.purrfect.com/', - tls: { + ssl: { rejectUnauthorized: true, }, }, { url: 'https://almost.purrfect.com:443', - tls: { + ssl: { rejectUnauthorized: false, }, }, @@ -486,7 +486,7 @@ describe('custom_host_settings', () => { customHostSettings: [ { url: 'https://almost.purrfect.com:443', - tls: { + ssl: { rejectUnauthorized: true, }, }, diff --git a/x-pack/plugins/actions/server/lib/custom_host_settings.ts b/x-pack/plugins/actions/server/lib/custom_host_settings.ts index bfc8dad48aab60..0ff8624d42cfe8 100644 --- a/x-pack/plugins/actions/server/lib/custom_host_settings.ts +++ b/x-pack/plugins/actions/server/lib/custom_host_settings.ts @@ -86,8 +86,8 @@ export function resolveCustomHosts(logger: Logger, config: ActionsConfig): Actio } // read the specified ca files, add their content to certificateAuthoritiesData - if (customHostSetting.tls) { - let files = customHostSetting.tls?.certificateAuthoritiesFiles || []; + if (customHostSetting.ssl) { + let files = customHostSetting.ssl?.certificateAuthoritiesFiles || []; if (typeof files === 'string') { files = [files]; } @@ -134,12 +134,12 @@ export function resolveCustomHosts(logger: Logger, config: ActionsConfig): Actio } function appendToCertificateAuthoritiesData(customHost: CustomHostSettingsWriteable, cert: string) { - const tls = customHost.tls; - if (tls) { - if (!tls.certificateAuthoritiesData) { - tls.certificateAuthoritiesData = cert; + const ssl = customHost.ssl; + if (ssl) { + if (!ssl.certificateAuthoritiesData) { + ssl.certificateAuthoritiesData = cert; } else { - tls.certificateAuthoritiesData += '\n' + cert; + ssl.certificateAuthoritiesData += '\n' + cert; } } } diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index c8c9967afca1a7..a191728a204892 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -142,7 +142,7 @@ export interface ProxySettings { proxyBypassHosts: Set | undefined; proxyOnlyHosts: Set | undefined; proxyHeaders?: Record; - proxyTLSSettings: TLSSettings; + proxySSLSettings: SSLSettings; } export interface ResponseSettings { @@ -150,6 +150,6 @@ export interface ResponseSettings { timeout: number; } -export interface TLSSettings { +export interface SSLSettings { verificationMode?: 'none' | 'certificate' | 'full'; } diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 7ee6e146b2a505..61b452fc118358 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -22,7 +22,7 @@ interface CreateTestConfigOptions { verificationMode?: 'full' | 'none' | 'certificate'; publicBaseUrl?: boolean; preconfiguredAlertHistoryEsIndex?: boolean; - customizeLocalHostTls?: boolean; + customizeLocalHostSsl?: boolean; rejectUnauthorized?: boolean; // legacy } @@ -52,7 +52,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) ssl = false, verificationMode = 'full', preconfiguredAlertHistoryEsIndex = false, - customizeLocalHostTls = false, + customizeLocalHostSsl = false, rejectUnauthorized = true, // legacy } = options; @@ -102,25 +102,25 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) const customHostSettingsValue = [ { url: tlsWebhookServers.rejectUnauthorizedFalse, - tls: { + ssl: { verificationMode: 'none', }, }, { url: tlsWebhookServers.rejectUnauthorizedTrue, - tls: { + ssl: { verificationMode: 'full', }, }, { url: tlsWebhookServers.caFile, - tls: { + ssl: { verificationMode: 'certificate', certificateAuthoritiesFiles: [CA_CERT_PATH], }, }, ]; - const customHostSettings = customizeLocalHostTls + const customHostSettings = customizeLocalHostSsl ? [`--xpack.actions.customHostSettings=${JSON.stringify(customHostSettingsValue)}`] : []; @@ -153,7 +153,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) '--xpack.alerting.healthCheck.interval="1s"', `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.actions.rejectUnauthorized=${rejectUnauthorized}`, - `--xpack.actions.tls.verificationMode=${verificationMode}`, + `--xpack.actions.ssl.verificationMode=${verificationMode}`, ...actionsProxyUrl, ...customHostSettings, '--xpack.eventLog.logEntries=true', @@ -198,28 +198,28 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) encrypted: 'this-is-also-ignored-and-also-required', }, }, - 'custom.tls.noCustom': { + 'custom.ssl.noCustom': { actionTypeId: '.webhook', name: `${tlsWebhookServers.noCustom}`, config: { url: tlsWebhookServers.noCustom, }, }, - 'custom.tls.rejectUnauthorizedFalse': { + 'custom.ssl.rejectUnauthorizedFalse': { actionTypeId: '.webhook', name: `${tlsWebhookServers.rejectUnauthorizedFalse}`, config: { url: tlsWebhookServers.rejectUnauthorizedFalse, }, }, - 'custom.tls.rejectUnauthorizedTrue': { + 'custom.ssl.rejectUnauthorizedTrue': { actionTypeId: '.webhook', name: `${tlsWebhookServers.rejectUnauthorizedTrue}`, config: { url: tlsWebhookServers.rejectUnauthorizedTrue, }, }, - 'custom.tls.caFile': { + 'custom.ssl.caFile': { actionTypeId: '.webhook', name: `${tlsWebhookServers.caFile}`, config: { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts index 9a3a78342c5aa4..a88a394863dbfc 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts @@ -61,12 +61,12 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); - // the custom tls connectors have dynamic ports, so remove them before + // the custom ssl connectors have dynamic ports, so remove them before // comparing to what we expect - const nonCustomTlsConnectors = response.body.filter( - (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + const nonCustomSslConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') ); - expect(nonCustomTlsConnectors).to.eql([ + expect(nonCustomSslConnectors).to.eql([ { id: createdAction.id, is_preconfigured: false, @@ -175,12 +175,12 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); - // the custom tls connectors have dynamic ports, so remove them before + // the custom ssl connectors have dynamic ports, so remove them before // comparing to what we expect - const nonCustomTlsConnectors = response.body.filter( - (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + const nonCustomSslConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') ); - expect(nonCustomTlsConnectors).to.eql([ + expect(nonCustomSslConnectors).to.eql([ { id: createdAction.id, is_preconfigured: false, @@ -265,12 +265,12 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { case 'superuser at space1': expect(response.statusCode).to.eql(200); - // the custom tls connectors have dynamic ports, so remove them before + // the custom ssl connectors have dynamic ports, so remove them before // comparing to what we expect - const nonCustomTlsConnectors = response.body.filter( - (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + const nonCustomSslConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') ); - expect(nonCustomTlsConnectors).to.eql([ + expect(nonCustomSslConnectors).to.eql([ { id: 'preconfigured-es-index-action', is_preconfigured: true, diff --git a/x-pack/test/alerting_api_integration/spaces_only/config.ts b/x-pack/test/alerting_api_integration/spaces_only/config.ts index 788d9d0698a199..204f5b27da9d51 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/config.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/config.ts @@ -13,6 +13,6 @@ export default createTestConfig('spaces_only', { license: 'trial', enableActionsProxy: false, verificationMode: 'none', - customizeLocalHostTls: true, + customizeLocalHostSsl: true, preconfiguredAlertHistoryEsIndex: true, }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts index 4af33136cd42c0..9822254db444a8 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts @@ -123,9 +123,9 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); }); - describe('tls customization', () => { + describe('ssl customization', () => { it('should handle the xpack.actions.rejectUnauthorized: false', async () => { - const connectorId = 'custom.tls.noCustom'; + const connectorId = 'custom.ssl.noCustom'; const port = await getPortOfConnector(connectorId); const server = await createTlsWebhookServer(port); const { status, body } = await supertest @@ -143,11 +143,11 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); it('should handle the customized rejectUnauthorized: false', async () => { - const connectorId = 'custom.tls.rejectUnauthorizedFalse'; + const connectorId = 'custom.ssl.rejectUnauthorizedFalse'; const port = await getPortOfConnector(connectorId); const server = await createTlsWebhookServer(port); const { status, body } = await supertest - .post(`/api/actions/connector/custom.tls.rejectUnauthorizedFalse/_execute`) + .post(`/api/actions/connector/custom.ssl.rejectUnauthorizedFalse/_execute`) .set('kbn-xsrf', 'test') .send({ params: { @@ -161,11 +161,11 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); it('should handle the customized rejectUnauthorized: true', async () => { - const connectorId = 'custom.tls.rejectUnauthorizedTrue'; + const connectorId = 'custom.ssl.rejectUnauthorizedTrue'; const port = await getPortOfConnector(connectorId); const server = await createTlsWebhookServer(port); const { status, body } = await supertest - .post(`/api/actions/connector/custom.tls.rejectUnauthorizedTrue/_execute`) + .post(`/api/actions/connector/custom.ssl.rejectUnauthorizedTrue/_execute`) .set('kbn-xsrf', 'test') .send({ params: { @@ -180,11 +180,11 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); it('should handle the customized ca file', async () => { - const connectorId = 'custom.tls.caFile'; + const connectorId = 'custom.ssl.caFile'; const port = await getPortOfConnector(connectorId); const server = await createTlsWebhookServer(port); const { status, body } = await supertest - .post(`/api/actions/connector/custom.tls.caFile/_execute`) + .post(`/api/actions/connector/custom.ssl.caFile/_execute`) .set('kbn-xsrf', 'test') .send({ params: { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts index e7f500f2771e32..a965b1716a6713 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts @@ -40,13 +40,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { .get(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connectors`) .expect(200); - // the custom tls connectors have dynamic ports, so remove them before + // the custom ssl connectors have dynamic ports, so remove them before // comparing to what we expect - const nonCustomTlsConnectors = connectors.filter( - (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + const nonCustomSslConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') ); - expect(nonCustomTlsConnectors).to.eql([ + expect(nonCustomSslConnectors).to.eql([ { id: 'preconfigured-alert-history-es-index', name: 'Alert history Elasticsearch index', @@ -117,13 +117,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { .get(`${getUrlPrefix(Spaces.other.id)}/api/actions/connectors`) .expect(200); - // the custom tls connectors have dynamic ports, so remove them before + // the custom ssl connectors have dynamic ports, so remove them before // comparing to what we expect - const nonCustomTlsConnectors = connectors.filter( - (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + const nonCustomSslConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') ); - expect(nonCustomTlsConnectors).to.eql([ + expect(nonCustomSslConnectors).to.eql([ { id: 'preconfigured-alert-history-es-index', name: 'Alert history Elasticsearch index', @@ -184,13 +184,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { .get(`${getUrlPrefix(Spaces.space1.id)}/api/actions`) .expect(200); - // the custom tls connectors have dynamic ports, so remove them before + // the custom ssl connectors have dynamic ports, so remove them before // comparing to what we expect - const nonCustomTlsConnectors = connectors.filter( - (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + const nonCustomSslConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') ); - expect(nonCustomTlsConnectors).to.eql([ + expect(nonCustomSslConnectors).to.eql([ { id: 'preconfigured-alert-history-es-index', name: 'Alert history Elasticsearch index', diff --git a/x-pack/test/alerting_api_integration/spaces_only_legacy/config.ts b/x-pack/test/alerting_api_integration/spaces_only_legacy/config.ts index 511e97b96e35d7..b322b8dffbf958 100644 --- a/x-pack/test/alerting_api_integration/spaces_only_legacy/config.ts +++ b/x-pack/test/alerting_api_integration/spaces_only_legacy/config.ts @@ -14,6 +14,6 @@ export default createTestConfig('spaces_only', { enableActionsProxy: false, rejectUnauthorized: false, verificationMode: undefined, - customizeLocalHostTls: true, + customizeLocalHostSsl: true, preconfiguredAlertHistoryEsIndex: true, }); diff --git a/x-pack/test/alerting_api_integration/spaces_only_legacy/tests/actions/builtin_action_types/webhook.ts b/x-pack/test/alerting_api_integration/spaces_only_legacy/tests/actions/builtin_action_types/webhook.ts index 4af33136cd42c0..9822254db444a8 100644 --- a/x-pack/test/alerting_api_integration/spaces_only_legacy/tests/actions/builtin_action_types/webhook.ts +++ b/x-pack/test/alerting_api_integration/spaces_only_legacy/tests/actions/builtin_action_types/webhook.ts @@ -123,9 +123,9 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); }); - describe('tls customization', () => { + describe('ssl customization', () => { it('should handle the xpack.actions.rejectUnauthorized: false', async () => { - const connectorId = 'custom.tls.noCustom'; + const connectorId = 'custom.ssl.noCustom'; const port = await getPortOfConnector(connectorId); const server = await createTlsWebhookServer(port); const { status, body } = await supertest @@ -143,11 +143,11 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); it('should handle the customized rejectUnauthorized: false', async () => { - const connectorId = 'custom.tls.rejectUnauthorizedFalse'; + const connectorId = 'custom.ssl.rejectUnauthorizedFalse'; const port = await getPortOfConnector(connectorId); const server = await createTlsWebhookServer(port); const { status, body } = await supertest - .post(`/api/actions/connector/custom.tls.rejectUnauthorizedFalse/_execute`) + .post(`/api/actions/connector/custom.ssl.rejectUnauthorizedFalse/_execute`) .set('kbn-xsrf', 'test') .send({ params: { @@ -161,11 +161,11 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); it('should handle the customized rejectUnauthorized: true', async () => { - const connectorId = 'custom.tls.rejectUnauthorizedTrue'; + const connectorId = 'custom.ssl.rejectUnauthorizedTrue'; const port = await getPortOfConnector(connectorId); const server = await createTlsWebhookServer(port); const { status, body } = await supertest - .post(`/api/actions/connector/custom.tls.rejectUnauthorizedTrue/_execute`) + .post(`/api/actions/connector/custom.ssl.rejectUnauthorizedTrue/_execute`) .set('kbn-xsrf', 'test') .send({ params: { @@ -180,11 +180,11 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); it('should handle the customized ca file', async () => { - const connectorId = 'custom.tls.caFile'; + const connectorId = 'custom.ssl.caFile'; const port = await getPortOfConnector(connectorId); const server = await createTlsWebhookServer(port); const { status, body } = await supertest - .post(`/api/actions/connector/custom.tls.caFile/_execute`) + .post(`/api/actions/connector/custom.ssl.caFile/_execute`) .set('kbn-xsrf', 'test') .send({ params: { From bb7bff5c960bab77b30c42d399c1121faf5e1eae Mon Sep 17 00:00:00 2001 From: John Schulz Date: Wed, 23 Jun 2021 14:46:04 -0400 Subject: [PATCH 45/61] [Fleet] Add UI and mappings for agent policy unenroll_timeout (#102970) ## Summary closes https://github.com/elastic/kibana/issues/100617 UI and mappings related to ephemeral agents - [x] Adds mapping/type/schema definition for the new field in agent policy saved object - [x] Shows input field labelled `Unenrollment timeout` in agent policy settings that reads/writes to the new field - [x] Same input in `Advanced options` section of create agent flyout - [x] `unenroll_timeout` can be set using preconfigured agent policies defined in `kibana.yml` - [x] `unenroll_timeout` can be populated if the user has a preconfigured policy that _does not_ have this field initially, but then updates their `kibana.yml` later to include it
Screenshot - editing an existing agent policy Screen Shot 2021-06-22 at 1 42 50 PM
Screenshots - adding a new agent policy Screen Shot 2021-06-22 at 1 45 01 PM Screen Shot 2021-06-22 at 1 45 35 PM Screen Shot 2021-06-22 at 1 45 44 PM Screen Shot 2021-06-22 at 1 45 56 PM
Using kibana.dev.yml

No unenroll_timeout

```yml xpack.fleet.agentPolicies: - name: Preconfigured Policy From Config description: From kibana.dev.yml (no timeout given) id: 1 namespace: test package_policies: - package: name: system name: System Integration inputs: - type: system/metrics enabled: true vars: - name: system.hostfs value: home/test streams: - data_stream: dataset: system.core enabled: true vars: - name: period value: 20s - type: winlog enabled: false ```

UI (saved object)

Screen Shot 2021-06-23 at 10 28 03 AM

fleet-policiesindex

Screen Shot 2021-06-23 at 10 52 39 AM

Updated kibana.dev.yml to include unenroll_timeout

```yml xpack.fleet.agentPolicies: - name: Preconfigured Policy From Config description: From kibana.dev.yml (updated with timeout) id: 1 namespace: test unenroll_timeout: 234 package_policies: - package: name: system name: System Integration inputs: - type: system/metrics enabled: true vars: - name: system.hostfs value: home/test streams: - data_stream: dataset: system.core enabled: true vars: - name: period value: 20s - type: winlog enabled: false ```

UI (saved object)

Screen Shot 2021-06-23 at 10 35 17 AM

fleet-policiesindex

Screen Shot 2021-06-23 at 10 35 41 AM
### Checklist - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- x-pack/plugins/fleet/common/constants/epm.ts | 3 +++ .../common/constants/preconfiguration.ts | 5 ++-- .../plugins/fleet/common/constants/routes.ts | 2 +- .../fleet/common/types/models/agent_policy.ts | 9 +++++-- .../plugins/fleet/common/types/models/epm.ts | 3 ++- .../components/agent_policy_form.tsx | 26 +++++++++++++++++++ .../components/settings/index.tsx | 3 ++- .../server/routes/preconfiguration/index.ts | 6 ++--- .../fleet/server/saved_objects/index.ts | 1 + .../fleet/server/services/agent_policy.ts | 1 + .../fleet/server/services/preconfiguration.ts | 2 +- .../fleet/server/types/models/agent_policy.ts | 1 + .../apis/preconfiguration/preconfiguration.ts | 2 +- 13 files changed, 52 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/fleet/common/constants/epm.ts b/x-pack/plugins/fleet/common/constants/epm.ts index e9dd968d3f0489..81ea2a630d3db8 100644 --- a/x-pack/plugins/fleet/common/constants/epm.ts +++ b/x-pack/plugins/fleet/common/constants/epm.ts @@ -48,6 +48,9 @@ export const dataTypes = { Metrics: 'metrics', } as const; +// currently identical but may be a subset or otherwise different some day +export const monitoringTypes = Object.values(dataTypes); + export const installationStatuses = { Installed: 'installed', NotInstalled: 'not_installed', diff --git a/x-pack/plugins/fleet/common/constants/preconfiguration.ts b/x-pack/plugins/fleet/common/constants/preconfiguration.ts index 937c08b7e8cb5e..2ec67393df76bc 100644 --- a/x-pack/plugins/fleet/common/constants/preconfiguration.ts +++ b/x-pack/plugins/fleet/common/constants/preconfiguration.ts @@ -12,6 +12,7 @@ import { FLEET_SYSTEM_PACKAGE, FLEET_SERVER_PACKAGE, autoUpdatePackages, + monitoringTypes, } from './epm'; export const PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE = @@ -40,7 +41,7 @@ export const DEFAULT_AGENT_POLICY: PreconfiguredAgentPolicyWithDefaultInputs = { ], is_default: true, is_managed: false, - monitoring_enabled: ['logs', 'metrics'] as Array<'logs' | 'metrics'>, + monitoring_enabled: monitoringTypes, }; export const DEFAULT_FLEET_SERVER_AGENT_POLICY: PreconfiguredAgentPolicyWithDefaultInputs = { @@ -58,7 +59,7 @@ export const DEFAULT_FLEET_SERVER_AGENT_POLICY: PreconfiguredAgentPolicyWithDefa is_default: false, is_default_fleet_server: true, is_managed: false, - monitoring_enabled: ['logs', 'metrics'] as Array<'logs' | 'metrics'>, + monitoring_enabled: monitoringTypes, }; export const DEFAULT_PACKAGES = defaultPackages.map((name) => ({ diff --git a/x-pack/plugins/fleet/common/constants/routes.ts b/x-pack/plugins/fleet/common/constants/routes.ts index 037c0ee506a05c..0b892bacf53a7b 100644 --- a/x-pack/plugins/fleet/common/constants/routes.ts +++ b/x-pack/plugins/fleet/common/constants/routes.ts @@ -117,5 +117,5 @@ export const INSTALL_SCRIPT_API_ROUTES = `${API_ROOT}/install/{osType}`; // Policy preconfig API routes export const PRECONFIGURATION_API_ROUTES = { - PUT_PRECONFIG: `${API_ROOT}/setup/preconfiguration`, + UPDATE_PATTERN: `${API_ROOT}/setup/preconfiguration`, }; diff --git a/x-pack/plugins/fleet/common/types/models/agent_policy.ts b/x-pack/plugins/fleet/common/types/models/agent_policy.ts index a9393abcc57ef8..f64467ca674fb2 100644 --- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts @@ -6,7 +6,7 @@ */ import type { agentPolicyStatuses } from '../../constants'; -import type { DataType, ValueOf } from '../../types'; +import type { MonitoringType, ValueOf } from '../../types'; import type { PackagePolicy, PackagePolicyPackage } from './package_policy'; import type { Output } from './output'; @@ -20,7 +20,8 @@ export interface NewAgentPolicy { is_default?: boolean; is_default_fleet_server?: boolean; // Optional when creating a policy is_managed?: boolean; // Optional when creating a policy - monitoring_enabled?: Array>; + monitoring_enabled?: MonitoringType; + unenroll_timeout?: number; is_preconfigured?: boolean; } @@ -138,4 +139,8 @@ export interface FleetServerPolicy { * True when this policy is the default policy to start Fleet Server */ default_fleet_server: boolean; + /** + * Auto unenroll any Elastic Agents which have not checked in for this many seconds + */ + unenroll_timeout?: number; } diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index c4441fb6e0d95b..36554b84093646 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -14,6 +14,7 @@ import type { ASSETS_SAVED_OBJECT_TYPE, agentAssetTypes, dataTypes, + monitoringTypes, installationStatuses, } from '../../constants'; import type { ValueOf } from '../../types'; @@ -92,7 +93,7 @@ export enum ElasticsearchAssetType { } export type DataType = typeof dataTypes; - +export type MonitoringType = typeof monitoringTypes; export type InstallablePackage = RegistryPackage | ArchivePackage; export type ArchivePackage = PackageSpecManifest & diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx index 25a09932428227..633f8a2c57409e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx @@ -21,6 +21,7 @@ import { EuiCheckboxGroup, EuiButton, EuiLink, + EuiFieldNumber, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -158,6 +159,10 @@ export const AgentPolicyForm: React.FunctionComponent = ({ ); }); + const unenrollmentTimeoutText = i18n.translate( + 'xpack.fleet.agentPolicyForm.unenrollmentTimeoutLabel', + { defaultMessage: 'Unenrollment timeout' } + ); const advancedOptionsContent = ( <> @@ -297,6 +302,27 @@ export const AgentPolicyForm: React.FunctionComponent = ({ }} /> + {unenrollmentTimeoutText}} + description={ + + } + > + + updateAgentPolicy({ unenroll_timeout: Number(e.target.value) })} + isInvalid={Boolean(touchedFields.unenroll_timeout && validation.unenroll_timeout)} + onBlur={() => setTouchedFields({ ...touchedFields, unenroll_timeout: true })} + placeholder={unenrollmentTimeoutText} + /> + + {isEditing && 'id' in agentPolicy && !agentPolicy.is_managed && diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx index 1ea1a7de53b959..0c6451e3f34a22 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx @@ -65,12 +65,13 @@ export const SettingsView = memo<{ agentPolicy: AgentPolicy }>( setIsLoading(true); try { // eslint-disable-next-line @typescript-eslint/naming-convention - const { name, description, namespace, monitoring_enabled } = agentPolicy; + const { name, description, namespace, monitoring_enabled, unenroll_timeout } = agentPolicy; const { data, error } = await sendUpdateAgentPolicy(agentPolicy.id, { name, description, namespace, monitoring_enabled, + unenroll_timeout, }); if (data) { notifications.toasts.addSuccess( diff --git a/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts b/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts index 77fe74fda54d99..d6c483ffe30d9d 100644 --- a/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts +++ b/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts @@ -15,7 +15,7 @@ import { PutPreconfigurationSchema } from '../../types'; import { defaultIngestErrorHandler } from '../../errors'; import { ensurePreconfiguredPackagesAndPolicies, outputService } from '../../services'; -export const putPreconfigurationHandler: RequestHandler< +export const updatePreconfigurationHandler: RequestHandler< undefined, undefined, TypeOf @@ -43,10 +43,10 @@ export const putPreconfigurationHandler: RequestHandler< export const registerRoutes = (router: IRouter) => { router.put( { - path: PRECONFIGURATION_API_ROUTES.PUT_PRECONFIG, + path: PRECONFIGURATION_API_ROUTES.UPDATE_PATTERN, validate: PutPreconfigurationSchema, options: { tags: [`access:${PLUGIN_ID}-all`] }, }, - putPreconfigurationHandler + updatePreconfigurationHandler ); }; diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index bd7bb98eb7c07c..fe8771115a2174 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -149,6 +149,7 @@ const getSavedObjectTypes = ( is_managed: { type: 'boolean' }, status: { type: 'keyword' }, package_policies: { type: 'keyword' }, + unenroll_timeout: { type: 'integer' }, updated_at: { type: 'date' }, updated_by: { type: 'keyword' }, revision: { type: 'integer' }, diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 2a6036d99281e8..465075cca7a0b5 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -642,6 +642,7 @@ class AgentPolicyService { data: (fullPolicy as unknown) as FleetServerPolicy['data'], policy_id: fullPolicy.id, default_fleet_server: policy.is_default_fleet_server === true, + unenroll_timeout: policy.unenroll_timeout, }; await esClient.create({ diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index a8be94ca61c0ad..e016fafe5459db 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -108,7 +108,7 @@ export async function ensurePreconfiguredPackagesAndPolicies( policies.map(async (preconfiguredAgentPolicy) => { if (preconfiguredAgentPolicy.id) { // Check to see if a preconfigured policy with the same preconfiguration id was already deleted by the user - const preconfigurationId = String(preconfiguredAgentPolicy.id); + const preconfigurationId = preconfiguredAgentPolicy.id.toString(); const searchParams = { searchFields: ['id'], search: escapeSearchQueryPhrase(preconfigurationId), diff --git a/x-pack/plugins/fleet/server/types/models/agent_policy.ts b/x-pack/plugins/fleet/server/types/models/agent_policy.ts index db551b25e9ebb4..48aea1b5cbcc4c 100644 --- a/x-pack/plugins/fleet/server/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/server/types/models/agent_policy.ts @@ -16,6 +16,7 @@ export const AgentPolicyBaseSchema = { namespace: NamespaceSchema, description: schema.maybe(schema.string()), is_managed: schema.maybe(schema.boolean()), + unenroll_timeout: schema.maybe(schema.number({ min: 1 })), monitoring_enabled: schema.maybe( schema.arrayOf( schema.oneOf([schema.literal(dataTypes.Logs), schema.literal(dataTypes.Metrics)]) diff --git a/x-pack/test/fleet_api_integration/apis/preconfiguration/preconfiguration.ts b/x-pack/test/fleet_api_integration/apis/preconfiguration/preconfiguration.ts index 7fc784ee11af1d..7c5c7d7f3f8046 100644 --- a/x-pack/test/fleet_api_integration/apis/preconfiguration/preconfiguration.ts +++ b/x-pack/test/fleet_api_integration/apis/preconfiguration/preconfiguration.ts @@ -37,7 +37,7 @@ export default function (providerContext: FtrProviderContext) { // Basic health check for the API; functionality is covered by the unit tests it('should succeed with an empty payload', async () => { const { body } = await supertest - .put(PRECONFIGURATION_API_ROUTES.PUT_PRECONFIG) + .put(PRECONFIGURATION_API_ROUTES.UPDATE_PATTERN) .set('kbn-xsrf', 'xxxx') .send({}) .expect(200); From eb7e0fa5f18ecbadddf9d7273ef0c4536553f50f Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 23 Jun 2021 12:06:23 -0700 Subject: [PATCH 46/61] Reporting: Check for pending jobs scheduled with ESQueue (#101447) * Reporting: Check for pending jobs scheduled with ESQueue * Update x-pack/plugins/reporting/server/lib/tasks/execute_report.ts Co-authored-by: Vadim Dalecky * update test assertions, use more explicit types * update comment * Update x-pack/plugins/reporting/server/lib/store/store.ts Co-authored-by: Vadim Dalecky * fix field mapping * Update x-pack/plugins/reporting/server/lib/store/store.ts Co-authored-by: Jean-Louis Leysens * Report also implements ReportDocumentHead * the actual ID of the task is prefixed with `task:` * remove pointless update to the report instance after failing * comment clarification Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Vadim Dalecky Co-authored-by: Jean-Louis Leysens --- x-pack/plugins/reporting/common/types.ts | 3 +- .../reporting/server/lib/enqueue_job.ts | 2 +- .../plugins/reporting/server/lib/statuses.ts | 5 +- .../reporting/server/lib/store/mapping.ts | 18 +- .../reporting/server/lib/store/report.test.ts | 55 ++-- .../reporting/server/lib/store/report.ts | 49 ++-- .../reporting/server/lib/store/store.test.ts | 152 ++++++----- .../reporting/server/lib/store/store.ts | 239 +++++++++++------- .../server/lib/tasks/execute_report.ts | 133 ++++++---- .../reporting/server/lib/tasks/index.ts | 8 - .../server/lib/tasks/monitor_reports.ts | 105 ++++---- .../reporting_without_security/job_apis.ts | 1 - 12 files changed, 415 insertions(+), 355 deletions(-) diff --git a/x-pack/plugins/reporting/common/types.ts b/x-pack/plugins/reporting/common/types.ts index 2148cf983d8890..8205b4f13a3201 100644 --- a/x-pack/plugins/reporting/common/types.ts +++ b/x-pack/plugins/reporting/common/types.ts @@ -68,6 +68,7 @@ export interface ReportSource { }; meta: { objectType: string; layout?: string }; browser_type: string; + migration_version: string; max_attempts: number; timeout: number; @@ -77,7 +78,7 @@ export interface ReportSource { started_at?: string; completed_at?: string; created_at: string; - process_expiration?: string; + process_expiration?: string | null; // must be set to null to clear the expiration } /* diff --git a/x-pack/plugins/reporting/server/lib/enqueue_job.ts b/x-pack/plugins/reporting/server/lib/enqueue_job.ts index b0e5d7bafb03c4..70492b415f961d 100644 --- a/x-pack/plugins/reporting/server/lib/enqueue_job.ts +++ b/x-pack/plugins/reporting/server/lib/enqueue_job.ts @@ -68,7 +68,7 @@ export function enqueueJobFactory( // 2. Schedule the report with Task Manager const task = await reporting.scheduleTask(report.toReportTaskJSON()); logger.info( - `Scheduled ${exportType.name} reporting task. Task ID: ${task.id}. Report ID: ${report._id}` + `Scheduled ${exportType.name} reporting task. Task ID: task:${task.id}. Report ID: ${report._id}` ); return report; diff --git a/x-pack/plugins/reporting/server/lib/statuses.ts b/x-pack/plugins/reporting/server/lib/statuses.ts index 1aa6b6d5ac8ffd..2c25708078aaff 100644 --- a/x-pack/plugins/reporting/server/lib/statuses.ts +++ b/x-pack/plugins/reporting/server/lib/statuses.ts @@ -5,11 +5,12 @@ * 2.0. */ -export const statuses = { +import { JobStatus } from '../../common/types'; + +export const statuses: Record = { JOB_STATUS_PENDING: 'pending', JOB_STATUS_PROCESSING: 'processing', JOB_STATUS_COMPLETED: 'completed', JOB_STATUS_WARNINGS: 'completed_with_warnings', JOB_STATUS_FAILED: 'failed', - JOB_STATUS_CANCELLED: 'cancelled', }; diff --git a/x-pack/plugins/reporting/server/lib/store/mapping.ts b/x-pack/plugins/reporting/server/lib/store/mapping.ts index ce8f768ef077fb..69f432562ec983 100644 --- a/x-pack/plugins/reporting/server/lib/store/mapping.ts +++ b/x-pack/plugins/reporting/server/lib/store/mapping.ts @@ -7,15 +7,10 @@ export const mapping = { meta: { - // We are indexing these properties with both text and keyword fields because that's what will be auto generated - // when an index already exists. This schema is only used when a reporting index doesn't exist. This way existing - // reporting indexes and new reporting indexes will look the same and the data can be queried in the same - // manner. + // We are indexing these properties with both text and keyword fields + // because that's what will be auto generated when an index already exists. properties: { - /** - * Type of object that is triggering this report. Should be either search, visualization or dashboard. - * Used for job listing and telemetry stats only. - */ + // ID of the app this report: search, visualization or dashboard, etc objectType: { type: 'text', fields: { @@ -25,10 +20,6 @@ export const mapping = { }, }, }, - /** - * Can be either preserve_layout, print or none (in the case of csv export). - * Used for phone home stats only. - */ layout: { type: 'text', fields: { @@ -41,9 +32,10 @@ export const mapping = { }, }, browser_type: { type: 'keyword' }, + migration_version: { type: 'keyword' }, // new field (7.14) to distinguish reports that were scheduled with Task Manager jobtype: { type: 'keyword' }, payload: { type: 'object', enabled: false }, - priority: { type: 'byte' }, // NOTE: this is unused, but older data may have a mapping for this field + priority: { type: 'byte' }, // TODO: remove: this is unused timeout: { type: 'long' }, process_expiration: { type: 'date' }, created_by: { type: 'keyword' }, // `null` if security is disabled diff --git a/x-pack/plugins/reporting/server/lib/store/report.test.ts b/x-pack/plugins/reporting/server/lib/store/report.test.ts index 23d766f2190f6d..a8d14e12a738be 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.test.ts @@ -20,21 +20,18 @@ describe('Class Report', () => { timeout: 30000, }); - expect(report.toEsDocsJSON()).toMatchObject({ - _index: '.reporting-test-index-12345', - _source: { - attempts: 0, - browser_type: 'browser_type_test_string', - completed_at: undefined, - created_by: 'created_by_test_string', - jobtype: 'test-report', - max_attempts: 50, - meta: { objectType: 'test' }, - payload: { headers: 'payload_test_field', objectType: 'testOt' }, - started_at: undefined, - status: 'pending', - timeout: 30000, - }, + expect(report.toReportSource()).toMatchObject({ + attempts: 0, + browser_type: 'browser_type_test_string', + completed_at: undefined, + created_by: 'created_by_test_string', + jobtype: 'test-report', + max_attempts: 50, + meta: { objectType: 'test' }, + payload: { headers: 'payload_test_field', objectType: 'testOt' }, + started_at: undefined, + status: 'pending', + timeout: 30000, }); expect(report.toReportTaskJSON()).toMatchObject({ attempts: 0, @@ -80,22 +77,18 @@ describe('Class Report', () => { }; report.updateWithEsDoc(metadata); - expect(report.toEsDocsJSON()).toMatchObject({ - _id: '12342p9o387549o2345', - _index: '.reporting-test-update', - _source: { - attempts: 0, - browser_type: 'browser_type_test_string', - completed_at: undefined, - created_by: 'created_by_test_string', - jobtype: 'test-report', - max_attempts: 50, - meta: { objectType: 'stange' }, - payload: { objectType: 'testOt' }, - started_at: undefined, - status: 'pending', - timeout: 30000, - }, + expect(report.toReportSource()).toMatchObject({ + attempts: 0, + browser_type: 'browser_type_test_string', + completed_at: undefined, + created_by: 'created_by_test_string', + jobtype: 'test-report', + max_attempts: 50, + meta: { objectType: 'stange' }, + payload: { objectType: 'testOt' }, + started_at: undefined, + status: 'pending', + timeout: 30000, }); expect(report.toReportTaskJSON()).toMatchObject({ attempts: 0, diff --git a/x-pack/plugins/reporting/server/lib/store/report.ts b/x-pack/plugins/reporting/server/lib/store/report.ts index 9b98650e1d984a..fa5b91527ccc47 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.ts @@ -21,8 +21,13 @@ export { ReportDocument }; export { ReportApiJSON, ReportSource }; const puid = new Puid(); +export const MIGRATION_VERSION = '7.14.0'; -export class Report implements Partial { +/* + * The public fields are a flattened version what Elasticsearch returns when you + * `GET` a document. + */ +export class Report implements Partial { public _index?: string; public _id: string; public _primary_term?: number; // set by ES @@ -47,6 +52,7 @@ export class Report implements Partial { public readonly timeout?: ReportSource['timeout']; public process_expiration?: ReportSource['process_expiration']; + public migration_version: string; /* * Create an unsaved report @@ -58,6 +64,8 @@ export class Report implements Partial { this._primary_term = opts._primary_term; this._seq_no = opts._seq_no; + this.migration_version = MIGRATION_VERSION; + this.payload = opts.payload!; this.kibana_name = opts.kibana_name!; this.kibana_id = opts.kibana_id!; @@ -80,7 +88,7 @@ export class Report implements Partial { /* * Update the report with "live" storage metadata */ - updateWithEsDoc(doc: Partial) { + updateWithEsDoc(doc: Partial): void { if (doc._index == null || doc._id == null) { throw new Error(`Report object from ES has missing fields!`); } @@ -89,30 +97,31 @@ export class Report implements Partial { this._index = doc._index; this._primary_term = doc._primary_term; this._seq_no = doc._seq_no; + this.migration_version = MIGRATION_VERSION; } /* * Data structure for writing to Elasticsearch index */ - toEsDocsJSON() { + toReportSource(): ReportSource { return { - _id: this._id, - _index: this._index, - _source: { - jobtype: this.jobtype, - created_at: this.created_at, - created_by: this.created_by, - payload: this.payload, - meta: this.meta, - timeout: this.timeout, - max_attempts: this.max_attempts, - browser_type: this.browser_type, - status: this.status, - attempts: this.attempts, - started_at: this.started_at, - completed_at: this.completed_at, - process_expiration: this.process_expiration, - }, + migration_version: MIGRATION_VERSION, + kibana_name: this.kibana_name, + kibana_id: this.kibana_id, + jobtype: this.jobtype, + created_at: this.created_at, + created_by: this.created_by, + payload: this.payload, + meta: this.meta, + timeout: this.timeout!, + max_attempts: this.max_attempts, + browser_type: this.browser_type!, + status: this.status, + attempts: this.attempts, + started_at: this.started_at, + completed_at: this.completed_at, + process_expiration: this.process_expiration, + output: this.output || null, }; } diff --git a/x-pack/plugins/reporting/server/lib/store/store.test.ts b/x-pack/plugins/reporting/server/lib/store/store.test.ts index 7f96433fcc6ceb..8bb5c7fb8bbf91 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.test.ts @@ -184,6 +184,7 @@ describe('ReportingStore', () => { _source: { kibana_name: 'test', kibana_id: 'test123', + migration_version: 'X.0.0', created_at: 'some time', created_by: 'some security person', jobtype: 'csv', @@ -222,6 +223,7 @@ describe('ReportingStore', () => { "meta": Object { "testMeta": "meta", }, + "migration_version": "7.14.0", "output": null, "payload": Object { "testPayload": "payload", @@ -239,6 +241,8 @@ describe('ReportingStore', () => { const report = new Report({ _id: 'id-of-processing', _index: '.reporting-test-index-12345', + _seq_no: 42, + _primary_term: 10002, jobtype: 'test-report', created_by: 'created_by_test_string', browser_type: 'browser_type_test_string', @@ -254,24 +258,12 @@ describe('ReportingStore', () => { await store.setReportClaimed(report, { testDoc: 'test' } as any); - const [updateCall] = mockEsClient.update.mock.calls; - expect(updateCall).toMatchInlineSnapshot(` - Array [ - Object { - "body": Object { - "doc": Object { - "status": "processing", - "testDoc": "test", - }, - }, - "id": "id-of-processing", - "if_primary_term": undefined, - "if_seq_no": undefined, - "index": ".reporting-test-index-12345", - "refresh": true, - }, - ] - `); + const [[updateCall]] = mockEsClient.update.mock.calls; + const response = updateCall.body?.doc as Report; + expect(response.migration_version).toBe(`7.14.0`); + expect(response.status).toBe(`processing`); + expect(updateCall.if_seq_no).toBe(42); + expect(updateCall.if_primary_term).toBe(10002); }); it('setReportFailed sets the status of a record to failed', async () => { @@ -279,6 +271,8 @@ describe('ReportingStore', () => { const report = new Report({ _id: 'id-of-failure', _index: '.reporting-test-index-12345', + _seq_no: 43, + _primary_term: 10002, jobtype: 'test-report', created_by: 'created_by_test_string', browser_type: 'browser_type_test_string', @@ -294,24 +288,12 @@ describe('ReportingStore', () => { await store.setReportFailed(report, { errors: 'yes' } as any); - const [updateCall] = mockEsClient.update.mock.calls; - expect(updateCall).toMatchInlineSnapshot(` - Array [ - Object { - "body": Object { - "doc": Object { - "errors": "yes", - "status": "failed", - }, - }, - "id": "id-of-failure", - "if_primary_term": undefined, - "if_seq_no": undefined, - "index": ".reporting-test-index-12345", - "refresh": true, - }, - ] - `); + const [[updateCall]] = mockEsClient.update.mock.calls; + const response = updateCall.body?.doc as Report; + expect(response.migration_version).toBe(`7.14.0`); + expect(response.status).toBe(`failed`); + expect(updateCall.if_seq_no).toBe(43); + expect(updateCall.if_primary_term).toBe(10002); }); it('setReportCompleted sets the status of a record to completed', async () => { @@ -319,6 +301,8 @@ describe('ReportingStore', () => { const report = new Report({ _id: 'vastly-great-report-id', _index: '.reporting-test-index-12345', + _seq_no: 44, + _primary_term: 10002, jobtype: 'test-report', created_by: 'created_by_test_string', browser_type: 'browser_type_test_string', @@ -334,31 +318,21 @@ describe('ReportingStore', () => { await store.setReportCompleted(report, { certainly_completed: 'yes' } as any); - const [updateCall] = mockEsClient.update.mock.calls; - expect(updateCall).toMatchInlineSnapshot(` - Array [ - Object { - "body": Object { - "doc": Object { - "certainly_completed": "yes", - "status": "completed", - }, - }, - "id": "vastly-great-report-id", - "if_primary_term": undefined, - "if_seq_no": undefined, - "index": ".reporting-test-index-12345", - "refresh": true, - }, - ] - `); + const [[updateCall]] = mockEsClient.update.mock.calls; + const response = updateCall.body?.doc as Report; + expect(response.migration_version).toBe(`7.14.0`); + expect(response.status).toBe(`completed`); + expect(updateCall.if_seq_no).toBe(44); + expect(updateCall.if_primary_term).toBe(10002); }); - it('setReportCompleted sets the status of a record to completed_with_warnings', async () => { + it('sets the status of a record to completed_with_warnings', async () => { const store = new ReportingStore(mockCore, mockLogger); const report = new Report({ _id: 'vastly-great-report-id', _index: '.reporting-test-index-12345', + _seq_no: 45, + _primary_term: 10002, jobtype: 'test-report', created_by: 'created_by_test_string', browser_type: 'browser_type_test_string', @@ -379,28 +353,52 @@ describe('ReportingStore', () => { }, } as any); - const [updateCall] = mockEsClient.update.mock.calls; - expect(updateCall).toMatchInlineSnapshot(` - Array [ - Object { - "body": Object { - "doc": Object { - "certainly_completed": "pretty_much", - "output": Object { - "warnings": Array [ - "those pants don't go with that shirt", - ], - }, - "status": "completed_with_warnings", - }, - }, - "id": "vastly-great-report-id", - "if_primary_term": undefined, - "if_seq_no": undefined, - "index": ".reporting-test-index-12345", - "refresh": true, - }, - ] + const [[updateCall]] = mockEsClient.update.mock.calls; + const response = updateCall.body?.doc as Report; + + expect(response.migration_version).toBe(`7.14.0`); + expect(response.status).toBe(`completed_with_warnings`); + expect(updateCall.if_seq_no).toBe(45); + expect(updateCall.if_primary_term).toBe(10002); + expect(response.output).toMatchInlineSnapshot(` + Object { + "warnings": Array [ + "those pants don't go with that shirt", + ], + } `); }); + + it('prepareReportForRetry resets the expiration and status on the report document', async () => { + const store = new ReportingStore(mockCore, mockLogger); + const report = new Report({ + _id: 'pretty-good-report-id', + _index: '.reporting-test-index-94058763', + _seq_no: 46, + _primary_term: 10002, + jobtype: 'test-report-2', + created_by: 'created_by_test_string', + browser_type: 'browser_type_test_string', + status: 'processing', + process_expiration: '2002', + max_attempts: 3, + payload: { + title: 'test report', + headers: 'rp_test_headers', + objectType: 'testOt', + browserTimezone: 'utc', + }, + timeout: 30000, + }); + + await store.prepareReportForRetry(report); + + const [[updateCall]] = mockEsClient.update.mock.calls; + const response = updateCall.body?.doc as Report; + + expect(response.migration_version).toBe(`7.14.0`); + expect(response.status).toBe(`pending`); + expect(updateCall.if_seq_no).toBe(46); + expect(updateCall.if_primary_term).toBe(10002); + }); }); diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts index fc7bd9c23d7693..8f1e6c315a2d1e 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -5,15 +5,38 @@ * 2.0. */ +import { IndexResponse, UpdateResponse } from '@elastic/elasticsearch/api/types'; import { ElasticsearchClient } from 'src/core/server'; import { LevelLogger, statuses } from '../'; import { ReportingCore } from '../../'; -import { numberToDuration } from '../../../common/schema_utils'; import { JobStatus } from '../../../common/types'; import { ReportTaskParams } from '../tasks'; import { indexTimestamp } from './index_timestamp'; import { mapping } from './mapping'; -import { Report, ReportDocument, ReportSource } from './report'; +import { MIGRATION_VERSION, Report, ReportDocument, ReportSource } from './report'; + +/* + * When an instance of Kibana claims a report job, this information tells us about that instance + */ +export type ReportProcessingFields = Required<{ + kibana_id: Report['kibana_id']; + kibana_name: Report['kibana_name']; + browser_type: Report['browser_type']; + attempts: Report['attempts']; + started_at: Report['started_at']; + timeout: Report['timeout']; + process_expiration: Report['process_expiration']; +}>; + +export type ReportFailedFields = Required<{ + completed_at: Report['completed_at']; + output: Report['output']; +}>; + +export type ReportCompletedFields = Required<{ + completed_at: Report['completed_at']; + output: Report['output']; +}>; /* * When searching for long-pending reports, we get a subset of fields @@ -24,15 +47,38 @@ export interface ReportRecordTimeout { _source: { status: JobStatus; process_expiration?: string; - created_at?: string; }; } const checkReportIsEditable = (report: Report) => { - if (!report._id || !report._index) { - throw new Error(`Report object is not synced with ES!`); + const { _id, _index, _seq_no, _primary_term } = report; + if (_id == null || _index == null) { + throw new Error(`Report is not editable: Job [${_id}] is not synced with ES!`); + } + + if (_seq_no == null || _primary_term == null) { + throw new Error( + `Report is not editable: Job [${_id}] is missing _seq_no and _primary_term fields!` + ); } }; +/* + * When searching for long-pending reports, we get a subset of fields + */ +const sourceDoc = (doc: Partial): Partial => { + return { + ...doc, + migration_version: MIGRATION_VERSION, + }; +}; + +const jobDebugMessage = (report: Report) => + `${report._id} ` + + `[_index: ${report._index}] ` + + `[_seq_no: ${report._seq_no}] ` + + `[_primary_term: ${report._primary_term}]` + + `[attempts: ${report.attempts}] ` + + `[process_expiration: ${report.process_expiration}]`; /* * A class to give an interface to historical reports in the reporting.index @@ -43,7 +89,6 @@ const checkReportIsEditable = (report: Report) => { export class ReportingStore { private readonly indexPrefix: string; // config setting of index prefix in system index name private readonly indexInterval: string; // config setting of index prefix: how often to poll for pending work - private readonly queueTimeoutMins: number; // config setting of queue timeout, rounded up to nearest minute private client?: ElasticsearchClient; constructor(private reportingCore: ReportingCore, private logger: LevelLogger) { @@ -52,7 +97,6 @@ export class ReportingStore { this.indexPrefix = config.get('index'); this.indexInterval = config.get('queue', 'indexInterval'); this.logger = logger.clone(['store']); - this.queueTimeoutMins = Math.ceil(numberToDuration(config.get('queue', 'timeout')).asMinutes()); } private async getClient() { @@ -103,18 +147,20 @@ export class ReportingStore { /* * Called from addReport, which handles any errors */ - private async indexReport(report: Report) { + private async indexReport(report: Report): Promise { const doc = { index: report._index!, id: report._id, + refresh: true, body: { - ...report.toEsDocsJSON()._source, - process_expiration: new Date(0), // use epoch so the job query works - attempts: 0, - status: statuses.JOB_STATUS_PENDING, + ...report.toReportSource(), + ...sourceDoc({ + process_expiration: new Date(0).toISOString(), + attempts: 0, + status: statuses.JOB_STATUS_PENDING, + }), }, }; - const client = await this.getClient(); const { body } = await client.index(doc); @@ -140,8 +186,7 @@ export class ReportingStore { await this.createIndex(index); try { - const doc = await this.indexReport(report); - report.updateWithEsDoc(doc); + report.updateWithEsDoc(await this.indexReport(report)); await this.refreshIndex(index); @@ -156,7 +201,9 @@ export class ReportingStore { /* * Search for a report from task data and return back the report */ - public async findReportFromTask(taskJson: ReportTaskParams): Promise { + public async findReportFromTask( + taskJson: Pick + ): Promise { if (!taskJson.index) { throw new Error('Task JSON is missing index field!'); } @@ -186,41 +233,23 @@ export class ReportingStore { timeout: document._source?.timeout, }); } catch (err) { - this.logger.error('Error in finding a report! ' + JSON.stringify({ report: taskJson })); - this.logger.error(err); - throw err; - } - } - - public async setReportPending(report: Report) { - const doc = { status: statuses.JOB_STATUS_PENDING }; - - try { - checkReportIsEditable(report); - - const client = await this.getClient(); - const { body } = await client.update({ - id: report._id, - index: report._index!, - if_seq_no: report._seq_no, - if_primary_term: report._primary_term, - refresh: true, - body: { doc }, - }); - - return (body as unknown) as ReportDocument; - } catch (err) { - this.logger.error('Error in setting report pending status!'); + this.logger.error( + `Error in finding the report from the scheduled task info! ` + + `[id: ${taskJson.id}] [index: ${taskJson.index}]` + ); this.logger.error(err); throw err; } } - public async setReportClaimed(report: Report, stats: Partial): Promise { - const doc = { - ...stats, + public async setReportClaimed( + report: Report, + processingInfo: ReportProcessingFields + ): Promise> { + const doc = sourceDoc({ + ...processingInfo, status: statuses.JOB_STATUS_PROCESSING, - }; + }); try { checkReportIsEditable(report); @@ -235,19 +264,24 @@ export class ReportingStore { body: { doc }, }); - return (body as unknown) as ReportDocument; + return body; } catch (err) { - this.logger.error('Error in setting report processing status!'); + this.logger.error( + `Error in updating status to processing! Report: ` + jobDebugMessage(report) + ); this.logger.error(err); throw err; } } - public async setReportFailed(report: Report, stats: Partial): Promise { - const doc = { - ...stats, + public async setReportFailed( + report: Report, + failedInfo: ReportFailedFields + ): Promise> { + const doc = sourceDoc({ + ...failedInfo, status: statuses.JOB_STATUS_FAILED, - }; + }); try { checkReportIsEditable(report); @@ -261,26 +295,29 @@ export class ReportingStore { refresh: true, body: { doc }, }); - - return (body as unknown) as ReportDocument; + return body; } catch (err) { - this.logger.error('Error in setting report failed status!'); + this.logger.error(`Error in updating status to failed! Report: ` + jobDebugMessage(report)); this.logger.error(err); throw err; } } - public async setReportCompleted(report: Report, stats: Partial): Promise { + public async setReportCompleted( + report: Report, + completedInfo: ReportCompletedFields + ): Promise> { + const { output } = completedInfo; + const status = + output && output.warnings && output.warnings.length > 0 + ? statuses.JOB_STATUS_WARNINGS + : statuses.JOB_STATUS_COMPLETED; + const doc = sourceDoc({ + ...completedInfo, + status, + }); + try { - const { output } = stats; - const status = - output && output.warnings && output.warnings.length > 0 - ? statuses.JOB_STATUS_WARNINGS - : statuses.JOB_STATUS_COMPLETED; - const doc = { - ...stats, - status, - }; checkReportIsEditable(report); const client = await this.getClient(); @@ -292,16 +329,20 @@ export class ReportingStore { refresh: true, body: { doc }, }); - - return (body as unknown) as ReportDocument; + return body; } catch (err) { - this.logger.error('Error in setting report complete status!'); + this.logger.error(`Error in updating status to complete! Report: ` + jobDebugMessage(report)); this.logger.error(err); throw err; } } - public async clearExpiration(report: Report): Promise { + public async prepareReportForRetry(report: Report): Promise> { + const doc = sourceDoc({ + status: statuses.JOB_STATUS_PENDING, + process_expiration: null, + }); + try { checkReportIsEditable(report); @@ -312,50 +353,54 @@ export class ReportingStore { if_seq_no: report._seq_no, if_primary_term: report._primary_term, refresh: true, - body: { doc: { process_expiration: null } }, + body: { doc }, }); - - return (body as unknown) as ReportDocument; + return body; } catch (err) { - this.logger.error('Error in clearing expiration!'); + this.logger.error( + `Error in clearing expiration and status for retry! Report: ` + jobDebugMessage(report) + ); this.logger.error(err); throw err; } } /* - * A zombie report document is one that isn't completed or failed, isn't - * being executed, and isn't scheduled to run. They arise: - * - when the cluster has processing documents in ESQueue before upgrading to v7.13 when ESQueue was removed - * - if Kibana crashes while a report task is executing and it couldn't be rescheduled on its own - * - * Pending reports are not included in this search: they may be scheduled in TM just not run yet. - * TODO Should we get a list of the reports that are pending and scheduled in TM so we can exclude them from this query? + * A report needs to be rescheduled when: + * 1. An older version of Kibana created jobs with ESQueue, and they have + * not yet started running. + * 2. The report process_expiration field is overdue, which happens if the + * report runs too long or Kibana restarts during execution */ - public async findZombieReportDocuments(): Promise { + public async findStaleReportJob(): Promise { const client = await this.getClient(); + + const expiredFilter = { + bool: { + must: [ + { range: { process_expiration: { lt: `now` } } }, + { terms: { status: [statuses.JOB_STATUS_PROCESSING] } }, + ], + }, + }; + const oldVersionFilter = { + bool: { + must: [{ terms: { status: [statuses.JOB_STATUS_PENDING] } }], + must_not: [{ exists: { field: 'migration_version' } }], + }, + }; + const { body } = await client.search({ + size: 1, index: this.indexPrefix + '-*', - filter_path: 'hits.hits', + seq_no_primary_term: true, + _source_excludes: ['output'], body: { - sort: { created_at: { order: 'desc' } }, - query: { - bool: { - filter: [ - { - bool: { - must: [ - { range: { process_expiration: { lt: `now-${this.queueTimeoutMins}m` } } }, - { terms: { status: [statuses.JOB_STATUS_PROCESSING] } }, - ], - }, - }, - ], - }, - }, + sort: { created_at: { order: 'asc' as const } }, // find the oldest first + query: { bool: { filter: { bool: { should: [expiredFilter, oldVersionFilter] } } } }, }, }); - return body.hits?.hits as ReportRecordTimeout[]; + return body.hits?.hits[0] as ReportRecordTimeout; } } diff --git a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts index 2960ce457b7ae2..f9e2cd82b0805c 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { UpdateResponse } from '@elastic/elasticsearch/api/types'; import moment from 'moment'; import * as Rx from 'rxjs'; import { timeout } from 'rxjs/operators'; @@ -19,9 +20,9 @@ import { CancellationToken } from '../../../common'; import { durationToNumber, numberToDuration } from '../../../common/schema_utils'; import { ReportingConfigType } from '../../config'; import { BasePayload, RunTaskFn } from '../../types'; -import { Report, ReportingStore } from '../store'; +import { Report, ReportDocument, ReportingStore } from '../store'; +import { ReportFailedFields, ReportProcessingFields } from '../store/store'; import { - ReportingExecuteTaskInstance, ReportingTask, ReportingTaskStatus, REPORTING_EXECUTE_TYPE, @@ -30,6 +31,13 @@ import { } from './'; import { errorLogger } from './error_logger'; +interface ReportingExecuteTaskInstance { + state: object; + taskType: string; + params: ReportTaskParams; + runAt?: Date; +} + function isOutput(output: TaskRunResult | Error): output is TaskRunResult { return typeof output === 'object' && (output as TaskRunResult).content != null; } @@ -101,15 +109,21 @@ export class ExecuteReportTask implements ReportingTask { } public async _claimJob(task: ReportTaskParams): Promise { - const store = await this.getStore(); + if (this.kibanaId == null) { + throw new Error(`Kibana instance ID is undefined!`); + } + if (this.kibanaName == null) { + throw new Error(`Kibana instance name is undefined!`); + } + const store = await this.getStore(); let report: Report; if (task.id && task.index) { // if this is an ad-hoc report, there is a corresponding "pending" record in ReportingStore in need of updating - report = await store.findReportFromTask(task); // update seq_no + report = await store.findReportFromTask(task); // receives seq_no and primary_term } else { // if this is a scheduled report (not implemented), the report object needs to be instantiated - throw new Error('scheduled reports are not supported!'); + throw new Error('Could not find matching report document!'); } // Check if this is a completed job. This may happen if the `reports:monitor` @@ -126,7 +140,7 @@ export class ExecuteReportTask implements ReportingTask { const maxAttempts = task.max_attempts; if (report.attempts >= maxAttempts) { const err = new Error(`Max attempts reached (${maxAttempts}). Queue timeout reached.`); - await this._failJob(task, err); + await this._failJob(report, err); throw err; } @@ -134,7 +148,7 @@ export class ExecuteReportTask implements ReportingTask { const startTime = m.toISOString(); const expirationTime = m.add(queueTimeout).toISOString(); - const stats = { + const doc: ReportProcessingFields = { kibana_id: this.kibanaId, kibana_name: this.kibanaName, browser_type: this.config.capture.browser.type, @@ -144,19 +158,28 @@ export class ExecuteReportTask implements ReportingTask { process_expiration: expirationTime, }; - this.logger.debug(`Claiming ${report.jobtype} job ${report._id}`); - const claimedReport = new Report({ ...report, - ...stats, + ...doc, }); - await store.setReportClaimed(claimedReport, stats); + this.logger.debug( + `Claiming ${claimedReport.jobtype} ${report._id} ` + + `[_index: ${report._index}] ` + + `[_seq_no: ${report._seq_no}] ` + + `[_primary_term: ${report._primary_term}] ` + + `[attempts: ${report.attempts}] ` + + `[process_expiration: ${expirationTime}]` + ); + + const resp = await store.setReportClaimed(claimedReport, doc); + claimedReport._seq_no = resp._seq_no; + claimedReport._primary_term = resp._primary_term; return claimedReport; } - private async _failJob(task: ReportTaskParams, error?: Error) { - const message = `Failing ${task.jobtype} job ${task.id}`; + private async _failJob(report: Report, error?: Error): Promise> { + const message = `Failing ${report.jobtype} job ${report._id}`; // log the error let docOutput; @@ -169,9 +192,8 @@ export class ExecuteReportTask implements ReportingTask { // update the report in the store const store = await this.getStore(); - const report = await store.findReportFromTask(task); const completedTime = moment().toISOString(); - const doc = { + const doc: ReportFailedFields = { completed_at: completedTime, output: docOutput, }; @@ -179,7 +201,7 @@ export class ExecuteReportTask implements ReportingTask { return await store.setReportFailed(report, doc); } - private _formatOutput(output: TaskRunResult | Error) { + private _formatOutput(output: TaskRunResult | Error): TaskRunResult { const docOutput = {} as TaskRunResult; const unknownMime = null; @@ -201,7 +223,10 @@ export class ExecuteReportTask implements ReportingTask { return docOutput; } - public async _performJob(task: ReportTaskParams, cancellationToken: CancellationToken) { + public async _performJob( + task: ReportTaskParams, + cancellationToken: CancellationToken + ): Promise { if (!this.taskExecutors) { throw new Error(`Task run function factories have not been called yet!`); } @@ -220,10 +245,10 @@ export class ExecuteReportTask implements ReportingTask { .toPromise(); } - public async _completeJob(task: ReportTaskParams, output: TaskRunResult) { - let docId = `/${task.index}/_doc/${task.id}`; + public async _completeJob(report: Report, output: TaskRunResult): Promise { + let docId = `/${report._index}/_doc/${report._id}`; - this.logger.info(`Saving ${task.jobtype} job ${docId}.`); + this.logger.debug(`Saving ${report.jobtype} to ${docId}.`); const completedTime = moment().toISOString(); const docOutput = this._formatOutput(output); @@ -233,16 +258,13 @@ export class ExecuteReportTask implements ReportingTask { completed_at: completedTime, output: docOutput, }; - const report = await store.findReportFromTask(task); // update seq_no and primary_term docId = `/${report._index}/_doc/${report._id}`; - try { - await store.setReportCompleted(report, doc); - this.logger.debug(`Saved ${report.jobtype} job ${docId}`); - } catch (err) { - if (err.statusCode === 409) return false; - errorLogger(this.logger, `Failure saving completed job ${docId}!`); - } + const resp = await store.setReportCompleted(report, doc); + this.logger.info(`Saved ${report.jobtype} job ${docId}`); + report._seq_no = resp._seq_no; + report._primary_term = resp._primary_term; + return report; } /* @@ -264,7 +286,6 @@ export class ExecuteReportTask implements ReportingTask { */ run: async () => { let report: Report | undefined; - let attempts = 0; // find the job in the store and set status to processing const task = context.taskInstance.params as ReportTaskParams; @@ -278,64 +299,73 @@ export class ExecuteReportTask implements ReportingTask { // Update job status to claimed report = await this._claimJob(task); - - const { jobtype: jobType, attempts: attempt, max_attempts: maxAttempts } = task; - this.logger.info( - `Starting ${jobType} report ${jobId}: attempt ${attempt + 1} of ${maxAttempts}.` - ); - this.logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`); } catch (failedToClaim) { // error claiming report - log the error // could be version conflict, or no longer connected to ES - errorLogger(this.logger, `Error in claiming report!`, failedToClaim); + errorLogger(this.logger, `Error in claiming ${jobId}`, failedToClaim); } if (!report) { - errorLogger(this.logger, `Report could not be claimed. Exiting...`); + errorLogger(this.logger, `Job ${jobId} could not be claimed. Exiting...`); return; } - attempts = report.attempts; + const { jobtype: jobType, attempts, max_attempts: maxAttempts } = report; + this.logger.debug( + `Starting ${jobType} report ${jobId}: attempt ${attempts} of ${maxAttempts}.` + ); + this.logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`); try { const output = await this._performJob(task, cancellationToken); if (output) { - await this._completeJob(task, output); + report = await this._completeJob(report, output); } - // untrack the report for concurrency awareness this.logger.debug(`Stopping ${jobId}.`); - this.reporting.untrackReport(jobId); - this.logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`); } catch (failedToExecuteErr) { cancellationToken.cancel(); - const maxAttempts = this.config.capture.maxAttempts; if (attempts < maxAttempts) { - // attempts remain - reschedule + // attempts remain, reschedule try { + if (report == null) { + throw new Error(`Report ${jobId} is null!`); + } // reschedule to retry const remainingAttempts = maxAttempts - report.attempts; errorLogger( this.logger, - `Scheduling retry. Retries remaining: ${remainingAttempts}.`, + `Scheduling retry for job ${jobId}. Retries remaining: ${remainingAttempts}.`, failedToExecuteErr ); await this.rescheduleTask(reportFromTask(task).toReportTaskJSON(), this.logger); } catch (rescheduleErr) { // can not be rescheduled - log the error - errorLogger(this.logger, `Could not reschedule the errored job!`, rescheduleErr); + errorLogger( + this.logger, + `Could not reschedule the errored job ${jobId}!`, + rescheduleErr + ); } } else { // 0 attempts remain - fail the job try { - const maxAttemptsMsg = `Max attempts reached (${attempts}). Failed with: ${failedToExecuteErr}`; - await this._failJob(task, new Error(maxAttemptsMsg)); + const maxAttemptsMsg = `Max attempts (${attempts}) reached for job ${jobId}. Failed with: ${failedToExecuteErr}`; + if (report == null) { + throw new Error(`Report ${jobId} is null!`); + } + const resp = await this._failJob(report, new Error(maxAttemptsMsg)); + report._seq_no = resp._seq_no; + report._primary_term = resp._primary_term; } catch (failedToFailError) { - errorLogger(this.logger, `Could not fail the job!`, failedToFailError); + errorLogger(this.logger, `Could not fail ${jobId}!`, failedToFailError); } } + } finally { + this.reporting.untrackReport(jobId); + this.logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`); } }, @@ -374,11 +404,12 @@ export class ExecuteReportTask implements ReportingTask { state: {}, params: report, }; + return await this.getTaskManagerStart().schedule(taskInstance); } private async rescheduleTask(task: ReportTaskParams, logger: LevelLogger) { - logger.info(`Rescheduling ${task.id} to retry after error.`); + logger.info(`Rescheduling task:${task.id} to retry after error.`); const oldTaskInstance: ReportingExecuteTaskInstance = { taskType: REPORTING_EXECUTE_TYPE, @@ -386,7 +417,7 @@ export class ExecuteReportTask implements ReportingTask { params: task, }; const newTask = await this.getTaskManagerStart().schedule(oldTaskInstance); - logger.debug(`Rescheduled ${task.id}`); + logger.debug(`Rescheduled task:${task.id}. New task: task:${newTask.id}`); return newTask; } diff --git a/x-pack/plugins/reporting/server/lib/tasks/index.ts b/x-pack/plugins/reporting/server/lib/tasks/index.ts index ec9e85e957d03d..c02b06d97adc7d 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/index.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/index.ts @@ -32,13 +32,6 @@ export interface ReportTaskParams { meta: ReportSource['meta']; } -export interface ReportingExecuteTaskInstance /* extends TaskInstanceWithDeprecatedFields */ { - state: object; - taskType: string; - params: ReportTaskParams; - runAt?: Date; -} - export enum ReportingTaskStatus { UNINITIALIZED = 'uninitialized', INITIALIZED = 'initialized', @@ -52,6 +45,5 @@ export interface ReportingTask { maxAttempts: number; timeout: string; }; - getStatus: () => ReportingTaskStatus; } diff --git a/x-pack/plugins/reporting/server/lib/tasks/monitor_reports.ts b/x-pack/plugins/reporting/server/lib/tasks/monitor_reports.ts index 36380f767e6d98..9e1bc49739c93b 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/monitor_reports.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/monitor_reports.ts @@ -11,21 +11,29 @@ import { ReportingCore } from '../../'; import { TaskManagerStartContract, TaskRunCreatorFunction } from '../../../../task_manager/server'; import { numberToDuration } from '../../../common/schema_utils'; import { ReportingConfigType } from '../../config'; +import { statuses } from '../statuses'; import { Report } from '../store'; -import { - ReportingExecuteTaskInstance, - ReportingTask, - ReportingTaskStatus, - REPORTING_EXECUTE_TYPE, - REPORTING_MONITOR_TYPE, - ReportTaskParams, -} from './'; +import { ReportingTask, ReportingTaskStatus, REPORTING_MONITOR_TYPE, ReportTaskParams } from './'; /* - * Task for finding the ReportingRecords left in the ReportingStore and stuck - * in pending or processing. It could happen if the server crashed while running - * a report and was cancelled. Normally a failure would mean scheduling a - * retry or failing the report, but the retry is not guaranteed to be scheduled. + * Task for finding the ReportingRecords left in the ReportingStore (.reporting index) and stuck in + * a pending or processing status. + * + * Stuck in pending: + * - This can happen if the report was scheduled in an earlier version of Kibana that used ESQueue. + * - Task Manager doesn't know about these types of reports because there was never a task + * scheduled for them. + * Stuck in processing: + * - This can could happen if the server crashed while a report was executing. + * - Task Manager doesn't know about these reports, because the task is completed in Task + * Manager when Reporting starts executing the report. We are not using Task Manager's retry + * mechanisms, which defer the retry for a few minutes. + * + * These events require us to reschedule the report with Task Manager, so that the jobs can be + * distributed and executed. + * + * The runner function reschedules a single report job per task run, to avoid flooding Task Manager + * in case many report jobs need to be recovered. */ export class MonitorReportsTask implements ReportingTask { public TYPE = REPORTING_MONITOR_TYPE; @@ -77,36 +85,41 @@ export class MonitorReportsTask implements ReportingTask { const reportingStore = await this.getStore(); try { - const results = await reportingStore.findZombieReportDocuments(); - if (results && results.length) { - this.logger.info( - `Found ${results.length} reports to reschedule: ${results - .map((pending) => pending._id) - .join(',')}` - ); - } else { - this.logger.debug(`Found 0 pending reports.`); + const recoveredJob = await reportingStore.findStaleReportJob(); + if (!recoveredJob) { + // no reports need to be rescheduled return; } - for (const pending of results) { - const { - _id: jobId, - _source: { process_expiration: processExpiration, status }, - } = pending; - const expirationTime = moment(processExpiration); // If it is the start of the Epoch, something went wrong - const timeWaitValue = moment().valueOf() - expirationTime.valueOf(); - const timeWaitTime = moment.duration(timeWaitValue); + const { + _id: jobId, + _source: { process_expiration: processExpiration, status }, + } = recoveredJob; + + if (![statuses.JOB_STATUS_PENDING, statuses.JOB_STATUS_PROCESSING].includes(status)) { + throw new Error(`Invalid job status in the monitoring search result: ${status}`); // only pending or processing jobs possibility need rescheduling + } + + if (status === statuses.JOB_STATUS_PENDING) { this.logger.info( - `Task ${jobId} has ${status} status for ${timeWaitTime.humanize()}. The queue timeout is ${this.timeout.humanize()}.` + `${jobId} was scheduled in a previous version and left in [${status}] status. Rescheduling...` ); + } - // clear process expiration and reschedule - const oldReport = new Report({ ...pending, ...pending._source }); - const reschedulingTask = oldReport.toReportTaskJSON(); - await reportingStore.clearExpiration(oldReport); - await this.rescheduleTask(reschedulingTask, this.logger); + if (status === statuses.JOB_STATUS_PROCESSING) { + const expirationTime = moment(processExpiration); + const overdueValue = moment().valueOf() - expirationTime.valueOf(); + this.logger.info( + `${jobId} status is [${status}] and the expiration time was [${overdueValue}ms] ago. Rescheduling...` + ); } + + // clear process expiration and set status to pending + const report = new Report({ ...recoveredJob, ...recoveredJob._source }); + await reportingStore.prepareReportForRetry(report); // if there is a version conflict response, this just throws and logs an error + + // clear process expiration and reschedule + await this.rescheduleTask(report.toReportTaskJSON(), this.logger); // a recovered report job must be scheduled by only a sinle Kibana instance } catch (err) { this.logger.error(err); } @@ -126,33 +139,19 @@ export class MonitorReportsTask implements ReportingTask { createTaskRunner: this.getTaskRunner(), maxAttempts: 1, // round the timeout value up to the nearest second, since Task Manager - // doesn't support milliseconds + // doesn't support milliseconds or > 1s timeout: Math.ceil(this.timeout.asSeconds()) + 's', }; } - // reschedule the task with TM and update the report document status to "Pending" + // reschedule the task with TM private async rescheduleTask(task: ReportTaskParams, logger: LevelLogger) { if (!this.taskManagerStart) { throw new Error('Reporting task runner has not been initialized!'); } - logger.info(`Rescheduling ${task.id} to retry after timeout expiration.`); - - const store = await this.getStore(); - - const oldTaskInstance: ReportingExecuteTaskInstance = { - taskType: REPORTING_EXECUTE_TYPE, // schedule a task to EXECUTE - state: {}, - params: task, - }; - - const [report, newTask] = await Promise.all([ - await store.findReportFromTask(task), - await this.taskManagerStart.schedule(oldTaskInstance), - ]); - - await store.setReportPending(report); + logger.info(`Rescheduling task:${task.id} to retry.`); + const newTask = await this.reporting.scheduleTask(task); return newTask; } diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/job_apis.ts b/x-pack/test/reporting_api_integration/reporting_without_security/job_apis.ts index 3b34e17cd3cb11..4c64176dacc8b3 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/job_apis.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security/job_apis.ts @@ -45,7 +45,6 @@ export default function ({ getService }: FtrProviderContext) { created_by: false, jobtype: 'csv', status: 'pending', - // TODO: remove the payload field from the api respones }; forOwn(expectedResJob, (value: any, key: string) => { expect(resJob[key]).to.eql(value, key); From 1813d70b3d8840b352047bc592ade740f84092ab Mon Sep 17 00:00:00 2001 From: Jorge Sanz Date: Wed, 23 Jun 2021 21:12:05 +0200 Subject: [PATCH 47/61] [Maps] Duplicated EMS instructions for Elastic Cloud (#103124) --- .../maps/server/tutorials/ems/index.ts | 85 ++++++++++--------- 1 file changed, 44 insertions(+), 41 deletions(-) diff --git a/x-pack/plugins/maps/server/tutorials/ems/index.ts b/x-pack/plugins/maps/server/tutorials/ems/index.ts index 410c833b8ac776..3c63850f872915 100644 --- a/x-pack/plugins/maps/server/tutorials/ems/index.ts +++ b/x-pack/plugins/maps/server/tutorials/ems/index.ts @@ -16,6 +16,48 @@ export function emsBoundariesSpecProvider({ emsLandingPageUrl: string; prependBasePath: (path: string) => string; }) { + const instructions = { + instructionSets: [ + { + instructionVariants: [ + { + id: 'EMS', + instructions: [ + { + title: i18n.translate('xpack.maps.tutorials.ems.downloadStepTitle', { + defaultMessage: 'Download Elastic Maps Service boundaries', + }), + textPre: i18n.translate('xpack.maps.tutorials.ems.downloadStepText', { + defaultMessage: + '1. Navigate to Elastic Maps Service [landing page]({emsLandingPageUrl}/).\n\ +2. In the left sidebar, select an administrative boundary.\n\ +3. Click `Download GeoJSON` button.', + values: { + emsLandingPageUrl, + }, + }), + }, + { + title: i18n.translate('xpack.maps.tutorials.ems.uploadStepTitle', { + defaultMessage: 'Index Elastic Maps Service boundaries', + }), + textPre: i18n.translate('xpack.maps.tutorials.ems.uploadStepText', { + defaultMessage: + '1. Open [Maps]({newMapUrl}).\n\ +2. Click `Add layer`, then select `Upload GeoJSON`.\n\ +3. Upload the GeoJSON file and click `Import file`.', + values: { + newMapUrl: prependBasePath(getNewMapPath()), + }, + }), + }, + ], + }, + ], + }, + ], + }; + return () => ({ id: 'emsBoundaries', name: i18n.translate('xpack.maps.tutorials.ems.nameTitle', { @@ -34,46 +76,7 @@ Indexing EMS administrative boundaries in Elasticsearch allows for search on bou euiIconType: 'emsApp', completionTimeMinutes: 1, previewImagePath: '/plugins/maps/assets/boundaries_screenshot.png', - onPrem: { - instructionSets: [ - { - instructionVariants: [ - { - id: 'EMS', - instructions: [ - { - title: i18n.translate('xpack.maps.tutorials.ems.downloadStepTitle', { - defaultMessage: 'Download Elastic Maps Service boundaries', - }), - textPre: i18n.translate('xpack.maps.tutorials.ems.downloadStepText', { - defaultMessage: - '1. Navigate to Elastic Maps Service [landing page]({emsLandingPageUrl}).\n\ -2. In the left sidebar, select an administrative boundary.\n\ -3. Click `Download GeoJSON` button.', - values: { - emsLandingPageUrl, - }, - }), - }, - { - title: i18n.translate('xpack.maps.tutorials.ems.uploadStepTitle', { - defaultMessage: 'Index Elastic Maps Service boundaries', - }), - textPre: i18n.translate('xpack.maps.tutorials.ems.uploadStepText', { - defaultMessage: - '1. Open [Maps]({newMapUrl}).\n\ -2. Click `Add layer`, then select `Upload GeoJSON`.\n\ -3. Upload the GeoJSON file and click `Import file`.', - values: { - newMapUrl: prependBasePath(getNewMapPath()), - }, - }), - }, - ], - }, - ], - }, - ], - }, + onPrem: instructions, + elasticCloud: instructions, }); } From 2dc1715a8ae75742e839838a3cac6dacd4cc2d4b Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 23 Jun 2021 13:14:43 -0600 Subject: [PATCH 48/61] [Security Solution] [Cases] Swimlane Connector for Cases (#100086) Co-authored-by: Josh Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Co-authored-by: Christos Nasikas Co-authored-by: Jonathan Buttner Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/management/action-types.asciidoc | 4 + .../connectors/action-types/swimlane.asciidoc | 105 ++++ .../connectors/images/swimlane-connector.png | Bin 0 -> 74730 bytes .../images/swimlane-params-test.png | Bin 0 -> 175258 bytes docs/management/connectors/index.asciidoc | 1 + x-pack/plugins/actions/README.md | 145 ++++-- .../server/builtin_action_types/index.test.ts | 1 + .../server/builtin_action_types/index.ts | 2 + .../server/builtin_action_types/jira/index.ts | 4 +- .../builtin_action_types/jira/schema.ts | 8 - .../builtin_action_types/jira/service.test.ts | 12 +- .../server/builtin_action_types/jira/types.ts | 10 +- .../builtin_action_types/resilient/schema.ts | 8 - .../builtin_action_types/servicenow/schema.ts | 8 - .../builtin_action_types/swimlane/api.test.ts | 142 ++++++ .../builtin_action_types/swimlane/api.ts | 60 +++ .../swimlane/helpers.test.ts | 90 ++++ .../builtin_action_types/swimlane/helpers.ts | 58 +++ .../builtin_action_types/swimlane/index.ts | 116 +++++ .../builtin_action_types/swimlane/mocks.ts | 124 +++++ .../builtin_action_types/swimlane/schema.ts | 75 +++ .../swimlane/service.test.ts | 434 ++++++++++++++++ .../builtin_action_types/swimlane/service.ts | 196 +++++++ .../swimlane/translations.ts | 20 + .../builtin_action_types/swimlane/types.ts | 123 +++++ .../swimlane/validators.ts | 28 + x-pack/plugins/actions/server/index.ts | 1 - x-pack/plugins/actions/server/types.ts | 2 +- .../server/usage/actions_usage_collector.ts | 1 + x-pack/plugins/cases/README.md | 2 +- .../cases/common/api/connectors/index.ts | 14 +- .../cases/common/api/connectors/mappings.ts | 7 +- .../cases/common/api/connectors/swimlane.ts | 21 + x-pack/plugins/cases/common/constants.ts | 16 +- .../cases/public/common/shared_imports.ts | 2 + .../components/case_view/index.test.tsx | 11 +- .../public/components/case_view/index.tsx | 7 +- .../components/configure_cases/index.tsx | 8 +- .../components/configure_cases/utils.ts | 13 +- .../components/connector_selector/form.tsx | 12 +- .../components/connectors/fields_form.tsx | 3 +- .../public/components/connectors/index.ts | 3 + .../components/connectors/jira/index.ts | 4 +- .../public/components/connectors/mock.ts | 18 + .../components/connectors/resilient/index.ts | 4 +- .../components/connectors/servicenow/index.ts | 10 +- .../connectors/swimlane/case_fields.test.tsx | 53 ++ .../connectors/swimlane/case_fields.tsx | 48 ++ .../components/connectors/swimlane/index.ts | 25 + .../connectors/swimlane/translations.ts | 42 ++ .../connectors/swimlane/validator.test.ts | 60 +++ .../connectors/swimlane/validator.ts | 39 ++ .../public/components/connectors/types.ts | 3 +- .../components/create/connector.test.tsx | 52 +- .../public/components/create/connector.tsx | 53 +- .../public/components/create/form.test.tsx | 6 + .../public/components/create/form_context.tsx | 33 +- .../cases/public/components/create/schema.tsx | 4 +- .../components/edit_connector/index.tsx | 10 +- .../plugins/cases/public/components/types.ts | 10 + .../plugins/cases/public/components/utils.ts | 43 ++ .../containers/use_get_action_license.tsx | 3 +- .../plugins/cases/server/client/cases/get.ts | 1 - .../cases/server/client/cases/utils.ts | 1 + .../server/connectors/case/index.test.ts | 14 +- .../cases/server/connectors/case/schema.ts | 26 +- .../server/connectors/case/validators.ts | 3 +- .../cases/server/connectors/factory.ts | 4 +- .../server/connectors/swimlane/format.test.ts | 21 + .../server/connectors/swimlane/format.ts | 15 + .../cases/server/connectors/swimlane/index.ts | 15 + .../server/connectors/swimlane/mapping.ts | 28 + .../cases/server/connectors/swimlane/types.ts | 13 + .../security_solution/common/constants.ts | 1 + .../schema/xpack_plugins.json | 6 + .../components/builtin_action_types/index.ts | 2 + .../jira/jira_connectors.test.tsx | 2 +- .../builtin_action_types/jira/jira_params.tsx | 6 + .../resilient/resilient_connectors.test.tsx | 2 +- .../resilient/resilient_params.tsx | 1 + .../servicenow/servicenow_connectors.test.tsx | 2 +- .../builtin_action_types/swimlane/api.test.ts | 145 ++++++ .../builtin_action_types/swimlane/api.ts | 65 +++ .../builtin_action_types/swimlane/helpers.ts | 62 +++ .../builtin_action_types/swimlane/index.ts | 8 + .../builtin_action_types/swimlane/logo.tsx | 53 ++ .../builtin_action_types/swimlane/mocks.ts | 61 +++ .../swimlane/steps/index.ts | 9 + .../swimlane/steps/swimlane_connection.tsx | 201 ++++++++ .../swimlane/steps/swimlane_fields.tsx | 313 ++++++++++++ .../swimlane/swimlane.test.tsx | 219 ++++++++ .../swimlane/swimlane.tsx | 106 ++++ .../swimlane/swimlane_connectors.test.tsx | 319 ++++++++++++ .../swimlane/swimlane_connectors.tsx | 103 ++++ .../swimlane/swimlane_params.test.tsx | 137 +++++ .../swimlane/swimlane_params.tsx | 159 ++++++ .../swimlane/translations.ts | 282 ++++++++++ .../builtin_action_types/swimlane/types.ts | 56 ++ .../swimlane/use_get_application.test.tsx | 180 +++++++ .../swimlane/use_get_application.tsx | 82 +++ .../actions/builtin_action_types/swimlane.ts | 91 ++++ .../basic/tests/actions/index.ts | 1 + .../alerting_api_integration/common/config.ts | 1 + .../actions_simulators/server/plugin.ts | 6 + .../server/swimlane_simulation.ts | 39 ++ .../actions/builtin_action_types/swimlane.ts | 482 ++++++++++++++++++ .../tests/actions/index.ts | 1 + .../case_api_integration/common/config.ts | 1 + .../common/config.ts | 1 + x-pack/test/functional_with_es_ssl/config.ts | 1 + 110 files changed, 5531 insertions(+), 233 deletions(-) create mode 100644 docs/management/connectors/action-types/swimlane.asciidoc create mode 100644 docs/management/connectors/images/swimlane-connector.png create mode 100644 docs/management/connectors/images/swimlane-params-test.png create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/api.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/api.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/helpers.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/helpers.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/index.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/mocks.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/schema.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/service.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/service.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/translations.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/types.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/swimlane/validators.ts create mode 100644 x-pack/plugins/cases/common/api/connectors/swimlane.ts create mode 100644 x-pack/plugins/cases/public/components/connectors/swimlane/case_fields.test.tsx create mode 100644 x-pack/plugins/cases/public/components/connectors/swimlane/case_fields.tsx create mode 100644 x-pack/plugins/cases/public/components/connectors/swimlane/index.ts create mode 100644 x-pack/plugins/cases/public/components/connectors/swimlane/translations.ts create mode 100644 x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts create mode 100644 x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts create mode 100644 x-pack/plugins/cases/public/components/types.ts create mode 100644 x-pack/plugins/cases/public/components/utils.ts create mode 100644 x-pack/plugins/cases/server/connectors/swimlane/format.test.ts create mode 100644 x-pack/plugins/cases/server/connectors/swimlane/format.ts create mode 100644 x-pack/plugins/cases/server/connectors/swimlane/index.ts create mode 100644 x-pack/plugins/cases/server/connectors/swimlane/mapping.ts create mode 100644 x-pack/plugins/cases/server/connectors/swimlane/types.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/helpers.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/index.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/logo.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/mocks.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/index.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/swimlane_connection.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/swimlane_fields.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_params.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_params.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/translations.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/types.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/use_get_application.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/use_get_application.tsx create mode 100644 x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/swimlane.ts create mode 100644 x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/swimlane.ts diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 65b600d4b7281f..3d3d7aeb2d777e 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -43,6 +43,10 @@ a| <> | Send a message to a Slack channel or user. +a| <> + +| Create an incident in Swimlane. + a| <> | Send a request to a web service. diff --git a/docs/management/connectors/action-types/swimlane.asciidoc b/docs/management/connectors/action-types/swimlane.asciidoc new file mode 100644 index 00000000000000..88447bb496a860 --- /dev/null +++ b/docs/management/connectors/action-types/swimlane.asciidoc @@ -0,0 +1,105 @@ +[role="xpack"] +[[swimlane-action-type]] +=== Swimlane connector and action +++++ +Swimlane +++++ + +The Swimlane connector uses the https://swimlane.com/knowledge-center/docs/developer-guide/rest-api/[Swimlane REST API] to create Swimlane records. + +[float] +[[swimlane-connector-configuration]] +==== Connector configuration + +Swimlane connectors have the following configuration properties. + +Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action. +URL:: Swimlane instance URL. +Application ID:: Swimlane application ID. +API token:: Swimlane API authentication token for HTTP Basic authentication. + +[float] +[[Preconfigured-swimlane-configuration]] +==== Preconfigured connector type + +[source,text] +-- + my-swimlane: + name: preconfigured-swimlane-connector-type + actionTypeId: .swimlane + config: + apiUrl: https://elastic.swimlaneurl.us + appId: app-id + mappings: + alertIdConfig: + fieldType: text + id: agp4s + key: alert-id + name: Alert ID + caseIdConfig: + fieldType: text + id: ae1mi + key: case-id + name: Case ID + caseNameConfig: + fieldType: text + id: anxnr + key: case-name + name: Case Name + commentsConfig: + fieldType: comments + id: au18d + key: comments + name: Comments + descriptionConfig: + fieldType: text + id: ae1gd + key: description + name: Description + ruleNameConfig: + fieldType: text + id: avfsl + key: rule-name + name: Rule Name + severityConfig: + fieldType: text + id: a71ik + key: severity + name: severity + secrets: + apiToken: tokenkeystorevalue +-- + +Config defines information for the connector type. + +`apiUrl`:: An address that corresponds to *URL*. +`appId`:: A key that corresponds to *Application ID*. + +Secrets defines sensitive information for the connector type. + +`apiToken`:: A string that corresponds to *API Token*. Should be stored in the <>. + +[float] +[[define-swimlane-ui]] +==== Define connector in Stack Management + +Define Swimlane connector properties. + +[role="screenshot"] +image::management/connectors/images/swimlane-connector.png[Swimlane connector] + +Test Swimlane action parameters. + +[role="screenshot"] +image::management/connectors/images/swimlane-params-test.png[Swimlane params test] + +[float] +[[swimlane-action-configuration]] +==== Action configuration + +Swimlane actions have the following configuration properties. + +Comments:: Additional information for the client, such as how to troubleshoot the issue. +Severity:: The severity of the incident. + +NOTE: Alert ID and Rule Name are filled automatically. Specifically, Alert ID is set to `{{alert.id}}` and Rule Name to `{{rule.name}}`. \ No newline at end of file diff --git a/docs/management/connectors/images/swimlane-connector.png b/docs/management/connectors/images/swimlane-connector.png new file mode 100644 index 0000000000000000000000000000000000000000..520c35d00381bd8d21a0accff1ba56cc1145ffc4 GIT binary patch literal 74730 zcmd432Q-{r)HXay1kuuns3C}G2|<)Wg6Kr=En4(W7&Vee)M(L3qD7R^+ZZJxI#Gui zErQV}+Kf^EJI`AlyzBqh`q%ot^?lYFZs$IA?^E`^_I2jnQ*{MO@|)x!5QtJqQBDg4 zB5DCX*GNf#6i+|(&ma)N6MI?Nr%JN2tWP~$ZS9?GK%hHuRu&dllz8uST3T9IboTLZ zlY97Ty?q;{Wf9!n(#_i2($&&ok(Od&GJAt&mPqFtC`Yrk){!ELb-#kYvp5;nKU$Ef z92 z%8vI=PAYkchz|Hk-ZaafoSbAGoSe*XP*C)W(%+!EMhL>}nKO3fxQPOS)w3~BvQ<+9 z-36XWK?LFUAY$N&0QlSlK7jn=-+(Rxf3E-^xsQbZ^_HmRBhi09U;CT!k+!Uo67W~s z%EQLS#q+tVSH)WcdZ4N?dmRHW12t7~D_3WJOKaC>HvBJ~-ToELpghAKs-PjQlbwZO8%4of4up(#Zx5>{w*mca_@A>Q*X|f z)b+ISkacwin)H(TS2O=v`1H&FEGQ}PcjTue@gHLTCl!#h6uG3pf9p(&{D?HF2?UY> zDak$3c|ou?bs(I;m`A;VhXi}R;ar%|Q;vzz!Z{A}s>v&0!8 zq7Lmu>}+f%0vBWa;{#92@vm5~G=yBG*(RKQb$q)}`iwN+cLIe)YWix5WMe2WH1W!S)_OFOYi^yI z0l>%=Kl(-1R9GJUcn0Q6YC_MV=Tu{?;;`2H6A899(n-Y3A#>eDqwxL6ja7lLFzx;}|^JSPSh zn9Rs5qP}&V{N)jLZQ^Pik3N5aiPV|KfK^n-L)d3rRgJ+%Z|ExAWc9`W2^MsipH2Uo zJ+mzS3ZZ#l$yfW zw&uGMXjYlg-c)2{appnvtTQJ+_0_W^{rZl!=lj`bf>6fY$pXH8^(gm`kK4aixXwr! z>-1-c*(}FjsLdI7?B4rw(6KeFAq|VcOqR>XbE>T%?btU-O&YwEs92=t9Nd0H(cgaf zaJ+he`-qu_S651hvy+xBM!Dy-lwa-NiW z6zLZ&B5jF6B{<3g@XCcc^=YfFi%}E4D|(5M|1R_vCOQjJFN$~Jlnl45I-<8bOWmztl}uzoE&xE%#Kb{tQ3nTF>k-hMPC}1>idos%erFi-W%g9TBAokPU7& zWZ2?u$`79Nof~W2pXDPLwiY^>_bBLxP>20p-m4;=%RG^$92!}w-5=okBCgYH_F4+x z4i7LShzu)uGJi}Sx8nz1t&bK#gMQuHivsV53)EGT?k0gZAp1M?hGlM_GK3L2MP|Ac zHhn9TB0sV(QZpy^rPp*G6J5ORn>DGG?`5x)baz1*0X08?W{7)tGyAUOoAo3MBrw@h zc^@C*bG7m!5J%-Q=-W`^W;69nNsftV^VeHAr3&k=(t1QR)sp8#nPtw}L?zZ1Ut?$pYDlcQkw1 zV>gy?k5KEm5DXvRqeqVfeK!cjUi=A>+_R8XO6Je>Ogp{eT+(G4$wH(v0}b_xqbW#m zneXlqxE&gK*{{}=Ffa9`3A?Xfyl|m2oW7)A`K~ z7MKgoipj^wAoxiTUFt*oc+bw=Lg%GzH35B#Qm*N>KQ%@DMzvpUAC>T%N&$;|AX~bb zg8r~Tj*?fnEO=p5RP|$Hn>Y2;Px0pC{i30!qgCdP9F%5945mpyJ6{#fsWQx|Hoppm zD0^t}Z4GI(W^l=l@63_K39=fZ>L0XO=p26haZ?ux?c2ndu6u}VaBImmc(2|S zcGUBw>Ke-HS?DUra$l#u@h7rzzk9V5Blqd?>)dDUTcz7hr>qD!81aTbC}1UCG5m7! z!btfZn6AER`IkxUhJ)+4&_JUF;gIM;lZVZEWv7+@qs-Z{qhE=)Y*z^Mx;&uH#509n!MeQu1|+^t#?&W-skJZjP0{j~idPeCWW*)G)Pt z)Gq>ZnrZ-6K|RYu2)6iv>)?J`$VgX?^Yig9X=_iI!Rq@O`yqQ zU*E&)Kv{MDgKZifd^B9(Ittei^HKvQWiVW0bK7S9aO;en9={of^W`#V+ z^y2C5dzOlHa#X&Ib}jNf9m%VXgIWEl)zOmQt@2Cjx5O%=3 zbkycOdre*{iA-2AQ$UbaehS@amAxn*-20RH=a=PJ>5KAUYaFIZ!->z#56|JZ^eJ3& z1^LBNc015jz*|kAE`R3a#{A{OmBtrfDEw$c3G%{Q?p1BBUfPb)E;)I_YIBcY5ogE0 zMnZs0%ZW?%uUg&Yh1IxZ4&Hf}QC1&ZiFQFvz>1x9bCulCJgQ}0)BNHc%t@s} zt7aI&rblmlTP4|_q~8B2n%L2(=7C_Wi{l^td{z6YDEFI(h-{zEow@dHX#Eq2okG+N zhm8e4b@a>WI}80uJtUuEYaO`;qzG%A=V3qPBNzRNG0kb8ItKHYBz*lTM)~z? zidEAdj!~Fz+`UBGizR*85zXWpIpIR+j{7wwJ`ynhHrNRkPJLp$0mqpk)))KJxr}RF zeAi*FRnMnQ#){4C^@y|Y&IHeN93^Jbt+}^2qt)?~QSB-MU;z!O!Tu5-w!SoxxEM?$ zhxh6ek{0v8-RchfyQ@FOw`T$msvCFaB1=8@b6S1|H=cCH-?w@~FX59ISd#3&9N>H9 z)M@Ygn6)2f;~E!l zrr3sZcUfzhb;jW3cDl7^Dh*KF$;N@}8u@esrV-^c+?<6gHE+%$CMJGPdtf+lW9gnJ zSBV+ii2VB9*|i3#?0ca({gjB2y-yd_{!Dl628wOYkm*a|q9v9+g=jatw`&6DzLUkG z%}R`P2Qe85H^JjF`zL!jm5xC)23%XYj)>~b9}Lj?{3~@-6``k1Aey(spchkdq|0D1 z+an6Un0_N7#iP}UZBHR;uWHro``T~P^>2%{J=_&%N<#srqE>~qI|H$<$fG5S1dU_#?2Ylc+9jb=% zFrxc2QNQaO)Ek^9m9oFAN4$G&Jlg2;Wc?_{LAxOl-T4|kXt2M z2$HlnenpXy<@QDrChIt`v7+l}60V%mq6cXJtXsPiaX2m}V7vrYN8OPr?D&B)WS7nK zdpB7~GOy`faq3Ye9HNx_)~#EgBk$X6W75uO8B5=78-Fh{z z+2=ZX^2WX#(LZ`C^wk}xz$TXw4EblTJeG$z^g)L`0?lFtYoBE_%_--_{pdtU4n=-?>r*Q4?mp_52r#Y*_%57*Oj_m? z-CFEo--hzO&6AH}5FN65?yMTwXEhC5%c|-ygDhm`dTQDAra1cwKOFWe+E=&2Rk;na z6`I06tiIoX6+C`T-hlbhh3(q|HkRm{IiBC9hF{{dJe!l#K5cFky2X9lfH|G=^Jvh7 zS*8ztm$3Z@fmT`p8v#LDY}(cXQx#V`7Vxiya);CMJUDqDa3;^hNnRj-L6x~aj5ZeP z#Zw3A!ktA-+v~S4n%W%hZDL(JVwkWm^6toHbfmSHSFNY*Xyc}| z$+r6!>eNMl18W<}9~?Yjw&RSdtxroA^W=5#ceDbkPBU9Y-*skD3fCt>eDE zhORRe85ZpeBPL6TZhnC^H!KZEpTtA`cY)OhOVwX^04o`F7%NWgfJ@p!cCYql{BQB8sMuPcsm{kJ-NwC977wpxbTWJ`mil*x+KB1kh2qJC zri(_|y3uLWFN*U^+K$sj?ANlAoKHF6b0)K;O&7=Qoh`1!p>N_<4!VzP3UE2QMb^_z zoy=lf1NirmLyl8|^u9|Dpt+35$N`)LoM)Zcks-%85LHln?H;DVTVwj=#KF|`Wk&Cm zDl_8}2VET7#y(coR@jPbxp4{FeEcNn!1-h5!D>l3s*3@oVG72hvk&|lHxz>{_8k9M zAXWy9@|(t%>d{&CE}Pi_$Tq9~YV0$pLH*G0n|x^b%Ewg+JKp*m+f~&iLbkm-DZh&` zMS+;9n0^lTeZNX6QL8v{@4K-$7Dw6Lj_}v|W)q|NYO9XZL?r{=%bJM8eW`gf^T5kR zqrJQ0rw`X!_CWCu%5mXG7fmyQ4(4=!@@TltZ2gewoUQT&8OE zb^`-)Gv%u#wR%OvWsitbMV#ZS97gBTkjp0KxZFsEWuEv9ui%oK{rN{DxITwJzp@A? zX_?3=8pQ)88h-mbKec!dxy+0rIF5rZ*K11I5#+f{iT42B2X@bX_Q0jXR~0&w!)yaV zZ8mR--$I*&_+5{Kc$kUic6jBj180Gv=P(1Ku)WOaCs_!+_>MrVZfVZq@X>=pZ4_?L z0X{kjUOqN$#Pp>&5=C=L|Cnhp>sVWe3mk(5pMZNRn7gSa!JVp2VS!q+zj`o*Bx z8-89VJ_nOgug9!r0rO}Y#o4f|4D(s9Kq319pX{$IlbTt%(QP3zQmgd)hoI{Quh3zA z;;N2LddtV685@00k$!9wjXv&i0fd|kkpWF^y9G5xej z(^GY`hrflP2d!?~{m3=?%1%0*o`*DJ0ZP2X{p6VFGp4c6#SEN_8CB!ue(=BqyEe_I z6wet~XOO#kh~NIP|8WTxQEA=fRFXrYdA}TiNA6EQ%j`*Sr;tEYxFqg~u}Hp9jA4;> zL4B2N{cYSp54Z|Hb+f2b&>x`#5gP}ko<-D5;?Mj8hf_ixDg@+wmiiY)dVTk`sXQhob-kIx2V1((Kv*_v*a~n3FU8(1yqJ+ z8{>K93CJjvxvmyq61alKE#63nE8)H9=U2&vZ|hoUk%{56Gf*(&$G!J{SJiJRV*M#e zgIvF5s+`i?Uwo^#LBjntjlWAhBeJ2Jq)90O9Ty@qa3Kr3F3G16;5^OVfXN^W3Y&*@ zSKpR(n`e_1>()=)S>XAut6NWf%3uYA5JFANUzEHtlgj-!zDkvSaB+kNDJ9{r_BCUhSnI_w%=;IOJ05p_bqZG@E4BT}83HZ4C1 z-Z>;}#ZZoRo`pAAX|9GoBnzolz@hac-jUP|=7qC#^T3g`iB(-hdFJCBhBWS){3>!@ z6JqDk^$?k2;yG*5sxd_(V@kIJJB?c>Q8No)t)EuNEaSEAORV9&}tISnN)@cgNe@hu7YDQHno3*^_ox69+s`?950(i4K zGstnlTXCn3-v*21mt93ZYudsbvUUmv_&ZV){n=9D7DUM)r8?(*R~LV& zdj=l+_$gMdg8zZe-hKa(aJKi#9j2G@<|Sa`bJLUb`%_kRz0ep-)6aeRvROs@9FcUb znBWeL?w9gCHap|o6)(E()(50sfPD^hyD#ZJH_x3a;i^>if&GbEyrCy;d)WibL=2O} zEg@UJ_nTMkI!mVx8to!l2UU{!l}@rRcGaxIp1nUn@_W;ct~_L3YeagsRNwPkF1=vDcVc5|Af&=jb|e%~+_=A`t;HIgC85`Rbdjp3 z?APywJUS$RwKa+2{7^Nfg0r|!TIJv1@el0d9U1NV%vcR8Z9g11>NPEzs8XqBN-4ZK z1plyA`@owTbwlitR=(=3DOeNVvQTcc(&bXg0`<%|4i!D+G2YZ%i_pf>-}k3dnk`a! z4I7lZllbU|RXWc^LTZ&*&8S1EK7j05QcScu!thNh$t5+duGL?!=bEAx<8LcnnQ$Pi zW!a}poDK~7;0nRfP)1LBln616j4LuFG2OrqZm> z1)OjvyB{9OKIvkBf&+6@ zZ;!$hkibz@S9=X!P}caz#26NwBWQYEm_gJSdUQZNA>$4uyE&Y%#!k618tmZbvPUHeJ965O&OPEbD=q^|+pIWE)Y-%4JLB4^+)G_c1HTLsEry3>3R*AS5cA)u zyT)UrxV6x?aCZRpUTDJYVdGJP`Z~%mpTNw-(MjBp z%>P<3*#(?uvwcxbz?**AX-j&rG%1nSJ=S})fk`~T;CQNTv`Vd74mSac3``Sn;q{)xyY+Ja44)k_3OEG&_ z0#~IUze(Ml;A7#ThpzW&FaP-RY2>SBSJrQq(^~y=txm}FLl*AsXjVV21f4BucA@#E zb%s>nlba(Y-fG4X*ZKvBg78R^W`C`49*uk3sT$cG3J7)!vV*xkSz6}zieF(4GJHeDfYuv! z6s_J+h+*En<2g5P=+o)-BWige_EDR4+Z#TF+4GqBp7dcXGqvCkV3K++aQkcAlAtgo zn*8|jwqWotEreKRYybf9Ue$TBXx?mD$tZ!%o)@;UA6gf(>z{BgL5*)}&(9YQYYh_@ zNT{M_F#D;hnIec9G2O3;TiXJ`sfTvXLAc-;Mlnv7U}VSSs_lK9-X#xFtVE?V1ths1tJ7Ws|N=xC*vO{Y-i6Z zkmju@xTQ;NmciWIJ(r*;9|!`xn3bg~u zaccL3dUkfCo`s<+@>CSxU6EhI>u_r$#mdD4MAK6b{9Tvl1g(BZ7HZXJriykB_O)We<(OQN})Xo!%thaFD#`~=~`)S*4Nz>A%9=Gba@@j`dfhbN02?-=#7>|Sr>Ia3$$Kll0bQ?vg0 zn=}9eHqt9avgRq%3TLmbHT56tirAT%&IIFxA;!T%`li9XZvMxPh_7txGb^?)k(8V= zrAubQN}1wGl4CV_I)d)pP&%K*!IcBAZyfwjB}|jjrrLBT)pk=$vv!6 zZ(D-3sHqAU?(_tr#APUoT|R67hAFg)V+cz+$bd+l$zd(`@rCVoLL(Dmi^qkR5_gYR z*d=!S-_ciqgO9Hm*14rho=jTRD8LT`ZLFh~b*C!4!!r>jt*#|$y1g`uuk%y%DtxA% z6k5V3>@UK&-&-5zoM8fCn&Gpf8NNd1BAYGy8cBW*-Bd@13yg*f~5Z`cT`$9?Q< zbN>>HJ<^hpifIeLF%+JL6Qb{LD|;^X+G^nIZAhu9Yax%62*F`e!)?j!I~xxBb45)R ze%n4>HvXGlHkRvs!}XquZG_b8^ay{`Ri0{+yHRsm7Af2mD5_gTf#cBxV* zc(f~FO&L>A8Jsf^0vfp9Iu*9OYFZRpaa=77FUS-RcA*t!a3!)>CmZuAegKAwV``$7 zqXm3jmFU5?74#(V(VBvnWbrvF{jp4gnXwyti+mDmhwi4S4KFsoVYBG^%Fti9?=3k% zs>3IgE-zA=AboIHJ3-lRy8;izFg-pQr_HvO%d8-u+ojuuZQ7C7xGfg_)su~E6&*dU zmOWIO;gRqdkz4+G?57V}%NW+^OYh^2Pn2>t^4rAwlxmC9>Cr`tyS}M%_=C!I2is3q zg@UCWv7b8wG-f1NKvl4dc^=I9J;Cv8}3N zz8w6Vu3i;UdP5P{mIrmn?S?hZ_m|J6`dBUbp)%05=CzD^I4th-{0Q9f2bA+_=9>Ox! zMo;({3p1CWd0kZatR1o*Fp^qHm!|tZw)@MV_WaLJZMH${M}yOF_4O5>n3ATKicrm- zK9@*sAmpT$^^+N5`tz%Awz*me>`4_umo9CnWz($IujcyQM4;Cs9rBU1RlYpBTgd-Stlh{#DQS{|eEAm1j8#82K= z)niz*VC*Qhk7cn+@-q>WnUaShr>yipegys@R(o%~Uw9(=UAr;CRFy?k63M#vp;8Vw zgUD49MKv`{X(?kd&)!OQKiMdbY&BvaE$cp1Z@-Ut_~tc7kY1@1H;Q4I*tB6!i=*;N zRlkyHvt>d6-jA+cGgbYl6z7*CScRzUYg2QUDaDQX>qGShM}MPwt5m`zPi)MFb1x~{ zPo+!h%*+?ktUF_P^t%`7rQOp^$5q0aSLcip3VN?d2jdV^`#u>#%e(erOftQ#+MfasZ2JFuAP(i)REJL-TsoZLnA5%>KpcnPq2ia7?U?;Q4 zoJ&vaS(o{iziHo;T<-p%-e8uhp2ZM2{Uxfb%nf!gq1NAVLbPETbyUqKu0Os!h5dl` zVHws1_C*O_)8DATft198OV-QVhzpf2BFn4sUIq|1^2Oun;#;SfJ$p?cf-;P>mC@UO zfyTd27Q~Id!jDg(kN><5i6x@}9gGv@iJkU@0Jm=X0%-^w=63D$kZELEI*3U_0@p%^ z7*CssfRBy=2r<*94;!FLg!{p-%zdH_b;?OF3X!L%$6RBDgTRtA&;s6_ww3<1!JFI^1VUyu1;2YN3B zNR9cgEIA0_v)2F36uk|!Q>rk*J^1Rk{0rm1+x_DmeYCtj~ z>(2Ult=|RIoAf&DoYtHEYTZ#I5qn*jy8Ud^QpIj?ncKeD{f{t^Y!3Ncb$-7n0_6L@K)i!0qh{X zH3vq8dW~!;zUcI-_XN$9Q8^MG3}Xv2iFgTp{=oq0lfxpX@jp=Oz7M+MUe5(mg={L|-*4VQd@)l4sDFHs zw0!a1NjyD51CrdKp`k-CUzU2eUoR(p&Bf@29ipte(hR3x9C<5cOJ%|b#+*^}u`?%$ zUKx5mWkPVV$}vUyO#?y9IFz!jYD;>vy>fZytdVc@P|J6#C{X(pG4N>Jf7 z%wZR);{fK>yX@@j#>1^ht*<&to@UnHUMUIMZ&?F2W+4TWh^pC(V=-eEZ~?pvQPjLR zD591ru7=%^V9irWibrQbi~t7ohJ$foJcrHv(tWK)15Jpzw^6-E(qHbF{pi=cy@LbT zifC{vp;G+qca2yikB3Gan~~&MbXswT(ESR?b+S$g=rK<$82cqxEq(0uG%noLW~S*V zp3~cb44_Dc-xN(=N7bsdlV7)vAR|$TVdSvth>1VMZ%xSfA*<};1#RwK7cjLC1(*j$sh@uNo3_5hFKi3=cb87V zauNDqQ|BIlN=nQ{{Q=NP=lkYxTu-`lT7zi=ouwzH!aRH`#QHbVSfp zT}0F)$-mm(jC+=Lmz^RW07unrt*%tC2o^O86^GU z1bmhEm|dGwqwLZ4tIeyXh+K{2M<4hN??lq}SHdd+2*e|EIJ!`qt82$TAva=~DND$% zbp1N>J&TWh5;j|M%QF}VelK{+X8Q1g$KD@H1Xu!&7@YR}{wFxsCG(>7>R1Vq zIK-}M2>OrQ!<)x$d|pr&B4~o7i8xn~1$CfgyW%;L4J-4zK1v7IIvZD5r#cUOd=!?a zmL9G~a~N)qB4*f$We{zJ->5mW8;8pA{Eg6)c%_Y%n4<}pn3-K5o&x4gya3u%hW@Hs z^emh%`Htq~4mSV2m?yfvv{0w;Ge8(gY2kl;jn7C&J>%go@#OnfBwYy-weT9%R4wct zFp+GA^ZDWy^q?Gm+t(|$V|H8OIaEw>#b)rY@KWopE4G%U=D9xW2YpTO-rWI!;JaS4 zIBJP#ZEe-6*Wxg&ajK`R%8U|r94oUCJJ`q`9F@Lv=c#RqMN=;vr_`l|>n^wISOEB$ zr7G3V;@3=q?>q&-A956AUa;+lu;~NvFA3ibAh0G|vd*hhmy!X7^b>b`uDKtdJ#enT zWHN|bZuUA1O)F*=t>)HFZ#)mHUg*(R{Ppeinq_7|(K0|g770;7i&!JG-Y!VDt6Zzp zSIv50M$4PRzP;EVe_xCW;kmOY&GSfj~Cjs6@U1%wt0OapmEBoqHV`uC1A|MrboHn!$z$PGKLI3#wU9K zJlxa=_7PLABVStizzIhA3VL)=D<{7-hAQMe=_Th~jj;d`EH5IJr>IaLI_)F1*=tzY zSKlQVJnd8o(iMcLL!LU0e&t418Utiq{8X`RCP1VY^Epg$nyk}g_N%Kl4e7RR>I$xL zj;5#s;)Ks2PTo?L*qtAoHr*+MW3F@Gm9X0Ad(DGQVUh=H!$Ucc+VN}hW@pZX7Oo3( zxfcx<#J&CXy-G`(j=#7zvpD}b_|sOh_TGwVthfzQojf+<)QngrN)?9GG}`Eex=d8O zc2q3SJ{fiI5~r9@@`po)uBT;sog7um(V>RKs$-=$tC+4#q0^xp`u!Q$EOCT^zUs!F z3zw%$WhQ%ufVmlra2o|gZ;g8cEGTaaQyw1a7Xmb>0IZ(|e!0LrZ&Q{!ZS)gt+;an0 zb#e08yB3h)52d!fl$5`e8NwyDOvDor%uw z*D@{NpW4Pa$;Gk7wZ;A>ltUi@r1fs%?85Z`Gp>hT8%(scwu{)TM1c6wV5Xake&9AX zKkqx^J5!U|EA8LgRn+0tzT}DSPxTn~V?=oRYm_AP>-sc58CW(k0WVT`j(K>$@BwII zYuS+FFSwD_%xl##nz&KQ5hHb54jX0bEg zsrg+U20+airv@pgPfi2X)CA@FpcifXmJiqZ#HjwUoSG()7%mX!jBfbS@gT(er2$v$ zb|G%$b$%lUrPox#hgIg>Ct!3FrirOq>Td`1+hfu`FGhAX)p~sX&?9xm>Uuv;Jm*B} z^(-t8e$LL=R0#n+3<6U-oU&KtMRVw=4OD@=OCEElgK{iw?47 z*Un%lq^vZ}Ks~<^+aE>FHSDX%+HW4_wO{4Og2B^}QIaKeiXKdV`k={szT&HYggCgA zZr*O~3cKySlIy!BRa-gx@KT7ZY}>;4=Ju5vOLp$@#CbH^%BH9J4?ue|gxB8Q1E<~k zWa%;O<;E<|csEs$SVhL-klC8iu6lS@)Ubk8DOtoIo|EkMo?3{x+t;Ww&ecOg& z%;?Q{l5Wkb+wHh(z|Ktt8Oxf{;ZFGOOTBU?iUtf;g&=yDkWlUcHgbSlozNn4n}JdF z4?}h{=?LW+v$pO!K{Woqa+&_QtMoKOO*Cd;`dqd@N~g1}r(S^0Z6iV&&N?`?oFDI- z2Lumm09$MAkIvC^aweT@01^BtVbCHxD_0pp8di`xi2B{#a~l3Xm!C9hz*{o-*JoaX zGDRSm!6qL$&7P-xJ-H<$y=aRinCe3my^wOwo84zq^9fuU|4_}3k;1p{+`BV|nCZUY za4&fJ<@3nS58$FiUQJ2lcJ0A;ER)3iOt5HIy(e9-5H~Mi88%MUev~}3C(g!@PMSRM zQ}{Y(xb!1BeW4mL6AxUkP+IIwRig>eiRaMZpV>Kn+?@nN2zgRq-%vAfTp(X;E(Et!W6;?Z5`FQt=Z26Ko@%R=;cB)BxDko_5xt?*JZj3xak>jC2CT7Cy5>Q@*uOd10Bj!X2|`%3{P%4eNIlQ_`B{_pjtZ$xd% zvQCqH-76=b<^rS_KDr~&kI5%4Fl^%RowN|2bzljxgV0H&URX^LKB0{a}+HBKCx z+K)AsS2;}dnr#o1#o3bXK3?AL)8A^RgR8wyRsrbpU!oYr^Z-_z0PY(1otzH!_f`38 zbmjr--@ZLw034;BY4Aa=?yBujzM3OcHfzB2p#Tt?$tgCg)cf{WHqT*Hd235$Kn3u9 z7AoE)X8W(m`yFm7*ev#n&2-sS->jd{veO^XB_R&AqEf zyM6~eq4W@5jP_siUs%4k0$g(InH1Yzn9HLvp0Udg{6@v)da@`{VcGWN00+e%eu3C= z8?ZKeIvy0lF=5At#%6)L7wiw8%0hVW$>iEbI}9E7=|{PmZ~y+jU2|+VkeS31v}dSS zYQ*>H)-A09b!WegdM*y7r&l0T1=)cdfQ6R`gtNOpx2xQC9OK>U71HR0+bE;9*%eal zDsAEPMy8K{q}g4irR8)+N%8J7j3j|hI$7@dV5Zpv502Qd*9>Js=peaoWx|muT^a+E!4!qkaEJ8ijpw}^u}hQ64pnV=1liq_W5_Gj zbSaf@-`duuJQjMnqi>FihCU4eqM{14d?su+>z*4mzDRXKRvB`tWjNq=v&nvQsUWMC z*NRSAD2MSi|c}AI1+i zPF}rw)osi)Ez~+MS!nl$MfCYojW^UR?9wNKElKM$lqbr*LcJpPL$^6uUD>vK8O^4A zht|YA^JK$)IV2@bnC*wPYB1y6d`4AC@B^%v;j>-8oGt&|74{=Fw}Ma$xPP_H7qY{k z!}}FYeQHM)wtc+qY9PD;OayS@-1^|6sVwXdsvEc3UP9hdKM>U^@CGh9KS}W z%}N`m3Hv=|79^OTZ9P#$l(NA1C+v~?Y8Z@Ky07AJKJ`12JORm*v?UrxHH8>Csq|La zH)+2{g*l9|ynthTV!iuS7<>4uO(A#c_dpkL*|NmhWBA~3M_ZsXk$ZR@|EDK;K}>r( zioP<}aQMe!NBGv(0VY@pYBr-3p~LOOAZ!(#Ho8vD*bun%(-PoYq8C!|W|DYo3CB+} zuPxgYmhIDbQn5?Nsky4D-HYxq+wV@Fhhz$@(^wF0X0(Wes?cIA=*{alsb+n_z{McuNUVLp_AXt`#fqrq zz!BD!*K}Fs;P3ve9>b72i(k(Q4J*?+b_qg{ML^wgLa_O+q@Jwdk4{bWPY#hq0`*%C zG*jzs)T$aKDQ4ZOB6VYylZDy^@tEe5!T!vn!^({sF5IuNlKD_^>h8CfbJPT|j-`Un z8r*ZXL{jl1M%f)msb`Um&;UcFtOu!6rMi`G0FQZ+D%9nQcU+obz2L=%<8a?kC3CStM{BZ^hH z1itaiAKDR{e!Ik2xYlncJHPLyN^(D@0l6yPdipvg>21=#XGMW<+tcQa-TF1@7f%=a zOs!wWk!=mBxTcuF^tQ05_4PldlW^DUN;T73%GOW3fu<~g3+5oTt1D;dW9a9sl^RspFf23JJgkNhc<+b{8()60KGj ze)EQ*U8|SJDW6)Q`5CqbbtKvmg6?tiM>g;`1eS_$#;45G&ceM<2KKVLp6?2IY-e5xNmd zej1X~dj7Z#d%`29AMRgLXOQY|(x(?wIM>(5svw9gNB%*5IRGh>RZksl5MDNM#<7aw zlg!LB71nZZE3u)8Bn~vC6H-3?-sqabc+3YG7v^lJ%lJJ;cH3gt=al-(3@G35 z=rn2^KZw-HeCR$z!J*El`}GNSBM4_I&23y7l}oB_>A!VXIk-}0=yfDr-#1l(NuNF4 ze6&8q<7DqQ9W0`3Y&F=neIu{u4JyFm>TeU&T^b<5Y5VcLeHH<7m*3QJ*-Y?gXB%IWcI(lq7kjCS=s4hwn zaDo1z#rviAZl)Q^!)ev4YX#W4nHZgVWqEo#+K!d+O*MV+P7t>i3F>35R(nJZ)k-0c zu_Myf%&OXcm{wv2RT#!!x^VmGll9Pd?*7sv695}J>P-^zNP%@I`TW<+`8$1BACZ6W zVV*Uq_v~^X-o|!ufoHV4#*R6gQ46u5s6w&kHsp7w)`0;m&@@^s? zN=1v5ar^tm5~47tiB8?DoDWQ>KqjdY0%(xvv2c-$VxM7m=E0Onw6=o+ zxU2xQ|C#&ytd@VpX~FdPV0d6A9p9Lp@;cZ5hM_b}F^&WVoDnfaXpJt%H2I7Ve5SXO z`ikB>uu;r5Bd6uRL$H|HoGN5j(c!WaD^xX#A2~ce4qC(*WqrGFDeZa~Dh;uAcqj1S z*czi*YsO?~Puu6AffQQ1jdKR)LwCgjmfr3NA#fO6Wv<{QE3m`#>S|+CS3Q4&hIDf$ zzOox zx9g=UEI8hG+HZz2`rfWOpKJ4WKKVDz?v?WK4OsH!U1p^A*KPC6PfduPBJ^i7Xh(l$v&ABRc6_gGotrAU z*q=wCXUGe_wWF(r^!zGKvvd$K#kXU`r;ipbXsVb^VBY@lAiE!@J6B~tY@!`wmgDKZ zoAh`;;xj-v^Pk_;JNeR4I)%^zXP0B0RvYJ3Qw5QuAK}#ss3K1YJkWLI_h-LdEoC96 zX(jbkdLiov;ASONc&5;F_RMId(`j!zgnUifO?hix-BWAKb z6go#YbMb8lnoD8o^!^PBh28n+8RR`)i9K}qQv|>;8t$|p;2Q=MYl;i!ca~MK0uSah zZV3EOSnB7oYUXxPTb7Vb)C{hQVC>MpK*<1-_6myM`8P$U%LT#CaDVuUH3DJ&5!Dv`;3+pZBk#NPA2gDkfd&!{#;fc*Q!|HIyU1~t{SZNq|qAW{?srK1!P z0qHe>q9RC9dJCXZr1zT8L_tMGdM{F?_f80i^b$IR5>QHj5P}2}dcN&--S0EE{{Lp) zcjoyc&cLj__gd##>nz7{oQ!KvbGoJ%jn4i6s=Vrv%!5v@OCvem%LgnLF%!i`0A0FyLf>5$(eL3TEg|6MWdQm*L1xDH^AWCG zh>le{fk!obMlt0s#5`5fo(CxH=H9k!y_exnpe)qQ3qRggRUS*dX4`)iD3TA3Rilk| z5Xv2z6(<$rg|koclQ^Fef$N6fAG=4;g*|lk-)_&JN}EEBWCk5R;PlTe9mMbSm-oU+ zr;d@s8RKsvnUUDL!E_0>gJzBITP;rNo#q`dlTCX$grJ#LuHa*&cWLz(?B;xP)L^kU zZmzL-ZQEVIetuV8SZ$W1-A!En{*H-IQ+II06;v@RWLQ>X<0R>9AS2+tei-eAJ5_Ez zBFy6Y1Tvu_K%Dw6y4)g{UPt*PE$|^QcHsw3&}2e+M#`?qhpS>5Gw_3i+OYtjl8F2Z zs;{!>Lj9ZF1V{w+0RE8kk!igP4^UWDIbQp~k~MvNn@jfWEfR z*Q`q{Pgw@V(V`LkJ6D99P%Ar zvD7UMFqm~@+fxcSEfnCAX}7Yr+MjWB?P>VMWIpA(&pq&o^+nt znin4$cOC$=H)N|Pd(|Dj*f`C!^vdzhBs_nBt2~NKyj__&B+(RIK3VPpdnm#}H>sDW zVV~K#K33pta=6sN5O_4J2uLj@Qqr=_a9wCUBCyRK;fkpL2y;UIq2e-Z4{<1f2MY~L zQyk4?ek-G-$hdB#s2+8)SR%6u&5>345q_1&V%sv!%Kkf-M>x%hyUR>b{jUx@bBE5> zI%fc0Z$oBXs1>Pop&uxB-$%1LR>%SJ9jV=oI>m;<)<<<((oq>)R;znkbB+B35yy(+ z?$*tN#ztbmR3MP%dKP|>#1Ek3D{WxNQ*UI8eVAIKSYdFrt(JAy2f>}qY8}WpDcV2TJN227p|Pz>NCIp|`Ued~|n1-aA#%zGV}TZ2Dbsq*FOc$->7QS;V-^E7jOI zfC#u*K4OC4Qc+IE)#E9yQFySmXl3`{yiYcsLvi2LOnI5%h@EdX-!H4G8Z}yr`l;vbe3JY8+R>x`b_C*GD!m+DjF}$tL69 z!|6DaTe)^Tg;nsGX&Y!nm{wok^F+Xw-P5UNA{e+TyMGg=oM#lBAYk}OT|K)vEE6b& z|3stF?jN_h>rQdL0XiP513#XA+PWj^~MiV7@g^;B(TY zGC_73rNQ&^3=KhRZZ_6`YD~So@tf@Dxze}dFf_{759G3H!AAg=B=&%oS;G{6E0>7E ztjTAH+h|Pr6zLak627QL)5^#R=;l9qCX@bSY{(rwb`r0|S|h){v(i75PeDPI!h?*y zpdD3)nxzSe<$G)!W(nhVo~a=qyxrsIh#oI=gNcWYEO~dga^Ed2rz$P#% zp_^Rcr{XV~(2BMNTa>iREGPX|0Zpz(?}vz^d5;Oq4okm7Ai}e$p%^h$s*z5_ZZ|E5 z#|d$P)YC5t;|4cD7tOo=1hz5Sk-Lji$h)Exbz7m<2=XOZQg zb3ktvHu zduymc5tqD3sR`HZtm2QXm)Gwsec}%p8!xec!KC@I*3;bkuAtq^@}VEjbNf8VChthh zj2UuxQ5PZrmlhHh6@!h=6c>ipwPvg1a$pqzpu4uChz&IT-9EYDVBgA_Vw=oN+U>U>=l2)T|e*S5I8up$}R?70^aL@Yt_7`clQ7aQXP-!xG_~|OYpzhk-Y_Y*7 z7y4odr|_7W!*oR`OebTm5qrL~X~#W`YA?@n0XOh|ycf&2)b;5y_`5o0kpd1L+y;YN zH1wLgxvBwjI_5fRACbclF{+`PW=fLv5Rm!q!-V(M$qhn>u zu1@pV>|#qYiSdAewTpGyhhd>2!+Sp>z~(D!4{<2iee^M>tlJZ+6komqYHc3TmhBQK z0ol!YF#RWD=BGxR>ujC#MauJ+-xePst;BhG?*mXgdqdlyo2RF&UX7y2%O>&L+in0( zq#;)v?=HpdjmRM@%*Sv_;|qH45P(I@BJ@|K z9%zp?r30vn3s1F$LxI^@9b2veZM0^1D%UwqxtKJ06~2zJtfPIs?vH)S2?!RAe|-u* zrG$uRw)5buD)@$pY5AE@WAD*TBK=l*;Xh;f)YU*t#s z;WM8B;y38I|C)o}>Fi5yfM_qQ2<$DrW}%cXI!4m|Swe z^vD(%so_u)?aW4QJA_3XEz7rM-IrfqN2pDAQBoZ~j%ia-=Va#hv47RMuz-+Z z)fW24)-J(R{Pu0|3oUMqP`P`%aOlO0(WDbR9?x`Ao_;DL%FW$&JVK0JgWo>G8}NHS zkglBJ{z7ZsaP@lkr_AUjfg!coxQdGYOU%sI(ORe_4{gFGNk=?ahv(W@LELh0%zX%l zz`s`gww#=GGyKgP^(300oX4!xjyIvmWA4pmTFJX^R#qW2?`gtwJeOq2|3_N)paqcs z5?Fal6Ojh?fQS%blfP~*WuxhztRM`^4oKl1mCr_)_?M(ZSZot6rgn+@;m*q-I25L&S>#l z=MyAkQCTin>HBr-an#&}LSBztazyWSg+DyIl7<|6+$1SzP5Kcl{TbDY`MTU6D>iA< zx!St?FS%yXmQr#bX8p?2E(kZ6uoRR|HJW(u+rZaC?#V8ykq75(8}mY>-mG5~i4&~`Uw(|HaYV`&{6{5P1$wE0sl9wmiSDaZMXOVg zF(4Nlf&*A0N2e0`ZXT{LOhjhb`H5ib}Se*S$BshWEr$2JAR8Hge5@N0 zIDl~(CnRJu!~V_pgwQ3ON1?pCsJlgycb}{eT3pmGc?-i7x7JOaH)a$%toPjvIsX!h&J}vC4ySV!J_z;@Kel!a9CtvKT4JiXM&_)pL527iU zr5#g1gAgMp39wy7%OmtS|2cs}?_C*2A5$shUy3)h1IW4*K%|87}l}pw?;E^7I#*}z-E3Lz{MXL*P{oWln|6y9VeWO{!2jEix zh=i~wS~!-HHZisP*dHjCHQC1Dd})gO1$;&wmq z7Dd*(Yw(q1CDBJar`CpiwY$dKnBrT(5!3hMp_~FwSTV% zc2%;IGqUCOGXrm`Zrhg<0r}q8TniYh$HU?-RLh)`byAyxd`eLEobgktP&cMU(xzMU ziyGoF9+KLB{x+3wKM!0V-~I*RBC;@|RV9Wmri8@2wM;AeCS{jl32 z{HPX!2x2>CmO?ZslhP(VPLa;MLrwz8A$~Yi%N~WD^)hy@z3vAT*v-Mf33WW{&P#sx zqO4n+1dE{97vnNS0j*!l_C2m^`(2M4mAb%BjKU_pxa9`Z2c`ct*}F``lodNMgspke zU$*HVRtGm8H&*54nK$a{!nNr}OzT|wXK$SD1Z=g`#8v_J1Esu59tb^=Qy`Z+pPTFd z>Ip|m+N{@vLE>!o0k|jUrBew=X+`$7Qp~JlF8e>GXid8;e0-(QqBQ%&qu6PtYQDA9 zl+QJaLD8wh)>jKKd6iu1;j`?5zA>1$#-SfBAe70TKYvApJMKn%+T@}GPYu4Ks6{UP zwjS=U`22!f;{~TB=lu*b_zy-(xC)jlzZa=U_v|aZV z9>&XL1!$QhSmnA}Ku~(dVwU+$fqz}$Ha^BHE2pxPwKpOLy)pGY-+S@A($c0PN^lt6 zD^FS8u`OtZydKCJ*o12|z;^`@Uo-P8-e6>5y0N}q=)ujW^}c)AnL1JDfxq9zUV&M(jYgaigWm=={dbUdwH4Sr{NI zMy&-b_;8D8&3UqN73jSZ-qc_UUam*A2=v~TY&hG|phSoiUy|p4nB}sZuUV-gwCe)obUeMT5?w#~}Sy6h|v-c8B^trw(|Ll&}Z&bXZTZ9cfJ;GA5E#E|E z=iq&|zR3!B1C#DXu@6^}9ZI7Nh0?CwDeZAr1ccN(FE}Ee)_G;$ zp}38hWB`+D{Fye+E+a(A@Mv>7%mW>|{kKR4hoMnmJSTkElYuRQbozixd` zREQFy{*lG%&N7;J^(rVLBNEWIj}``^1tc-CIPy*?>qP$}P0fqQS_LFkJCJ`8i4z)M z^qtB$d&zmD%%s%m%eXqC75i0`9=*;a^1EE*)F~R zF;XF)j8Iwg#f5^gC-qGAF@E)dJ;kSHz|f>!=Vz{1W1G@_WNWC}R|ap5Tp3{hk?r1i zWNFIfom&i2ptV@9u)`kk?Sr=J6RG|g+nQ&QqP?)t-s9`%hbsU4)&}Luyk>oaF_gUV zXv%}aLN@WWiY)C*9k;N6cjxc)!Cro6svMe$7+ zSg_l!Y#$%C-N>SS5iiK$O1>CaHBZrY_%?S=kzYLmvhEqQ_f&27aA)kFh69W)jFH4zp7mKUjqZ&pL-}m{?E1lKmTwG z02@E;^>oyqAspjHz`wcZ&ell%XH*uN_IoE@dj4mxix8FjzbECp^YZn-f<})^+-HW= z-=6&Y&HoeBUIgL<_KWq=k$(k)J?g-M{r~>*!N%vEkWB`shn{gj>dt6%Y|LhGrgmpg zChP%ToqGA96_9}u_geF<^8593?sCBeV2&BZ$pLe&a_R(tfVS1_vPrkj>FL@G9j^e# z>Ck8U7kAk_*0!ISw0M1l4ZWK5&Sr#A` z9oq_wh)M+V(!GGPU*6r5!_i8;bN*{^hDShY>AW#yGNK?iw+CClz?btd>KC(Vja$at z@R2P5x=A+o`1N21NZR-j-*j|=edmRQj7saYoaff8{ZICcpeZol1*LT)o;9;&?fQdU z+dsH?uX)Q-*IpW!kO~UpwRX!yp6j~NN_zcewiGy?T14{9!11X?c zTbZv5DqF2b^y@RVk$ShYEiJiZpC}z~>j_%6rVS)1J$U}&#f`&&Mp+8Rn~I?|H5ZcQ zJ)@nFRUH(W_5@$IU$6b_XY(uLL?N4}R2x@5WJ>BxmMI@T!(A&#cT%E20cc~~=Le4s z`{O}PkG#BI5`&L-x-TO$p?4PPMwIP9A{#f=g{Jxk+2~bvwn$`j71ROXs+& zS8q!Z@8^0RG3!>|vnO3Aoa`8|*{p8znIw!cu*qT_AjZ}e>E&sj<+a#GhUfVV@Tr*n$R<@eWr@y}r+_~TjIdWEZ@)5K_oDs-t3rQ4qE>L? z7Bca6@P`zhy$${N$pR^P{*N=2o>4m6Y)nkT6Gd@}%gdM9&UANny@qZV-vM-hBy;M? zNgqc~u0_zRH{RgleaW4glyn4F6Uvk8H_kep_M;QzvlAvi1nrT^v7R_xgb1<-L66#LXP55q~S_!_d82>ems#q2xDL zP+`9UNVGv;EeMsj+)I&kxgN`}6{AdIvsDV|nXs9ne98zvX1}0+L!ks=A!J{4m0R^y`PVKWGY&^juY% zb0HmKa2{h?GS+=lA3PCqW`O9LzF~>6eK;=2H5^`A32Q*71tyq#8z4fu_w8DbwvNOD zJ`|jT84jl8pF3bh*1MKNNga0pL7`YEah_qc@P%6A@eb!{goZ_#XuXZ6o`{UiE?1Ve zPD5}_4y6g*iMx zRysy)PB%_Lzw*b_JP(unY6R|!6Nz5Ghu?wlJwaku*|i-evRl*Uvho9GTkJUsE$dTw zzq5GzaN&(Ovdx!!)^4D!LuWQOX?Kg@)u~cSf@5XIOg%<7Ej0psaMmQ@i(l0g%F%sZ zF^aag&00nD&H&)jBJ+uu*}By_^Q_pSk@!UKi*&1zS3c6}e_vhk`jkenGZ9l@q~-zc z2*##s(Zg{7;%sUbKTCRpUJhp~tH0Y_T94vAYZ34O6@cL|u?djsj>=r*&}jDSy)>4y z{~imTt9OM~Le%HnP1iK9HPuM}oQK?K`w+#sU%Ps?HK-GF$ln1DlWbCdVm1t%vshZY02?8(G z3_X~bs&e~r&wi?|^xC(Ry5nEnnbKBMXKQ?)%;v98&TdI|Yb63j!2bFL=(?UZL~X$j z@2s#3*-A^+ZSu00*gQOY?bnh{)yt9VvrI?uZq9fV3_^L#*p{)_BSZIG%AUJP*&psS zTQ^UYWkT6D(^1Nw(&P)*pYdIDs_*`EqF_X}{hQMcu!AbP*W`kaumZC|gAFT_sd*0~ z$2MDalnw^i&Nya~+Rt(IZC|_roAUkfhNb5$aY3NgVWMi~h{(mQIsl!C)s|Q11a1NX zyIz4-tnFY*Z*lpB&-q%Z%Y|vos7B9%mg7Fhox}sH2WRo}-W!idEE#)*#hLSiGO!37 zjPwPDn4v}Gdpr8?6n0}PjzwGdH7wRh*?Rh0Ef`J!1stP!Rrz6>G-lY2gB+W?5KK}< zkrr>5Wv+T}j6Xf~TmX&MIkTC4Kas#6Wa%tpeNi%>;Im)GMHPu9=Fr{;+6FDxLsVYV z+%~D3r1zf$65_X>ipba362t0qSJ+xqZP6+INnm-&dZSDz_XGY@i3O^j8HIul``Rf( z$h&l&+7|)?(H~QNlG^wlYCBsjT=)UDMKH0j2y@J&T{pOu82?W1R7y2n#{YrcAeg@z zEu83Bwsvdimnm@~(YMxyfK}!WPPGa|picOo2ww+4B9deQ;U(bu!8((uv1irFCrZeK zDXW?FA8GP0qHkL^jwE2*)gkJS2W3smg1q&*4QKJl(HeuOvBoBni0Mjgp>b=Cm(#-S zSrttkRyH=_!4zxy5bw-MIY*ZmXVocf;oO~Qp5X(z#G!ZTcl=wOrW@wRp~iqT8()0Y zRn}58ecZ0=kET7d+<@BmKEw=AZ{3vg6O+L)Phs(?3U7?atWYtR_V`zeyV?r1UqIg> zfvfZD!a50j>Ws!LsVlDG^nl|3Z_tc|jhfvR*wjmb=dN!aOW66ZMs92` zS-{Gw;a~SINqvSLl_}(!P81Q|KaSgm?tlPJQDg3`4S}Ur^$XdWDuc4a*~xCM-~n4# zR1K2A8H^vS&xQ>gMuNxQUR3VOeiLcEISm&VcFTheD%9b2<$}=k2nhHx=38WwKPFks zGk?zKD1DVd<<&ZNPE^4cTh+~>=r^2Uw6K^)zT&n6 z#s>#zu0gKdja4pVNNHlqJQU+>!Nt)Oh&o{D-9(NJDwdpY6s@<5@>&n5BcfiiC5l?Q zM2G`DuY^Yn5My=7-;kP<&mHs99M{V|zXu=qzrMztwCNnlnMLdp#MB@Sy_QplzHH6n z}^0oT#89?`HOyRJ(P2toNJ!Iu#T_Q=u#dVgdSeWi*=tDgoU0DCQzH z?L&S--xentmAe?QUEX*UyMF^z*`zY@HdEl>SX<6(t%9Sg#ra62aW|WOHEi@cf175# z4L!SNZ0{IziZg?85shN^hfLvl5hpwHPm->!TNC0y#92j|z9b>8cy2>3=rr(seSv&O zoEgkE*1p_xF4`mi652O=5|^tl@!A-lE{er^_X$gLk*_sMtz&AgMM?6v*`f(uhhUz&A&t)<$2uHJ?5S@??E$KD$7hWt>de-Bm zr^@kKQRPf$5M~Y6`rElv|YbP-U~7>T3&LDOa7V*ma@ zMccp$4-=$gx>+4IVc|55Kha_iRgmT>>P=lgS&gDtI^4(wHxv5HgEebGT`2`z76XrRlKrVwz-+%CC z!4+#G^zi@lr}7Q$}%{e zE=a291c1A3?yb4`Vt)l;NT>>fmuCEme-ogZLWE^z zlA?jUqBx~bDoREmUPx~gx!bzO6!>fT{0?b_!>XA25dQyqg8guWk(Eo|SqCs97G zd_|PgHGqO`hSSz=;g-ZyjcTf`QpX!_YOkorijJ(ZD``gH3;iZE4M`>qgUy?p1}3 zZvb6Cct~U&VYOGZGKjDZoW+#cNPV3^S!>}*V0+8GRDx2y%S6vOF5~CQLHo#?_B1b( zdU_B4dTpQSzz>4W&teH>v5eGofZCqwlG6SALc3APgbauGQlK!oJWxIh!lXsr$j&GH zGs@|P|#1aSpHetxti z10av*{l1^0e&H4%P*|^;19TGdKKk*7UWcFqpg*Ehq>3IhNo*c;bE(bD<5v;Yu(_=& zWc(3ij>1eIH2xIaA1{OkK?x;twl}L!Uku^Z4wxl!PfMzYYL50gVu4Ej%)#OzUbjip z9_QX2$Fa4I?aUjPd2uz7_rg;Mq@Cg$(X4`?qo2vTS496 zz*^d+d~{8TpbRo$jMG3EeIa>rVE_4p>NGqi&8WXt8ZzPS@0}RMM~yE zu8_Fz`t&!>EsEU3hhd`zj*54mjWk?UU=Ki`mK8Hshoeyzq_Z+jj*-P^VyeHntA_)a*IXJBzl&~^7LlHjG7*H z4rsSHXnJ#)Jl@;UDE=WGD5tf~dEo^)VV=+dj&>Fyie`y@p=j42XB`K z)b0@Ei&J<`PVw!|Jgq!#X%rr(EL-jlR}1Kr$xbAnpr4f)SKdpwZIZH1zImMFjd5zF z4@Fh2tO>kfU6d<~E=E5!680&u)k>Yw`6N|tTAPDcKaP0R7)4W)UZdD+ipXfXHeWy`;y^HY+9QWmNhCO7S}SA32&P{f=ylEY9%zGd=lx) zCzdZnxma^lqq%H7qq>Nl(Qr+i$}ViazS3N<%=4 zDHBud${D+y%iN{HvN*c4^(M~?P6bbqtniH)WBSoDHMQASYQBEW6p$Uxb~v?P>iXz& zvnqC>DTd+cD>X@sabdRv(`-O zRpny!tl&96+Dbr1>6vg=@kNCw9MbVs^CiAZ&@wg1I6X&S1uR%zZQA64ahQRCm5B4s zo^MG|kngtDd&DomdMuAjH(t&UEnIHC!LpDh?J%Oj|9LVO4Y0r43D0LfK!b{nf2~El z2gJKhAh~y&f*ND%%z{Y@u=uUOrczk8JbWtsk|rbb%@uURo}cLTb1`wcs4s9qfz)l{i7lcMQt$VN{A+=Gqd`R+`=zE+&$ z>PsJtPo;0B#FT+VLbIWV&pCC}w>K3}a~^6bm_zcD!%8w^HtwmhYOW_L$dR~5T7*+U z?H{Rj`ilj2?#!KuE{l}zS)m%13Ke$d^*#NYE zPzL!$9Uwi|!jiWfw*}c3we+UWUE~VfDKvNcae#WJJ);0UrDQ=;7Sx=Bv>(g){1Pi* z4YZDB+FoyQ(mD*i=MoT;F7I(-p;+BkS=#>D{Ai=2ims3@KEcDRb=~1q$3+9ow)MT<2T7Bd}zX zDc(Q$eZ|EpWg5f2&O<&?)uG7Ar00sYF-w_6hD!3fYLRNm8QmqXEa$m6^mwNb#6eu+ z?k^REOdyUge|=c84Zu=!I{>Zeh`Ky%6R>c@2)G%->gfge%ijdO09su6w) zy`Y8p-m&<>@`jq&64Pen?U}lHrE_KxJD3P|d9RZ`PHSi1ZBdOMpheFRT)D&Yk3SfucC_wO>bLu?m$Dq89VMJ6ukb$jVp#K$<=Aj{h z^Lgfy78D@l>J9J!Uenzn5%*(JigruO57i*r3qeFsvb=Xn%i#oKm@#PEAb*`VF#SY{Z%)%+N zJY$DF1uYbC(+Aj@+U4v>k&4>&JG*SIEd~#xSYJfkT7dRrf2&A9NFtb#`Jk6>UDxc` z<*Jj8q^FOg`x!ZyvCyoMIxRNb>9@I;Q>~??+K3!w9oD%G5gF@d^)(Qt%;D57L#~m2pI|7(ltc#MhMfHt8RJXAtHfkF|me8{yKkKyZ>9KferBY;5}HFQ2-k>wsb);O_ERPBQ1Jz)*0Kro5euD1%<1S< zE@HXVqyu00RVkGyU9C|?@Q{z%bngQP?!`B9Heu3k)R+6B+?y}3NzY{%g=q!ej{<&N zb&ia!iFgY6vL~j%%4p$p5MP%@m46EB91`0y!mdnqewBQJA#UPVe<`1JJ{gii1(RS~ zKYbvp116{@NRrAJY(Jj)sszFH;1J`Abu;C-1+KPT)Jc*sNbCQA&cHgp>g20qvCKTD z2&9`Kxns6;i6?C}Pu~q-3E|3WJ4N$&0qGm2f z9BhmrP5EUdGnr5BhA-UAgv$5S;Wj-LLt&mAGP|v)x`M)MhPYh2Ovv3<@w&sSyrkTY zj`YJ_yDxV;y6WEQEzlyA(I=|7;M=&1DvRG4hr0=D%EtoQCRU=xVGl{qYHT!zM{}P# zH=R^wG6Af z0idv3qN)%=1jUa1iemG)6SE|}oW7JdZMTvZA4hCDMByuefm_#}-5yt}lJ8;q)C=(+dJFvDe zrIYD0iqiH_d>+iJX{M^IX)`xcq;ssq$Os-sLtAHcohUxL@Cl}`XmLY2^xD~fcd7l6=Pm%Xj8sQYF>X6J`) z%59)Uaw5IyxCrTiPVuV>#0O!gM_Vwnprbtj2`B3a5L(LN&#BAufi;yC(Gx@ zi%isqHbzIO47Q5L7d%<_E!>7iq9s_N-dMjPy0>#S-kjT=EH&PgPeOxkaDcv@K)QSP zPWF}{EvJJm-xcN6BX}`4ucj9l-F1)GSV`-Em4oG_Wk7LiVJz5MALTIv1h#CRnBIf5 zs}tu6FxsX`l04&N^{SKJ`BT}KMbH=IwsSK?hcN4}G!-45IkqSH)KkE|9NlYj!P2L% z!7^vSGfqlMn%2OCL9d+j(`IW2JDc$nR?m98aga6vpI=FH<}g{@0#Bm0=xxO$Nx4G) zj^(geuaP1=f^gho;~SN>bGT9GTeo-IROIWfmw8Gzb}*DallfDAKo6oemT$^GJ-M`t zD`Yy}Kp>OTwY}r*m_nC?7~|vOyo`qC^F4v#Z8NB8L48X;?Rak0p2O6q)Z~NX`p-Pu%Bb|wobJ3;hNm^xGHSso zGQFqEqSr_cxnr?-CXGHca|g8ZlYkb7tE9v z!TSA${en5=_V(+mXvfq8mFq)wqQ&A0K2G(;s*YBf24*!%;AzKH|JyNT*Gr>_JLL$u z$#mTE`mZf$pw2(An&C4lEFGh7Qfqu=o4xMv8R%-dTF{KK`Ii_COvn|b)^FZ{dH3qV zEu@<7Ri7qv$RE4T3YEZf%CP?VI-U$kl#MmrBiHCL)emK=zD#m zk28cicg<6xuzAKKX_?m1xUNR}ah}e@_D5Oxb-Y*lL{f8#L2Yr0+T-=Wv8pYGlUh3> zdI3}%icxgr&`nR-SidrPWN_7fDW4<%TyXjKB=j`qMS`TT7y^i!O z6S;@BL^tDQ5W*dVO6IF#qxGI&A@z9i^cbGuZ2}&YrgODqZR%MpRJfL!C z2C1ulZ2D07tGlitdiMFDSwVA|dS!LGC1E7% zIJ1xz`z$h{EWyK8WWz$@q&vCb+RKI_ku-zPxuMncQuGh|aE)_O(dQp3)J8~)_QHmZ ze7dg~E@IMNtfuAJQrFrABmvFkCy(jI)nw%g4L=T$w1(X3rJ9%Rk5)+vX1nGIIE zrK|0%NjRNwfxKE~di_;ZRg5I2p6#UURzAv*`;oyw)Nw@)4>2)(Oc*^+;UNIfl;`B0 z-ns{X9g#Qs2gnWBCSBt^^3OF@`ct`U!`@xgXa@jt001M+io9tpR^&R*+2weq3zd( zA*o5Zr6E?%Ix?s#HwA@<1VzdjSqhiST7O|JZYHU?kXwbTunZ*S{6#?cb{~LYXu4b; z{EIV;O`vq%vHWmE^DnT-D?rPeWgzN*hyUNn`CrQ+qxgSj zTTy_{wEhTB&3jQGVKba13N=0LVCZme-5M+J7irdQJ@I*4KQN|4tULfpM}*b&aL>6G ze|+E$0={_3f9;1_;7*#%`uciZ8JQ^Tr}eMDdUi1WAiql&k|<)vAN-M3xIa-eF-rMh zAOesggh|5zae>j+Kr?mI3||{;SXx)#6_jbV8M> zi2gYF1psl{SH}W5pg$01Dz9lNq|Tfk6jz{efC?ED=_e=! zX2jr!;Ex`DI#vfXzheq%o<4ngiG@XgPb2R0gyZDf;BwdevmQNHm4mW@ZfsgLsDy%y zTL8qb?+3Nd-`KnqmEnL8^g!|rw!u5A8KCmdK$cviG)2m0`}3EW=;$9W zWIu4mZS4YWAOVef3;5Kan+>*b!eXIc6vq>IwEJtT-$j+B4v5n^Q2;ksJttPj=l%9y z%jforhJx>mwhrR6YwI;9`$S=_OD7u2qj-g%-O);9+l#L77$6_20fn~Ey_UK#72ljG(`H)=pd+UYfO;1MGztE1~Ou26f3a-1&H+e^` zKaee!RR}FOvTj23`nopvule|R%N~u5j#`sWPkv)gT+@e?0=XU*T5Ejj?;)Bk7ooxe6x10bdnfl1Gq|6_lhA@~0c z>||nj^Vf5~0hw4?a~wPNFU6UjSd}N7VaqNf86d$bWjTXU-LaJl2oUA|4^YFhtgYOrAf_zO8NUlL+Dq3^(-HrPayYc|61DfcSFuJFftbY4IthHSZk^z&XIe6jdKs+fQnGuFZ$Ck z8L0z9KhuSfSfvbst`n?PuSl91x9U*=Q94KDD+ia|V99cxb+;{A#^d$;QqM7P#sPPT z2+&zXutfv^bZnm{7QiGa^>jyPsNOrVYa@O0!OjQ=pURm6(C=g4U;lEN{&TL}Q{m=4 zyQmt$(9aa5ER1J4PURcfkaEn^SvZY>E@sthW+~!^?xC*AxMauOG?^=yRPk4$GrX8IEReZ*qxUGbTBu9uzvn|%o1Ev`nc_F^LaVg^vd3r3pi={Sc3D;@ioX83 z81l`uW5i|PfUMP_3*CX9XU~xV5RlPwo~uBRZ<_aB^U$tlw`t4474>V{@8>%;n}H-t zUJ(&#%L2r@9k*pX=Tg|W%E9iW*Bk$}X4Kb0lRn$4hlFoVx9Vj;i!yH~k;)}q-QUl7 zE$w-Xs@L9!F#iU@LB7uu6>pZBX51=8$O`MBfJ_T_NBGCMMm+PQ<~qBc?TgC2T>xTv z)3d1$sKjp24NSUDa|3zcI2tAaprOgi$}2n~o>o*5F{$j@PHxh~t6tX7(p>+BP1+@n zE}s)r5KF$ICAmk(xszUE+Bj233O%=%&;Gt?!l7bDRmmB(OD{Ic2K#BqL7VTRUdfrCDc- zgaD5gO%&GA7YI8}mBcpfX~!twnzUHQi_xVEu;}=1ay=4|8jClw#EQlX8C69b6Tfr= zGPYGayLwSb;>O-iNa)Utqv(68RiI`a!*WEtf*pbV>QY!vaSZ6Q4E|U(HB#ox)_9bm zund;a3;^^KbaNg`K`dFNOK>iqd=(UOPV>Ql^hcsF(4%EpP|x|urM0m>>c_@}n8a;Uz4WHql{rsD-K>%M zBzQ=^+OthjxWWb_eGL>)(xS92ygr&2Yd&*w>6*B8lH$pLC&BN;hX`iq*{31(Oc;TA z_ApbifHxE?s8=w1!WAspLlxNUiz6gt%6o^$30VS|)uLn@)WT9%e;9+(t!vk=c}z?s z{#F87sp>C_ufe#dk@L*gj(<^ASf3&azI%VE(6aoK;&xk36>7J2@~Z`SrbZ5| zbl~?n&EL>Y(2iU|D|APav>1A}E)n}-%xv9E@M-pIpxSL2%Em9u#(r`xTKF zLGwwfRzfuulVqhc))6%)>ZoO9UZgL0WC7Po76;cjZ!KpM3A0>Db>33#p2}mVHyf+a zS3D+>EWHfMA3mSkf=>m0l?0Tw4;j`3>@txP3E1&MSXwYDPyEHq?e)3Fu-o9K@c|hT zL1GeW6yPm5Ar`~wQk{?@=?cB|3qh`7zi5Y)yC;ibAGdb5nz{Py3+i`GE&P5Q?CkWP zbzUq4MQzn6`v)g*0*-#{r+nvF%`+vSpG|9tR86erfc|TD-DBlPYfefpdv2w>&g^e@ z5NGGr)%$HGOBxBN@10%hwM7OiUm`dYooDMKsW4k{vC#<=416dk-eBwnt1!69CuVED zOcK;Dk*n8D5MRTW^GQp08Pml~CmBn8Kb0SYoW6w;4vhHy_Mk~Wl`LKAxdPV68rQyg zI=h3XOStVTO=?PV>@nV}s`Od`h@X+nLXYCv|LBDw68f1wL~4I*(I}Qabh4)Fc96l~ zfcv1M!^oUx3+GfT%x=qNv}#4S+R@JbMYtMf7-c_Zo8k|%dt}7a7B7OO@pCjR=Q&*A z#d=Si*-kDXp{x`WkGgDax z!%PeYW6XWHuJ7-AUHLt@U;hX9>wfZo<~5D!obx!H=jV8@bYkp50p|uF*haa7!h)45 z)54g=!PLmfazTquWm2{%;34Bv3KfI6yI0KMS^;|l0k{s6%k3(b6W|`vtqA?!f%J!$-8z3EPT1TRa!6)XIA@>y!5QnCqi>_ zUarimX&S6453yd+u$tDJR7{Xlu_d#?FLkBwqc$A*EX047163coi7*V6*vXN8`sd*LHVY`1jF8n*0u}R= z{kOcyBQaXKbT-n|;r@QLPX(xwu5E)>tNlv}agOJ0(ld9#6_iy*IU(?e=i!`B8~>hggkibsIkQ z_vVf7>5Ua{qW!8AA|^7t8|Btjy&8vX;Hhx0Mr|-qchPzL$61}GS zgM&W@l=-|)ItcBmzQ(uedu*lebRRkc*22|gZwfNZ7L+fF$1YBG=;eNWIcH-QFnF!n zE#qii6$cYVz5dPawt&6OxAWq;nb^W6&9qh{b^Bx2C45EP2H)VGweZe;@MVnyb(jMj z8z5LsFb?QFw6oBecZ@Oo1Azo$nD=@7*lfqClAw>M6w$R64f4jXDNKgd=cO0&u=N?z zWQ8&MgJvqF9%#S**~La|?0e+Tb3ZWk)|-be3X^Y$%sb{s>^!6Qbok9a7{>(7?a-^& zUw~6X&vBhpjC&%k^r3ZSHlPB7`{0^5KQeGMTY;@_Y0`rD>S!XhMjNrr1fl(Nr|#kd zOf06wL7QHkZh2$LYM-uq>Q`hJss3C{p4N_uy(G8^4n| z5it=jKDVGKmrC3S3sec5yQ3j;+!-E#UJw4z0H!WNIvD7?I`H%kQ)5l{V>m0%(b}sC zhT%3tA>XyG{QI2$d1&tp+ikwsMdO=4+l%c=(*>H=*Ipwr&#~P&`-nw<9Z%Y{NES4B z*-WWTQVB2G5}g{yF0Jkk-$5QKaxm9zvH(x_Pg3bia}_)1^n2G=`60#TrO810I6C+! zAb&h<>Z3-fwNU8B&DUz`6flG&6(cF%Bu&*6Ho=Q$(YxY2Owb2^e5T9w5J?#A<^U(27G zo(WATJ5{P3%2){{a90hr?o)S~`h)z|RHYEs{?jQirbI9)F)F2(?Y1e0oX()03e?OL zE~lwnqlYA_i>P#5{FUDxit}@yP{z_pEonR@j3pl(W3{z+FBBx~i9HO(o5iL^u5RZ2 zh}%Ip9&Z0+XBX^;JxO>-0Jqg5l z);w_Uv!fxV4w+}KjW$jUWl&{{5f-T~*|0I|CtUyD=KxY6X~s##rp62Za4_L>Z}IS) z{HGL`n?0iz3ui?dI}N<+U1SD9JK5zNErir_ zKc_OuLk`txZyJ&%%w)Cga~m(e!oE@WKZh?nr3RaoucKzO(g$N(AMknYPa;)XK+&6y(3Ne zOSauz*7|Y-qicURgHEaG&I}MjHpaS5b04o*Ymuov~YqHiWWEZ8$ zNkP!UshKJ_0TnB)NFzGJwxP>1&Y;uH7m=byi&nQ?#L6gV^)oBRi!cs(bUn?6p5P5~ zPfJT>p_PT=Fd=|sgIxeFn*U;T2RnA0RGd>Zg{bGcM&hxD&a!?fJJG#JYYoFnr&T#F z^=NMFoSeypZJ54RljQXdHd~gs(dXbld53kxGx%<9^nc<@$U^)^`g(CV9Bwr}JZ%Ed*(5&Uy03sgv(E zGZ%eFkpZZ!ZyIXJzm^)%K`vGWU$i^u^+XdAiy-FYchSF`CpFaLer%5TP`fD_EmvC| zM>Gn#0gw03AEj{Ej?*XENK{{0A+wH)YPafq^Se|RSH6+My$!zlT%7YJE*kxl+KpbuPZS&#dtc0WAKM*i&3<>2-ov-W>K z@X&>W+%nJ`JHCIbJ+22ai0#33{Y~Bd_Z=Wqn$N`^eZ%PTzHnaNpCZc23P}R@wq;Li zvG00Q3hb^EOsUkJfLiBK$gh#pUbKm&vQEui~g{0aC*E*gyDP z5#TO`iv0G*ty6jaV3Kn4rv_o{_;`YOSgUQ%-9NJo0pPD4je?Ka=PNWEUUH9?t z1z&6tPkBMKMy++n?rjIT>o9=A@~#N}1{H>DeF3zl&Y0xn!)*t-1H8^*a(WxCE&LPU za%U+izL&QhWCVD9XHnetk~p6&=Y5bto$&AHI6@1&9&xKp`=4{Q_0!W!;PgI4<=Y(G zc96mmPdUSR1l2%Ks^otE(%l`_K;;Ki6DX~K``bX?<^|rp!E9qFI;8K7g9X3*bo3RJ zTd4tg47gky7N)GhMvfG{r(_K>Gs73ce<@BVge%kj{R4kQ>Wv=v<%pa>|a|=>|Pw|^Uw#W z#{EOUlXYg;CN%%RgN5BiX2s@;I*w@f?re461yOYnNX;?wa0{bX+S7C)XbfS!!ZCH% z+sf^|CoM)mb*EsJ6WJWa`t>6m)xHhvY|GU$6zt@Cx7w}Aw2$=$CebZh^~Kx2kn*0R zU#6Igo!$WFL$`A^B0*8DPTQx*^NZiZsHy1+ZckU*2(0GFzBi$8J%xzzZy&=tf!{{j z!p0}cX6UYykYl&Olr5o8>`b{J0u%db)h zOcUB5hNR-v@TTSflL8+>xggbkeGi+Mc)}GrI>QoER<-} zqHnw2b|2hp#TN3--wDCQ)BsDm(@a078Z0+F7PC1=Hv#_Q3sJyGse){&d;607q$a2^Z zm+7F8OiHf6I3A=hc1~K@08gcY9J`MI*nb+7`wr^ECMVey_YUdjI@pUyDH?GLX*hNx zaa(dP&5b{7D2(o%CJhhXX8M~50I-eWI6F|Z_;v;sh-Ia^LL2Ls=K@}A%((oO+$~|= zRai<<3L=K)G!|GXxOY6-$Lk16mnr!F1b6-So_^$p@ICFN| zcPhB~?dq$TDXPN378>a$hQ#WI%R8&Uh^4qs>=i;2yy;e#_2%oL1 z++1ri^`DK2tPm0B@F&&>9+;2V19p=Fr|1lAh`uG<0w(bg#jEXC+W zf%43RgfqFA`?Ev@!&VG(O1v{st?@H(AtlK;Y*ur+%z#?Ag@x?8`mH!;DO;6j(^$M~ zQ&tc`d2t9w9j(rE#Ijmap(EAuZvN#(+{r)ZXvw!h#>J#*7H?Fy4z-pM#Q^InX%#oA9^m2)ds;Ve|%0*VX zSvFK?wZPVp48lsvwVy;EQTF98z?nsfsDjdIlSPscdi2N87EM2IHqZLg&-usuQD0(c z5Dr$I3iU~z=kgltlXJ9;9lSdP;2-whR>c{63yMkr3sg`J@1a3o!O+a?P=eH>^PMp8dcMT~k+n4Xyo)8g8k}%G>Z1ZY)viQ)c z-zShqY0XdL!&WP6f!Z#FK+br5AOh<@bV}AgyA+2us*^lFiA)j za|Sr2`X4I!(`Xm^v7~@>o<){htH)>!;I_2bZpWSees{XSNq5q@+tlX{SH=x^0+p z6g}t^_@$*q_sW&bq>E|HvyK(-&wg9G%o*O_Ckyi99BCoMbJ4tf8Ru?ZxW0XEQ~f_+ zcezkG-Y0&qHhHjHzM%h`k%QXHwx+hB~Yz5g3>2Exr8_uuOZ`mSZA3$+2C2%37O6z}!F)IO29?aRlX z10}p}`oX$TtF8CVrzxXPGySx-alkwFJ<{&tOJb|ldVcEn;|KrMX^^nQx8sc2_NCg2 z{+cG9wc2(AyRmm`sls)SZdi=qx*4^p{`T>< z&v^_&cWw4Qy#;J&|NfVQ>pR5#**1pXeFT@UIXty#b@Alda*bx}h`>qBy9o_UEJtez zqVOk}>g^--n28ju>lZn57-qrHveJ9z3R6=eGvWWe%VV}?|n+O*E z{u&wC``8z0fwYteU0+F^*vZkj{PLvIO#tQCnV9xBz*_AcdVl82&P5$6cb>1mSR6)s z88jJ7WOevzs{S^$1LFPC-JN$>8Ok23HH)*w((5iHG80Ub6RA02$vZg`8e28jCURv1 z=a4|%L&m5cJIR+B1|k+SA4n8=j+O(F}cYYoHy6;N8HDM)epQk(=MY1yi`Xl&nheemqsUuT*&iH*6*tz zP5NR-Y#VJxbAccy#}U@IAH^y|c_o@!%kyEv#xXUXbtno#%gBAuL@;!MonK}{JWazP zrQsVIiLdcyX~0NXu>kiR0e{68NSS^qW5Hyu!+a-6jZK6`WHJ~@+Z*a=d7damkfPJL z6%j#D+%*ae(9hIZ8wQI%biDQKue{B3$?Mb+;!5xt7uSFG@~Wap>tHP|k7?!pUwV=51RM*Ozb6Ykq} zrJUoEO2N|^e52j_;df#R#TGSFCz6aZ<*!j1Nk&xld*5C+Es+_ILW!lgYra1ot4|ao zIXY?G>JBt+!ArtBIhsTYbB6kwq%F%I9r%OuYN=%q>K4)*V;#%->Jr&hZV94dp3KKL z{#>M(RJk1mEdnTvchBqW0}s}el^KS{vG++>0mIh>h_hjaXg$6FGov`IMk~RZlzCXP zW+C5I?GjSN+WDY}i!V=RdC)D2Of0hz?{oIOWr;7e{PeVIjhWB)7V2IGO1V!q?qR6f zWV@&pVV69Y2jmzT?PA_>-Ue!bHpc(liZ%~Zr>t&DABDWk3@#{K^UCpU_+7GOv@4?L zKzQ_3+?%cn=k6-)D2p;1QQoh{2?_c7>tl(E&JQXQ-$HoqPvTE3R zsb1pOMUWX1OeB9@oRpYj1Uj!)CZ;R8Uu^lQ&R(6^`nx*u{+Ydx9N8K`PFo#iG{dnK zYr7?RbRDaWPdaz=ikQ(xD=AwB?Vb*#1pJZ9}Vk2H{MC% zUI;84hI9vZd^+N79ugu$t*-F-qoqRg^uoRF`-c86j2n$Ce*^N^B6RtFy?lRV!p$Wk zo5u)Z?ww$Y$TfB@d+PM+SAbcBSdH1k};GP zMIM1I!O24|ZKDe0xr0vPL6nBIz={>Us?06_^}Pqo;DAPG#1sGFcR%3qUJ$cQbo;Wo^19SiW$ZFh;l4FOxAlEF(Q` z!0OVFWFzl}wU~Q7NRn^At;B+p+GxgQ^R$+#jYRIwp&w(?uk6M} zgYx@ zj>lqr6IKd_-kX`0)tA?dg^Xu`E%rWG`$fA+!QfMqC!WlmD(vn77UfpQT9|*Y6CSD9 z&4JJkwhgPw3?htFKb88D&v|1Ra8+YIt)b3F_phlU72iKC|2_3*3u?NPRF~OxswcPQwj)eNJkzy zZ>!?%scT@IIFAd|-CZ%AELA?4v(!+3rb1&6nEF?At{rXn#g^JdX}FyybU8`gYMwdo6;BfF7zebeN(l>)ww+jXU$<5IEvKz4up zS#+mc4F)|mD{&S5dk-6!-ri|_TVeBK{5BPVtyWt!7zphu&;2G+07015V6MCiC*QmD z+l3jfWe3nq+S_ZEzdg#>B0d}iqTW+Z54z5JZ{L{w7fY{w`!OLsMet2cTIaU8A7~<_ zq$-8vk0h~OI;~-zkZ8L0?BBe>765vK{pH$EUVH8JS%BJu-TWro0C}Q?NaP3 zyC0rlleVoky#7+~tc3Z7CY;WG{95ns`x}o%;;v6OMlPKZ)-}7V_2uEyxAvb2yFUhf zczy2Ng?B=MA0yA4Ide{*Y8y|0Bln5!o5)aV6%6{(m)I$~7Mp-mQDxv74G55t<;C{G z9C*B-vhNouq%A6?(i3MjR0WXd@N{v}fSCu3;Ih6Gv3)h(LP$?F7eOGJQboZ!kD)wKfqWfu#+oyP2`XB=6FF7O z!F?*u3tuM7s;?i08QtSrA|{G=2B?=MM@!Y;@JD|{$0SB_aCbnIR+5ZT{VOxvwGeaP zWO(oAh=XlHysT5FZVrLRVBu$rT7|>*!)@NRVZO6O0YpPGvXUe$%-5IU1lbn?4EOR= zZ{EBq*i-WXR03Ki46TcyOcRct7$AgndM@Ht7v3tg@u;nSP@{GE=~W+2yR^UqtEHdy z-B{fRLb_gZc?6(=jG(6$N+62*;!BMh@tAc)nwNn<`F^*}~( z+3W>S#b}4i3vmARy#}QHnjDk166OVNhI;?HTTQ1Hs=j;`3~-V016h7dZi`adhc{GR z0L?ady1}YK!XW9w0G3R)fwfE)XfDzzq5k%LdCvkHhn$Tw6!Am*;hxRE&m!SkTKiT@ z_bo{my2C-lnfOlPagoRSkCs=Vwx|!VU#h(+zv|zoFl-|!y-X!_6_6E9pZ+vO{P?-$ zFhBoYdgJt;oD(4a@>TeQkGrC3?ZZ6nOJ~-}!MYC~)J`Ktbu3M*W~=6kX$~Z8Rv_3y z9X6>m5mm+4`kcjOT1UF!bAAWngb%*jfdG{2VWaf)TNYfi>4WaqSfZI_YXY>++eo(6 zC@LDLE8X29+g}WATnNzBeF_}mrb8u`}xo@zJGwzV1)|DGz=Q-KM$kdgVgsGAc7Fj>77;)|I? z&kxGMOWgoDAf<3}awa}~`t*PjJkZ5aOeKio=pK=y$V5c(ux?)uS*}ud?mB8T`#)I# zz(O*XVLQ5Zn12(^Gq<75v+_})IZL|ns{fZ+8BNw1vY{r?ZtA8%&=?FkBIW@3i05$F z&X~9E+A!H!l6&60ey&`~PJA@CRY7*O^t3Ggz{SbDu7Pzo!B4xfUzTDuH{b2z;OJDP za#xKgd3?J;2zB{zIX~!xlVY*yHw!t_JP8l{KH!aY@)47h zo7Kl|(`bfil1LXj!+)#pz+~Z=qt!E7<<9Fq@Gd@7XK#3BRT!l`ao=G9fvmE=X@ts- zD(<{%+n)G)Z6ahG#DkBPSr23FXe#5WC%#w2%#wCvg>TQOdt9d*P<_7rrt$}Aag5#A zNoipPjM7wW2gFeQ;D%fJX-d+%TV>#*L#r)W(OcPFfOCiS9YFmp_Uhzt7@jtTBlGiL zE3Y984c2_^GfWE)U(;j&3*a4|9J66t%`nivlFD27Mdm+>5n;altCDPjt6!}b?-5-< z;V~hOlnuA^LNgI{3k9lz zv)4n!4UVzK{XzpJ4Gj&Gbk@~GJ1t5&%Cu`~T%4S#hoA`X0A6_^!aVaGr0`U0oIZV; zGVe$J5=4wm&=hA1+;1TQpyt5wUj?T$5oZiK6)_=2OD(BH-H?!n7^T8)%_@32GI970 zW!$*wKC+{To{M5*zjO$g5<`VxRzqdB-Ug}hn2&Y?n|WSNlS>Xx`g|V zfB)(1Fa|5}A$wh&J|-2$Qnz!)u9`Nne)v6;vbnw9xMcOcXqVPA;CZyH_QI!3B_y3U zwVJ}@To|?;R>v?Vs<}9i>`efHFE&FZcH){Z1<6uPLwm#IH{<}-%)XT#4~KbWy^?XL zzG%Dd-F%eQ9I;c@ZQwX)iN2STGT$!F=%_5>6_FoUp1k%tJ4|1^F?iW(jY-!W%b19w z=Q14SF2#c{aFY7+z@np@QlpT#;i{Y|?8ASG#$jf{wSazPg5ag>c@q6|TTWi^vRhxn z8tm|qBO(AaNdgRMhZ2+}402GC+uC%MgXdl~us*deVo}s+FKEL98C2{sBlOI$)%d$V z;}?IhG?H0NDldo*$rbkC&D7$-7b9z_6#cvb}DKCrS@AV`@8gz49?>xatGq{uA(s z&rcw-K0l#-vT5x z^VDa((WKQ)2BWRa7VelDuUi}I@7r-AaXyZYX|=;HWSNE{NQ z;7Q!P5@bg`$#CjSG2nG5c8%UQ$I-YJQ->958z_>S$V?xcXMQJ~e!QPn&%W1})x}T3zDG1oHC>bfNhTjU#h3~s1IH2PKSzaK zz*W!3`MKSy$&S@nsl0gm!)*}%?&(or;NW21)nKfasOU$<(a)cECI|s6ZOeI9ML6FW z<{ze>dNUW1Qq$E~259!UkQoGPd6} zXVae%{MOeBb09{21)rqRYBy8@>c5559lT&A@eVfwci7x`Z1Uy`tXIp{)QV7J)C2~o zN!5FlqfDEjZd%ypT6YNcXyV5yKWc}BbVaKXHGm%AeMHjgOGA_fAnem|3w@>^E3%9^pYOFD${i_hb1&tWnYl3Rt~*v*fWB>VP+u!$Bv4qZ?c+9ebqFae_L z6Ur=Xy;%<$%UTu6d*yj4!z?owKM-&oGY0n-VzhpdR3d^drLtrRl%31w4gB32;Hk-N z*j%?RV9jdO6)?5VED`WRZ*ocuG|+;txnDGYQoZb7W)i# zVubV+JextDHMG-ZDkpJ(^m;AVpdSo+73@AX&q~y+m;K{_kF7+Uy*xkqb)4xxh?H7j^80IJ%tkdB`L&9$8 zMHCeVlG~W)hwb_y^-%oam^ic(G(%HBNv{28TUhEBxFM+6N^NT-5_434Q5Hm!(NPfVc= zhnlMJdFq#9Cd47>9uy{ag)`5)Nx+r5lT?e{EVZslA|p{>ca_m51ZivIX(qu)m{;K^ z3sB#V;X8G09&kO#7~6SRjxx7qEWdQGU(x= zM(-@Otl@!I zHBZ0?n!*^%VUXFOB2Od}@R!L#U3>~gz7*qbx*O7tUP~;l-wdEVP9iaZEh2P|f@Qvg zUYe#JCLuz+r=z9>;hmIRG-^q1CPr20Lk|4JeKJ*>6x5$J)sVR9<=gS~Kg>#<-5p?4 znhJuh6c(fChVo$(;^~_Ec_H)KZ;S34dJvlo?7{ygmi}7Qr(=Ns87Uigp?9FXo|A?T z%AJ$t4IlONZ(O^MZ)~FG8jDuD;ZMteY8H! z{i%(Oyb!Iq5+nZ@mmO-Txt=%LifHs^x;<&xtn-eWIiB&V;Cl)d6*e5_Wpx+6-A&YIyD6)h!%q_alHB1$f2XFRE){G^u{>h0Pb!FaN$BU*H{7Hdz zKbw)W@dA$F%4i5C9@DwOevdRmN5(OZ|psxd+A< zz4vkJ= zyMQMnB{XwitAaW`i(nC)ovqiY>@`LWj3N&dDG@f;C+In>soK*XDhj`W0 zlvIZruJn`MSRg?ulSPeTOX_b(tKhy^PnWq|Xj?!p^UKaoDbPdOB8M&eHCY!cXku|e zr1(*089h>Pc;J0KT~8%`DO+!T!&H@zH$*i3GW{@wZwLL&K!06pCKD3=(+=&0fAR(7 zeTbxg7aydj!^LBtfmRE#o0{X96Ax7AY2)7;3QxgJRp;mIJLyxePX}r=Qh7~rZduFe zu6^!#l!BJ$S;OQ!s+{;tW8vgr_g#n{GC~T7O=cTh^h-mqa3js2%%#bv-X3XFSlc^% zqUz3W!?r>C^)JUaHfYF8QF@tjF4^iaG+4?gczP`?J#syL7i82wjrK#XPb5n7V)*t4 zYDlKMJXsSMMsVX__8VPmbl4MaIIvuxIF`jKMmELYYkE;mTZi@3ex$<#)}9{?6bePE zkP``LCIdx*Vy;qKDZ^vaSSZyU(@#V6)ALfai4s=VnPalhb12QIF;%(0brD)phga89 z_dUyIg&bA?l5?m*5!N4Oeb2hs?^n-hRqDn)uXG>Pq9$d;BiH>URh=XS_k;q6YB|jFsyc`3 z35aoO-t~fGR_-oUUnN9DzFB%eQLn6sN_)6vFkgZ?fYDW?6jzy~Um*6Ug_~14#BDx# z->=DnjItU?Gp^F}Jcu#@;9dXnpu0JEIE9G=id81`CEv&-M_0485ltDowr;gTEI&Fm zdL-p^0e?;63RU$SOOjTm5I7`C5B6b1U+ui)_w`i&sa1Bmvz3NApQq7u9?ckvycV6R zOE#CxY?%p$kV6YGE2ai~Ha%7vijX*zZD^#8&su)<$a_U&V{f`yF{zBUNf@SzJGVj`#bchfB<~^)u32OY~1F zdQ@7927z*@EN_(PJ;}A({k!0euCst64#P371P#C-p15pf3(xr;G^--8ZtcvMCNa95 z-}(jI%f1C|flT_Xkv^xn>?*gcI%D@+$h%hiPE~gHGc?ZIr|)q09DmTyv({&zn%0|A zi0jVh#E>$oJ$eu{CH#X%RHfC4 z_3!X$-9Clnh?qC8ZEr7)nKIPBTT0EBy|LBYpYA+v)OX@0#H}vyrFSQ2D65y0$ncWG zl1dRnWS1VXey(P@nb290`3^ZkrB!bpy{1W_y9v#EjaXdPz5cp_LPQiCHS}#?&fX`B zN$Onpp|5(0kamHL$-im7UFqS_3@xH-`i7LvC0pRJJ2U8h zOM!VS7R3@C(EO?pcYvGQht%V24t*M+Id58w@fjaKEK=HfK$hVs?+UdQ-(47I+&uX|sw;(0J zHSLcoP71~-OU0EHPHVuD(wowPvmm06;HXN&0VK4Wpwsdk#*uX``snidSt+%#L8JvE zVqL%RaCcJoaIkI45Gdzhs;9c&QFiX4SI^{XoR|aShs-C7J$oW)tK6bh^jL@DPZhtCO!7Af=$pSI9P! z6LPbVp;14D-Ao&zkB-2d5qj4N;y|lj#BbcRR8CFERz_@l3AyhV{S)baVy z=&dr7-UEn4pu2w>w0nUclq>iRH9^;3qE}k`KP5cL82wphD@{+!THzhicyzXDW2kI6 z{VXJyOI{c;mDXy=s&}6&*EIA@FA%0d`|=`hxxS3%zLF=nuCt_XilPquS?$`df1RZ^ zv{VFd7RYnUQ5Cph;GQp=fz@QG^)hs6{%*JwNk+GGab%k7{F9-B0Q8|)1+;;Qq zn8NV-aYb=uMt7PV@j%-^i+IgtJapsh2E;2Z5mUPRP zUW4!{IbX-Hu$FRNY!J$VBQ`f$e4n|Vf325eR!VP<VmL_nRodGv9Ch+PzTQO{4hx??`>+x=2dV?|Zs}PhIzv(JycV8si*hyT)4iBI_v*7W;19Ir?%Mp=s5*}?1AOgU@tjg}eJl5-6)m+FI;PpCdz0C<4g z9)B3ls{;6C>gKB%S9LyMblrfgy-^U!bIJo5n_Z(Afi$g2$#hSD;Xd2*CTp7QhSk}! z+_&5(RH;p% zAkP<5f-**Cgo?7l+(6M#*KEz;jkRpza3(9G8AfsoT7?pdSr`GNQVaE&D{9iM5` zSsq(5fC$)HWqi(PTJbI9zsqXAY(ZO^xad6%^X$`hxo1c1Ug3k@xxR<>WfSX5D7Jv& z`w7L^^&di^Qvoc*`p8~7vlx1Bn(rXkbuj$r1_)~E-_N3Ar`8JipFz=I2MQ0Y>K7nG z3N)faC);KJ1CU-8ZryQ5eXS`}s1lv0AUud@$U!VN&5fXjmvUz%hnH&yH42>C;v(!~ zk1t;BKcU-Q;I!LFDK^ke48Bx5s-`k?4Cemp2F+kInBQabc)#C#fG|HNjTAzR2OzNyiX>urYd2@=V$aSSN5&+jD zYEo@x2IkG<%`sVfgpAdCTfK;d^hA>`d7YuAXGZT$QPlutehbSS5TSMbwDWBhpl3Ao zLm*tV+(2Hg)k-QZrJp^eA_r;FJ+4Yt*n3=gQNo(>W>_b~D=O+*TU-rCJvG_dYvz0a zWWF}$+$NJpvqPH)Pnydv7#^Ugma3!QkQ4&*enyBLM1Os8y_EhkQ846}SCReo@FDkN zdcFph-1sIEAJ#zdxPEb%b|WL-AXOQaznY#xH%9Yie>@8(>voTdAV3JuRP1+)`FA}= zQ`mrkwLFDIl@n!(X2a5Yb|F8^PzL6BDt2L6L6VfyS9c3(H~3;YBx=om+_vuLb9apH zjC19K{>9T@a$z+TB-0cl95S^&=Gf#IooE3?e5y8DxMxPDhj8#ZaMHuGX&^_GqCEQYl;N+);A@ zm7h%o1B9Buv#~-&$4*{-d;}K40`bN;k;a8NE4@ zMV|#_0rpf&mqr39n@*LGR;Oxs0UHXYZGzP45L-{t!MikvBUU;!@jA3j7s1EN?c$%u z6qc;GyYq5d3{nf`E57+@7W_?o?DdZj7T8^X{pAUEGowO1k3N@j=gPQNjDuI@vs}ez z0h{MK?%81xR_>+UR|DKyGAFU_=BG?Ec*sIiqc5b->d`F>xUO&>yZ9>p;uzx|x)NW2 ze5L5Jw9)se@p8gGfs5A}7&l>(L{SbuW&qqkBbB=I6u#-D!pe+0D-~x3H>%KqzB>b4 zp}iAB`IEP3-%Y@Ck93d!!QJNid=%#cQ=W&Bcl)Y1K_vU19-u&>^xVKtmt4Qv>vl3UB{ZVtd zg=SmdAM-l#zkPFd*1mg&g&(uNJ~%;k?c$qmmuCN<2p>u(o-?HxY@@~7taog^sqiCI zSRB2pd6c9iD=Qx-aBjP=TnzZi`@c!YB|?G0<=6W=?}+s$Im(rse*eCUdEs9}PB3=+ zE*h|vS(0p(4-aC7Yp6-y-TJXw8;ExEy1d$ftWyS!)fK;U)?*g z11y`({rGcde*XonDL}c)dFyg-`3jLcHnnG zT(hVaTUXXUSJz)Z#Yt~nVl7TnBEO&LU5g%UY5}u`{3`wrs%?G*xGS}ljQCZEddvQ< zt~}T#7@>BO%^2{=O>2R90PQP0SL51$4ECdi-FNQW!jT%9jq*Y_dJ>~VT(J2fzwIlA zb54w~t+w!GC(QJV>3(|n=Dgj*PzJpd=@6C(L`Vg-0utMf%eO|Wc~7&BLu`9Ac&WQlV$*V>s($QdU2kP5^#YqFa;o z9!?nk0+M3#!_e%3$rKh~7soPOd1v8N@>$TJL? z$J1`u23{e_SX~IK4f5&Oj4HZ@m@PsmvOw^hHeh#u5T5M&?JtilqJ(d68{6#+NzE~m zPnw!jy8leyO)2z-3BR)2ivvQNb=G&*7LNh~583f?ymhh+6|8sTM%);zb!WN#Z|&JI%MLm>n2ZbG&cFc!AMxhP>LU!OiF_~ zt#2IfLy9yR&z$uQsF47&fU9-=U9m!%H>JQ<5x8(}XNOj!4;=sFTVqBF^IHF0{f##d zeK87nN1z$#Lxy#Hs)xJ$XV3Xx@u;lG!Gt%B<*-5!ieEP#W1Vl(vIt^nMd6jeP13o< zUy&a~WsK<{O;H66i;I4NO*k_P{X&EKj;Du+on+*)pLZ_r!otGgjJ#)l{y+XSDKfd{ zI=CJJ+l^F#B&YqZ#QlNuw>ATWy}LBw5{mQ>T~ z8CVgkLh;6s)l9P3xy_kYZ^Iil7%%HOeNmW8+aFsSu`N;&Wf(c0YMk2Wk=7?Bj4%VH z>7Uq)|Fu3x!TQu{MeOjk>-U2sPUXw4lb}w_buL+qyq=Va$S8Tvy2fiJd)R7J(RowT z6qPOyQG{gWg#3gdTZBR`UA3xl`%z`+Qkk~=l`A8U#HBt)mZw(pRj%xB=9$wJ4k9w+ zk|eFI>_R7ZgQiMu3G-UFEHvYX4t*0O{#Ey9D0#=PFOx@SqNUu%?TC4LXH^>maDjB$ z?I*w7Tv&ML_p>_o6YD2~+c%jU`JOoQzSoFQQ@<0Eas}N>DvV!G?<3a$yZkIRnr2lX zcj9>LiRI+sMGz|W0HZ25`pD8W{vP#?>gcI3dYLWO*>}3 zxYt%t-T8L57K!7V(p+lfFF=SU4b=0E@U^bAihSSX4hqcW%Fgk01@}u88qjgx!*h$j ze*Lj!3 z9*y}L7#H1mlZn?56T5ET)qHz&z;dVKHn(9oYxfS{gC)L`9k-v2R7^e7D5T@Ob=qI$ z*q*%zKa6>$VSre3YVa7ThiI%%bAklI;-%UZw{D0@mXz81Jp5D?H7tqiq|(R?H}HCo z1G`-B6cfPSPJq_TBfWG9ssW68C8RK}hd9Ba*lj=RIEeK@Cfqoa*+H%np*TuWdvl z^K$knDlAj{&<|Ehmq#z9rRf)(uT(VN1W3CRW$dxox6Of}3uIwSB=-Z;7$H^Xouf^$ z8X{TJGxyD8;~tWZodQpRV(Z*g!7 zmP4Y{YlfY_K+-GYKSgr9>dnA)g4@^JUhF49rk8pR7*7Qy&bh9RVLNEH$3Zlt@rQDhim=z#$Vm5}Zp;Qjgh;}O<6 z=i6E5%ge`E!=AnGeeb=m`?{-Bb+~v`GDw!nk3Ku(q+p)e9vBusOq_P+#2f=!{E0ftL;dIxHi~50`g*56A`3wNnhe zbcnF;139L?G;i--8ppKR7XT9g&{`O5z6_+q*wY`Tb$a_HW5}@rsjfxT9lnurNw>Z< zast^vyxOZ)DODiS^Iqm>CNNmy{|LbSmRo%v=GA5=6yjnxSQ8%M4TZD-mcvSgkGVt)-dn+Z!jr6!$*q9xl$&gW0FeqqB)Q#ARH_6(}Q|^I2&K-C27B3?-=$^dWS(BT^n$Na0ieyu*x|3fCjb7|6*2&9VbzmS;$F~FDUc@s+A7E5zFmSX zIFCVU_-%02=^`k)@-NUZTGykhmp}Rc>z|lCc)y`A|%IxDTO5- zAXeWUV-}#ts!>XMaZ!YxCo_U`~2Pc83Dq%LSw=12Cj$O+mAvjt{LMNzJD;2>marn8QD%ykR3#`*!%~Ez;nU^W{C1dZE<|3yKRlfFLY< zFS%f}#=wMThE4cDp4KxqO@3t~+7{(1^v3)06=vSM8q}rZte@_*51sqHtMGll@^R(T{x+#JCTBI321}eKh9$ z+D0Of{Uo~*wpo=G}wtG5&Rr!J8M09?GI8R@xo6{Zr$p(inFP6-$G)t|u+YNCg zZQ5;V#Ua5R@hs!6#?K$=@w2<5ghhy-s7+WOFiP4q_8K*4Pk`Cxiz-SHh8CqEV&=O_ z#LcYykk`!zZA1r6+)C?41J0?C&mY%#-KV*i4y5i4Y7rQRO`7ZkgKfk13pl7011I~^ zJne#tnO+}#+r)ZxDOLFt8Ke`r#} zsL{?nxk^d019mLFKQEPVTR-|0+~n8G(Qg9rCugmfmIHWyRnF(^gGcxaxu8%QJ8zHJ zH&j<6prV?wY3CDnVTARGUGJ#aL20K=)36N5)B3M?39L4t+#}17ATuS z8q#3v;21k)vXv~ulh7U&wLadH6yA1c-RHNOrEfDfb?L5oPuV5%&X`--3%nn7;M8j| zCh$H^^K4%1I3iK%TFfzsT3C&)JA4k-X|D7n(Z9z)MQiPkA#vIH@^Fi{lOKLILH|4o z#dIbN&DG$%W$S;M$?|^2B5tL=38Jh>#bJt)?V_;biale*+~c|x8y%lXwB0MJwCa*7 zxsb#%-H-xqTpy}wBHA2FnDtB&Z5>lVKa%m!a*m1w+mUz(oC>(ciP>y(7_%58#?Llx zBn6v}Fea@vWJz9U2%;d_uO{+j9mdlOScr~XbXJ8l&Y>VGjt&R@ixPC07EG&|11zqf zQ%q-9=^amJQJmIdZ1jDKynQ#PhJ0O#m#zslCSgrhEnfMCTzcKnSK(TbqVTePF z+ze8S<-X^7>T#?^*ZY{7n)SDyhA`PA6c6{*iE`Av@-6Tto$!lF%h!6nBbKW1)rP6{ zm8mosr-E|o(Q>Bei*UJEqZ=v2mk8}=zF_6xZt z`AEG<{?5_%sFr%RS5|TUv|aZ)UWKX=654ul3qd$j92qBZI7ZO6rmV7gnnU_plj5V% zKvk7)i!z|a5t=#s)rv<~&{d~-YaB(=c_^K@2XMtiq@Bj+{qj-0?I1pvafx9)Xp!`k zb66jlxLW^6m90JP-abA`721}Y*ko~9DOX}`+-{2+7Ly zV5o-~^?M50_^W)qP9~qYl2jYI9m6+r=$TcX8cpKCw5v;h4Rd9$tR`p!&6)KeeZ{4o z?P%jxr-515UzLkN`(MRvTgDIPH;T8v2QN;#PucdA=-eujbUrp3+-WY>cTReX0{q++ zaBs-$T*6SH>!v<_t-#jq_njSr+ zKX?Z9;9nf2)9+`gqr-X|NSGvDv_I*q0)5OHFID7H{h6G`wVRV;1u4f`vmtD?C;)O& zccggC!xz8>(Q6gZs-k_pl63xB3#j2VE}z6As({kO4Td|!-%LL^5iT;o#6{_%L<)HKSfF$ry4|2+&-3VUI#CBlf_N~1&cI0noGR$uMD0cAPNcr##8WN zAujhal0RvfxsSfaxU&bTwy`;W{#IIS)>ev!?FAKSeZq-HNOE@S7dn(WMf%n za4281z7l7++sx2!Fo)q`O^*7`fK<0aqE9l>?U`RI;(AWeL>l5zD9-DQ^;*+jRYClM zD#(9BiM#8ogS*q=iSqEqvskJXQZ!cntHKIiG+yIG zn&wHe)$4-l5jsvu@5Vwc)Xq3OVTF zfbaDY*$nY4TH=F^5Hz~Xa_qpQrj})``wA?ul2I$G^c>lMF9m<HP}4j%NwFG zmxOVbUH`~$mr!U|Y@2zs()Q%MM4Tz&`zP}USt$;!ON5F13}K=WXlfFQgXLk)QT6qL zd5$GtB}VU7z2oNd(p9w2XRys}39I6%ssh^YI?n@R&E_SCclC#bb81dx_$Df^Pf$_a zc)7UtLHSI`7H3N_ho_vN6;J&BWRc4Vi+42Jr3?B?mEPt9PJGWzu zO5hP8UlVtl;n*>IM{B}SxfG#qmAj;r+S?;h|Yhv4x26bdoM#|Ud03jT5yQB#X~5zt#>K4-ZYA*}ak z^1S`sc5gUyO{?vV;jF_WPr5p}$1dB$bW@DInch__+EJm%WCOZ=14q$t6cwpzd6H$+ z8a52+e6Thuk&&(R1}&ZAgu8Z72)7x-^u7x77i$UK6r-6kN@5~R@lK6gEYvM1v}u^y zg*5lbBwyoWWBO=ziS=jqT~_;&;gpR1kIoDm4NSik(mWU`McHt5xyI)L1H{ zri8<(t#~^p=(seh7GXGMuELeK_$qT(kJ**SE!8u?@k1Ad>qsT6xZ0-z)z-TGo!7o3 z5S(u}^SLq~Ln|mAmIZG3c)@6NR{~kicaH{DLmLSsuU0)D&iN`A##$H9^)XtFkx--J z7aCU?VkDxYzPfd&u_I}69JoBIL#L4w>y%zXVNUsLE;M1{Ot;(!`pG$+y&OZ% zaOBbZTyMW0X~XNm^J{Mm4$7XNIAU)_Wt2`){`6ke8nSUB<6{t>bjN58?e+fYH}Lhr z`|W{oPPa7__3T?wmKwoI(<~S!u7Z@G^(ZT{k&Wh5ym;$H2v3ENcsD~-=)_WKpKug= znytBHf5`P#Z3(^k9`Mg2KTZ>~8TznpL>uktcE45ObB9S(mO81%-1j#&e!%$$`3|6I z91*PRQam4``|);_u#2a%-wCD}x~bCq`%Zw=u4YZ=2&$!mSufR64WIcM$-}g^e#*fuOYC9+Y9?e#oDaz`{9F0hst!Lwn?Zr}EoC z?8*g<{G;8^juU?ZMPDgz3c!@K(@x&GNTF}%16+Yb^JX&m&s!Pvzf=GJBy|}K?DGjJ zyyts7p70G(E74KagXG41NH!2>as?a)HOEhxBCs>fi58e~pKs#)89HyKBVZ z^L0dC3P*BMt#boVZ?AKOldp1b)nAa)6})(OvDwvTsqajp6b0t^;4{IGL4GpSiSLE8 ze`W3s!2g)o>?`-#sTxeTiRMfbdPs)yzj|mgU!Ue>M(Fi08At>IF^4 z!l_pRG~d;bNAX|h>R$v}xHNEpu6-`Q>||Rf2UOp1EO_Wr4K>rJz&HV~S1&r!K;ezR zv2`5V%c#2ayR?+RI3W7QKi^@1oSXw%fk^SJfBFJ9n_7TXSojo0v)^V5S&oegpDhZT zWbHTNaiq43HHkXNbghmtJ~QB!UT9s9Ly_Lh$b|oxZxv|tip`xC&SvIk(ve`OmI9#k zGbB`}ebFVAc+_RDvl+1R71+ob7V5jjn{9`^g`MW&`E9~Iyw_9W|JjDWq>l3xl#V;A zz=!IVo9RDzS*TI+{+sL1BlSu>h0^(i1l>maG_S)xgTba-BkcFu@%>~!My@F?r)wDTd13ny0->rcrDh6dlY;P>5Y_IVs&5yo#K9@PxpzujX z_c`Y1+`4*fCE?70J zQF5whT~LM#CNMw`-_@m%HV>Gik>rZrl=(Dt9&ue=XozYchDTrLYWxH`Or?0U>gZPX z^GV0mh&axfJ3h+B_w-%pqYJ2l6-w7truH+StkJHCa@A!)}wQQS~4URByeDGoOc@Fjd%@fkweC-G1x|NX95rMvt zNpRuane*c?15`<9l|*TUa&nDcP7-hP=D^qWHm{CfvK3IZk7D*4Zz~+8ZUb-O%lZtM z;q!fxit~YIw1;!q8GVU9iJNJViKgd2eySlfQI;Ev?eOqFLhq87h zeaTHYEmT+}hE8OQ9?(ZH!g(s^x#<}02qrid#>lMIqd;n@&B*mhRP;T_M73aN>f&d! zNhaQ=^kg4fSx*L>>{u~~d%{I(k>0OpLwRK`IcV(5L%7-4v`&4VtfuScifOJu8e@w? zOqH!3f;+L&GPr5Kow5@4kzWvIENCZrpR`FxORH$=)fl`Z6`-f~5#eeU-Jyno*{0?9 zDT+!WUKsb4uM}$CXJP&zJHFH)pqOAjFzDj(B^I{=GKnq9K;x>O>h0)H(y%gepKF50 zWnhyQJcAn;hPH9p5VcxVOI57eIes;X{KB&N&0_IYSSNbV&IkOcW=(|2xgaL;x%s5` zhH|9wx!xV-50B-`?|$dC?x3eV&MvfG|2QOMzdXX``4L7KIw9_|5gaqk6H+;kNY9?b>f*Bn(=P9dUl=ib(yM;o&iM?b0VNrXNh zu-zF`)^1z1zE~w}Nk5hb3yX0@;jy#^i0u7;Tr1T%iR{r_4zUR+ei7`#5{yGtVa{PS zoTpr%bSz%F(ALMH3L^kb$SPC;RI=(;nvp<8lRgLHwo+e{V}?6wrgOgSz1 z_px+=u}}KAvJ88cT@rxwrxVYZ-Csj(c%Rn!L{3w(|4#}6?8uq_Arak+4UR}LjDmkcomHJw2q3CGbG|&0+^kZoIsC4U_ zU+6u9#wPbnMfHX^h3GoZM;E<^epr-%ms5zJe_WkG^mf|a1l0OEpL7C2jH=J9{P1~o z5bXfuY^WGK>&R<~O3&bf$VH;C+)8P68ywzdtj{Ayvi=1@(6$GS-LmR5XtIot${t%;CMm(dldr#Vp5LO_ za_9w;R{FDc=)M(X6SO9F$A(6-cgh7gsW1o2jr)CONuZl47QK$nnNztHUE7CTBfwm` zU3GqYLDg9XhN>2vuojnc{U%nw>Stx-GnBYB)H`UraV;(;Xs4S?d7u)PGKc3ZQhNG+ zIO+$5D!MP=5whwgDUqsp*qbPe#7Cm=oxLlphSch9F|OGagBj;n<(MVIN8MR|HDx?v zVC^wAt`4f+&ve;Fk@Oe;q+1h)Ab$KX)VlFvy7uv#rNtE{5)5H{;{KMo>91@K*Y1O# zXXWSXzwA>DJf3k`@LiyTvHW}~H$z=;vfk6Rfd%^khGxP?C4IC^Ro4~KLRZ-#v7Fm) zV`ELRxEvFV_%S~@wNmX~5ehOK5gP+@20sZZvBXhV3GQuEQ%$dZjZS#A+;I00j@Z;Z03I$ys*F{jO+X>0)&;Cm-|UT)xF zfEqYWp6Sa0Yp2l^;X>S5)_jT-2x}XZ5+|2_nm;Y3g1SyZPCo@$Ll{@C7emJv@2v<+ zJlu3W={1FaZGQMCjSm!9^(Ddsip4#dfIFd=r?$Jx5Hi zMbE?49^Ii5S!Z1n9K=ZE^@ePLs77}vsKDU zmo?;^!ntg~Qv=uDU#hEtV+bX$l*mK{%CTHj%*QubR4ghah7Wa4xLS8U&u=HV&kentrN*=^dM&M;bXb5uW0io)z@+SJX0*4Vpe%N5f9SZ z4Tsi-MO$rmB-nKMho+)+0%TyOgO6%!s-!cb(qiw8?~+~zpLz^ORSB09bm)#+PwtFQ z(C9#|f+@M9a~!_j7QX`kB4$nn9}`wJ82!+vdvKMV^-ZUPT$k;<=K|dcb5s`)cUM(g zd^tt)_AgX*C6JPgXH8OOq2P>F=#Os*(-4pIR9yjoeU9#QJlR#Yn{<~?b>AUnvv?xg zQm08WC|!OQ-_mLRtbb(OgQdT{Ml3EDY}m1@i!8|s@TTHPtVmldukIAh&T5C*KVMP3 z7+U5KB~=2zQ#y?_xo@p%gtRt^V4qjVx8m4nA-8EXWGCW%n1gq&eM@eUpl7@=WBK0& z;`E|>!F#=3$m6h!P#+X7ia#{=Ou#kuUiQW4Kr$(*E57wa7j_4T8GH*2)>7A-%yhwQ z^u@w{aBv|k5ICqUKx0`XOWU!W)}ITj83&DC0p zA5wliZKq_1WADlT_)0hpKJX1WxvjeIO>R4;yUBHoyzFrAKLL9&TnyY@0r+hmLlq?cg_A7>(gh4(Q;5#RV%(tcxZnKK(CD1OC0RrQ zVFmv?Jf6(<5aKoWg^lseZHv1#*7aj*a;|IeUSzymwFD;(-}LBJT81cYPm!bS6X(@5+*LGQF`hnVz5#^uF6v zm2E?n;#V~6zp>%J>5KZOzO#a^hWw`)GNrmMtT?l))GR(1$dp1~uxHM~Sm~Uu!-HmX zkF^TE8(0>(-0pwUg1$5ORsB3{u2)SDs_$VTQx5PiT<3nKcus8`>g$wU>rgqYWYG;` z#S0uH;>v>wN8ShZCiX7IqQB#UDV{$5a}I!N_5uawEvCciFR7G+A8@?5>*OhKTvE*) z;9MjKG7(-H#sH{CpG?e6E_>-w=xPGSSs+1NIualnpq&1qfAHfQ7pPQhJYF8M$Ro}Q zmcYSES49lsv8%aRFjeFeO=6|;uBQhTy~-WTOx^vCE{erhpABW{eeX5>+HzsLq;l{> z*U=oFXNKJN4gKT{<#xaIJzul`?Wz8zrLt7Z*yoAWL5bb2?v#Ev_pmY zbXm!ss-y?UgoXDL)I@$StqIq+D>CbM=z?1icIvtw&t1H(54EMB-U5dq1y5BcNZg*4 z9Aa=G9_J|zpE->&t^FANcrTYK-+XMXgMn}y^=ggyW;}r&*{+edo~FYExD_iV$cC}uTFDmKBJ(U%siQ-O5O775% za4Z;YC^IE1M$1X)u;|xzb`QIbM8sBq5GlO$kX5M64s`Z`HxzC36qg)tjW0QJZ*bzv z$S_Il=bh9M3R*{2x2lH2fF=U3KgpvEoCBUYe8_VCa2tL@~nK^oT z-EN9y&3MCN`)aIaCLDo6@63(2KT?AKE^L#C0~t?CHqe5quO;!<~ndaLw9-3pUZG0Zm3(P;oN^9X<3nahOB zkyD4&u&soA!5BBoEfjsUw;fl+SgJMWXwTzhpi7>xng2}SyEBf$b2RHzNdY^T6*Unk zpskKP8prP&(3@rb?xgrotA0b+mw)ej-(0D3$AU?;b5xGJ)7Pu&<$Fiq2gTF%0iKmV zPp)^3#La|vM6qT^5_yGLPjR1z9wG@4)s4mwW9={o9um7X$UYYn>vhIhk|-9XBkH~^ zDa$-gH@mu`^PfCyTA7K_58(Qz!9?$X`c+CZp3i0Oex8CO>?p7`aFg#h3c}^#p1JYC zt5>@O!Wjk{BTf?ei>SGnON+S=4D`_4o?H`n9X_@Z!iCo-^rm@ybfEsm5sU=2vE*+b zne`!c+;@wC*3M|ZVeB%*80Nf#^*mwLwbbBSd8C+^nWpQRG63Ujsb9lvvlg$d-iAzb z$@^c49sbf&^Y>8-|0tzXo-uU1Bxkl>7G}CW`K4Df!l5rwF{DRC;b^D7(lbTGS$)|M zmR)>@8z`Zb{2@4sv2lh|l!p5TiEm+5qtrgPF`FngXY2HBp;Q z{iDy|p>2eOi~Cy$mnkx7qpPzu7a^?eq?%*u?qiRI-}VyfS-PsBORkKC2_v!^D<0?R zzovWFI68(OkLnZgB@Swr?hZ+ebEaATrMn$YS8 zKX$_bMQZQOa_ac550IChV^PK~vMWJz=)q?k(5cT=O~g8nhLouS9u- zC^nQP4Sx{j<(-c=g2oX8W=A8K5l{oth69V=j-u8r`X=n&*`UR z(10#x0Fn81w!3K%971`9NkIk)EvL#S~xNXznd2fL~I zD<}m|eVo6SoU)ppmgt~sxwHz#IJgd(M`^MDpeF^#jjE#v^kRCD#!9mL^;w2%bZ|)YG;q zrJQ#{aWjgPe;Y-uM4adAth)Psq(eUj1G9iS7ZX3>fr~><-G#0+S+ZSk1loj6aYh2q!{Dhm$ zEIX@Lc~FU_8T*V&m*LFMktRf>5QZ4>1m!-~vJ>G<(qgQTT^Xo)L-g7(Gsb4dL&rOz zQ3+)o7Q^Oa=SVe0(0+F@O=2c#ZZ0@4xJdf4hdovPF2d=D0X;2O zA-;vhbNGu`xXd=-aWv;0QRSKGfLmb2gw7Bqe%{ageqbs$78_oeOYmr(?eXpz7wfB+ zkHxI~s)?$>p3@SpevhNM?q$=BRG(0O*RHVZD$4mtjAvv^O z?QL!dYc%x6+`!BRknV+XNA84S(LHntNd73vyn>>SbcT{5+HqN#Ciop&HWu`TN~G=C zZjv)?eQ&QY9W+X)wG1nobnD>v=+VMgtq z*o>8D>^9~3(+5kkNveM1q$;G0y&j^Z_;Y!XcWn30bk%k`J-<&@O;OYi!O<4MT=I=8 zoPMiBIw~b}#Q!wlY*WK&wKkZ~y=S)i;O&RUN=xtdOgAd1Dqzzx=z6#Oj>NMD5lhXT zz>OHPoxF^@F&v1N7^vxR^iod34rdU!x(#3EQI;Sln&^6J@@;IAgk^JS;iRA6PQBqvv>S?4|e`=HKs0Gz6V-ge|+EHlGk-}tB}F+qo?jl)tj7CW%tB+ zDNZMC@36r67oiW@!EseSW8O(PWFrJ-gBiFJ#4pe&y&&AC5G664C-wS$F_mfqH`k86 z+)HIpGDu|G$yOn)x|MzM104WOh8<$>;QMDq=F_UO$x>g9u*ycZxT{YDIsUpBbP`+n{j{cZfy9oTtR*jq|g1w2Zzl@CX^{f$VB=USlu^J_#oDZu%L0f zy~p?@XgC14i#Hu`(4bxP%WT&($ zyjr9L1PzAhI;AGQ_|y93WqsX1Afzz*Xkpvb4p=jY7;lJrVA+p+O-^Sy?G+n-W(jB0 zj=ZfWYu)H8*4mJg;jGD=nhyvL^&Vce1GsY5UJ+-TiN11T9UOKpT?Lg0}yi zH~^3RR&NLmvkM=!X@#v8v^%REaegcXjcTuzZ>%Bl=GcnW@;6GnQO6Tw7B9r9$?-f* z9t8bE*_g`s!a-&@VTLz;SLrptF1Wp@jFmX?EUJ)XALt-iN@vo)rmnZa5kG38d45oh zO#Qk28KB#c&&8ZSI!zBJqMt~7%s#2=BZD!n!P z3uz>CJr&9*qYo8#+P@l$*W_!|xT=iThR#=-1{5{oRUGP-%M2d(P>%-qk5HQg_#GaU$S+3n}MscH*UK#!bTBwC?|-#*;Es-0TGd-x6I z_$k3vDAiC8W6A2hKuyr*yiGD_R|?!Ope3~A9>g=24x;G)gm25|4#On0@>ieZt+I} zmgWGZh>d}8OAJA)M_+{~K9^Awf4SFNMKp|l`}cKFA={=+VPQ<@fP>Cj8LAY6_$E#? z-R4*VyCg^yAgIM45HCBQeJ#iga;)WVjt3-3tfUT>reg>UXh6kXME7!0|%nel{s>SG@9@{QhkEMnR zp3flb=W9rCpN)j>jEYm+kfkT}$k9ZWWZ*Y^c*4DLO^*JVI)`AG$o?}diFaS_xRc{G z(2Br+e(V3?v`^N3bnH}lB(l@OGh}SM@4fo0+PDyzG3LcTD?Lh=CNV7(errNq{1tfk z9Tpx43;T)`77m#XYnz4d82SMY_LXEVzxupBeVfc;s-Hti&G(IJmcjxOHGzVc1wtV( z!H+&-(Pc4~&Vsj;Tc*c!c&x=S+ZH~a#>A;t&u%&SOH<&G`TjMoHIpya^p&fESEObc zZiFwa@-|JJRt%(EeR*`hdIHZTrcGfMe24yJZO}NlG~{TaIE}BJj<0%^SYPn!9GRu1&N1lRVB z-7R!Je#CO#Uv^$D2VZKQnL7;1QC13|06HoHUWy8?_LCAwJfAzA+P(n zK4~-E$b-QFjQqy6Q!=N#E0_hH=vylSYpFg0=IZ;Z*py z|0DU|^V<}`v$eG)Yby%8ayL$t0+a` zv{S%}@gTcmv(F}tw^^jwd`CX|ADIJtsZT_I{mN`{(PjV^9x|xS;6MWEx`LjGMTbz9 zpS&a6-+DHu7)}!yl($E@Iilr~1ar_QTsnb*(eD2)4hye_d|ZyeZCnIb^7kaq{e1B( zJeAKw@48v@6GEN$OZ20ywj#@Y3hKBPZj$&V%l+W{Tpa7Ob+fWX;6(oirNU}<2t-^q z656xBP(zZc*Nb$u!ouA7TiJZu=E(>47ISlr8}%9NZ~hS2a3JkPfUx{_?#yo6eer$* zKV%S~Gf1Yc?8sLd<*#{}cFbm;lzD&otKy)&K105u+g*ViR<00mP@;Krg6Y9*|W@Ps5fksUu-F`2!>4sllt9?NxLWARFj^+cubsV>4BD}cOVnDgZrw+mxQWRv&e}eH z+;}(t-$h~JVVadT%v{qX*KK1?#c$W+^pO$(drOTMc~|^}a8$Eqa3V;+xe)7iDUqbR z)})LQ-`SyM0atAE=kZa2Th?s9j8;o_xvZvEHM(+OWA*NYlapM?ePK5l;C%i^i~V|7 z(#J}94qO}4W_%8*EP31FPDvJ>$b=Yi_?(a8itFP^Sd$E|R_fgeKG87LJyxoDUskNu z@&2Xr!^ap%N~y3b?aU7$1eRQD$36px`5~qL%|@4*o_+?#i+QYSx*!8v%7VxJatNOS^#>D@B0NCLzZ~y=R literal 0 HcmV?d00001 diff --git a/docs/management/connectors/images/swimlane-params-test.png b/docs/management/connectors/images/swimlane-params-test.png new file mode 100644 index 0000000000000000000000000000000000000000..c0e02c2c7b18f2fa4118e10d31d0a7c6447d4c19 GIT binary patch literal 175258 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 literal 0 HcmV?d00001 diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index ea4fa46d3e8082..033b1c3ac150ea 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -6,6 +6,7 @@ include::action-types/teams.asciidoc[] include::action-types/pagerduty.asciidoc[] include::action-types/server-log.asciidoc[] include::action-types/servicenow.asciidoc[] +include::action-types/swimlane.asciidoc[] include::action-types/slack.asciidoc[] include::action-types/webhook.asciidoc[] include::pre-configured-connectors.asciidoc[] diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 5b4a197eea4620..b19e89a599840b 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -19,7 +19,7 @@ Table of Contents - [Usage](#usage) - [Kibana Actions Configuration](#kibana-actions-configuration) - [Configuration Options](#configuration-options) - - [Adding Built-in Action Types to allowedHosts](#adding-built-in-action-types-to-allowedhosts) + - [**allowedHosts** configuration](#allowedhosts-configuration) - [Configuration Utilities](#configuration-utilities) - [Action types](#action-types) - [Methods](#methods) @@ -54,6 +54,9 @@ Table of Contents - [`subActionParams (getFields)`](#subactionparams-getfields-2) - [`subActionParams (incidentTypes)`](#subactionparams-incidenttypes) - [`subActionParams (severity)`](#subactionparams-severity) + - [Swimlane](#swimlane) + - [`params`](#params-3) + - [| severity | The severity of the incident. | string _(optional)_ |](#-severity-----the-severity-of-the-incident-----string-optional-) - [Command Line Utility](#command-line-utility) - [Developing New Action Types](#developing-new-action-types) - [licensing](#licensing) @@ -102,8 +105,8 @@ This module provides utilities for interacting with the configuration. | ensureUriAllowed | _uri_: The URI you wish to validate is allowed | Validates whether the URI is allowed. This checks the configuration and validates that the hostname of the URI is in the list of allowed Hosts and throws an error if it is not allowed. If the configuration says that all URI's are allowed (using an "\*") then it will never throw. | No return value, throws if URI isn't allowed | | ensureHostnameAllowed | _hostname_: The Hostname you wish to validate is allowed | Validates whether the Hostname is allowed. This checks the configuration and validates that the hostname is in the list of allowed Hosts and throws an error if it is not allowed. If the configuration says that all Hostnames are allowed (using an "\*") then it will never throw | No return value, throws if Hostname isn't allowed . | | ensureActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Throws an error if the actionType is not enabled | No return value, throws if actionType isn't enabled | -| isRejectUnauthorizedCertificatesEnabled | _none_ | Returns value of `rejectUnauthorized` from configuration. | Boolean | -| getProxySettings | _none_ | If `proxyUrl` is set in the configuration, returns the proxy settings `proxyUrl`, `proxyHeaders` and `proxyRejectUnauthorizedCertificates`. Otherwise returns _undefined_. | Undefined or ProxySettings | +| isRejectUnauthorizedCertificatesEnabled | _none_ | Returns value of `rejectUnauthorized` from configuration. | Boolean | +| getProxySettings | _none_ | If `proxyUrl` is set in the configuration, returns the proxy settings `proxyUrl`, `proxyHeaders` and `proxyRejectUnauthorizedCertificates`. Otherwise returns _undefined_. | Undefined or ProxySettings | ## Action types @@ -113,17 +116,17 @@ This module provides utilities for interacting with the configuration. The following table describes the properties of the `options` object. -| Property | Description | Type | -| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | -| id | Unique identifier for the action type. For convention, ids starting with `.` are reserved for built in action types. We recommend using a convention like `.mySpecialAction` for your action types. | string | -| name | A user-friendly name for the action type. These will be displayed in dropdowns when chosing action types. | string | -| maxAttempts | The maximum number of times this action will attempt to execute when scheduled. | number | -| minimumLicenseRequired | The license required to use the action type. | string | +| Property | Description | Type | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | +| id | Unique identifier for the action type. For convention, ids starting with `.` are reserved for built in action types. We recommend using a convention like `.mySpecialAction` for your action types. | string | +| name | A user-friendly name for the action type. These will be displayed in dropdowns when chosing action types. | string | +| maxAttempts | The maximum number of times this action will attempt to execute when scheduled. | number | +| minimumLicenseRequired | The license required to use the action type. | string | | validate.params | When developing an action type, it needs to accept parameters to know what to do with the action. (Example `to`, `from`, `subject`, `body` of an email). See the current built-in email action type for an example of the state-of-the-art validation.

Technically, the value of this property should have a property named `validate()` which is a function that takes a params object to validate and returns a sanitized version of that object to pass to the execution function. Validation errors should be thrown from the `validate()` function and will be available as an error message | schema / validation function | -| validate.config | Similar to params, a config may be required when creating an action (for example `host` and `port` for an email server). | schema / validation function | -| validate.secrets | Similar to params, a secrets object may be required when creating an action (for example `user` and `password` for an email server). | schema / validation function | -| executor | This is where the code of an action type lives. This is a function gets called for executing an action from either alerting or manually by using the exposed function (see firing actions). For full details, see executor section below. | Function | -| renderParameterTemplates | Optionally define a function to provide custom rendering for this action type. | Function | +| validate.config | Similar to params, a config may be required when creating an action (for example `host` and `port` for an email server). | schema / validation function | +| validate.secrets | Similar to params, a secrets object may be required when creating an action (for example `user` and `password` for an email server). | schema / validation function | +| executor | This is where the code of an action type lives. This is a function gets called for executing an action from either alerting or manually by using the exposed function (see firing actions). For full details, see executor section below. | Function | +| renderParameterTemplates | Optionally define a function to provide custom rendering for this action type. | Function | **Important** - The config object is persisted in ElasticSearch and updated via the ElasticSearch update document API. This API allows "partial updates" - and this can cause issues with the encryption used on specified properties. So, a `validate()` function should return values for all configuration properties, so that partial updates do not occur. Setting property values to `null` rather than `undefined`, or not including a property in the config object, is all you need to do to ensure partial updates won't occur. @@ -133,15 +136,15 @@ This is the primary function for an action type. Whenever the action needs to ex **executor(options)** -| Property | Description | -| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| actionId | The action saved object id that the action type is executing for. | -| config | The action configuration. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type. | -| secrets | The decrypted secrets object given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the secrets object before being passed to the executor, define `validate.secrets` within the action type. | -| params | Parameters for the execution. These will be given at execution time by either an alert or manually provided when calling the plugin provided execute function. | -| services.scopedClusterClient | Use this to do Elasticsearch queries on the cluster Kibana connects to. Serves the same purpose as the normal IClusterClient, but exposes an additional `asCurrentUser` method that doesn't use credentials of the Kibana internal user (as `asInternalUser` does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API instead.| -| services.savedObjectsClient | This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled). | -| services.log(tags, [data], [timestamp]) | Use this to create server logs. (This is the same function as server.log) +| Property | Description | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| actionId | The action saved object id that the action type is executing for. | +| config | The action configuration. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type. | +| secrets | The decrypted secrets object given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the secrets object before being passed to the executor, define `validate.secrets` within the action type. | +| params | Parameters for the execution. These will be given at execution time by either an alert or manually provided when calling the plugin provided execute function. | +| services.scopedClusterClient | Use this to do Elasticsearch queries on the cluster Kibana connects to. Serves the same purpose as the normal IClusterClient, but exposes an additional `asCurrentUser` method that doesn't use credentials of the Kibana internal user (as `asInternalUser` does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API instead. | +| services.savedObjectsClient | This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled). | +| services.log(tags, [data], [timestamp]) | Use this to create server logs. (This is the same function as server.log) | ### Example @@ -262,16 +265,16 @@ The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kib The following table describes the properties of the `incident` object. -| Property | Description | Type | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------- | -| short_description | The title of the incident. | string | -| description | The description of the incident. | string _(optional)_ | +| Property | Description | Type | +| ----------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- | +| short_description | The title of the incident. | string | +| description | The description of the incident. | string _(optional)_ | | externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ | -| severity | The severity in ServiceNow. | string _(optional)_ | -| urgency | The urgency in ServiceNow. | string _(optional)_ | -| impact | The impact in ServiceNow. | string _(optional)_ | -| category | The category in ServiceNow. | string _(optional)_ | -| subcategory | The subcategory in ServiceNow. | string _(optional)_ | +| severity | The severity in ServiceNow. | string _(optional)_ | +| urgency | The urgency in ServiceNow. | string _(optional)_ | +| impact | The impact in ServiceNow. | string _(optional)_ | +| category | The category in ServiceNow. | string _(optional)_ | +| subcategory | The subcategory in ServiceNow. | string _(optional)_ | #### `subActionParams (getFields)` @@ -311,20 +314,20 @@ The [Jira user documentation `params`](https://www.elastic.co/guide/en/kibana/ma The following table describes the properties of the `incident` object. -| Property | Description | Type | -| ----------- | ---------------------------------------------------------------------------------------------------------------- | --------------------- | -| summary | The title of the issue. | string | -| description | The description of the issue. | string _(optional)_ | +| Property | Description | Type | +| ----------- | ------------------------------------------------------------------------------------------------------- | --------------------- | +| summary | The title of the issue. | string | +| description | The description of the issue. | string _(optional)_ | | externalId | The ID of the issue in Jira. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ | -| issueType | The ID of the issue type in Jira. | string _(optional)_ | -| priority | The name of the priority in Jira. Example: `Medium`. | string _(optional)_ | -| labels | An array of labels. Labels cannot contain spaces. | string[] _(optional)_ | -| parent | The ID or key of the parent issue. Only for `Sub-task` issue types. | string _(optional)_ | +| issueType | The ID of the issue type in Jira. | string _(optional)_ | +| priority | The name of the priority in Jira. Example: `Medium`. | string _(optional)_ | +| labels | An array of labels. Labels cannot contain spaces. | string[] _(optional)_ | +| parent | The ID or key of the parent issue. Only for `Sub-task` issue types. | string _(optional)_ | #### `subActionParams (getIncident)` -| Property | Description | Type | -| ---------- | --------------------------- | ------ | +| Property | Description | Type | +| ---------- | ---------------------------- | ------ | | externalId | The ID of the issue in Jira. | string | #### `subActionParams (issueTypes)` @@ -333,20 +336,20 @@ No parameters for the `issueTypes` subaction. Provide an empty object `{}`. #### `subActionParams (fieldsByIssueType)` -| Property | Description | Type | -| -------- | -------------------------------- | ------ | +| Property | Description | Type | +| -------- | --------------------------------- | ------ | | id | The ID of the issue type in Jira. | string | #### `subActionParams (issues)` -| Property | Description | Type | -| -------- | ----------------------- | ------ | +| Property | Description | Type | +| -------- | ------------------------ | ------ | | title | The title to search for. | string | #### `subActionParams (issue)` -| Property | Description | Type | -| -------- | --------------------------- | ------ | +| Property | Description | Type | +| -------- | ---------------------------- | ------ | | id | The ID of the issue in Jira. | string | #### `subActionParams (getFields)` @@ -360,10 +363,10 @@ The [IBM Resilient user documentation `params`](https://www.elastic.co/guide/en/ ### `params` -| Property | Description | Type | -| --------------- | -------------------------------------------------------------------------------------------------- | ------ | +| Property | Description | Type | +| --------------- | ------------------------------------------------------------------------------------------------- | ------ | | subAction | The subaction to perform. It can be `pushToService`, `getFields`, `incidentTypes`, and `severity. | string | -| subActionParams | The parameters of the subaction. | object | +| subActionParams | The parameters of the subaction. | object | #### `subActionParams (pushToService)` @@ -374,13 +377,13 @@ The [IBM Resilient user documentation `params`](https://www.elastic.co/guide/en/ The following table describes the properties of the `incident` object. -| Property | Description | Type | -| ------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------- | -| name | The title of the incident. | string _(optional)_ | -| description | The description of the incident. | string _(optional)_ | +| Property | Description | Type | +| ------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------- | +| name | The title of the incident. | string _(optional)_ | +| description | The description of the incident. | string _(optional)_ | | externalId | The ID of the incident in IBM Resilient. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ | -| incidentTypes | An array with the IDs of IBM Resilient incident types. | number[] _(optional)_ | -| severityCode | IBM Resilient ID of the severity code. | number _(optional)_ | +| incidentTypes | An array with the IDs of IBM Resilient incident types. | number[] _(optional)_ | +| severityCode | IBM Resilient ID of the severity code. | number _(optional)_ | #### `subActionParams (getFields)` @@ -394,6 +397,36 @@ No parameters for the `incidentTypes` subaction. Provide an empty object `{}`. No parameters for the `severity` subaction. Provide an empty object `{}`. +--- +## Swimlane + + +### `params` + +| Property | Description | Type | +| --------------- | ---------------------------------------------------- | ------ | +| subAction | The subaction to perform. It can be `pushToService`. | string | +| subActionParams | The parameters of the subaction. | object | + + +`subActionParams (pushToService)` + +| Property | Description | Type | +| -------- | ------------------------------------------------------------------------------------------------------------- | --------------------- | +| incident | The Swimlane incident. | object | +| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }`. | object[] _(optional)_ | + + +The following table describes the properties of the `incident` object. + +| Property | Description | Type | +| ----------- | -------------------------------- | ------------------- | +| alertId | The alert id. | string _(optional)_ | +| caseId | The case id of the incident. | string _(optional)_ | +| caseName | The case name of the incident. | string _(optional)_ | +| description | The description of the incident. | string _(optional)_ | +| ruleName | The rule name. | string _(optional)_ | +| severity | The severity of the incident. | string _(optional)_ | --- # Command Line Utility diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts index 10955af2f3b13d..5feb47ea6c962b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts @@ -21,6 +21,7 @@ const ACTION_TYPE_IDS = [ '.pagerduty', '.server-log', '.slack', + '.swimlane', '.teams', '.webhook', ]; diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.ts b/x-pack/plugins/actions/server/builtin_action_types/index.ts index 551d3d02ff05de..07859cba4c3719 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts @@ -12,6 +12,7 @@ import { Logger } from '../../../../../src/core/server'; import { getActionType as getEmailActionType } from './email'; import { getActionType as getIndexActionType } from './es_index'; import { getActionType as getPagerDutyActionType } from './pagerduty'; +import { getActionType as getSwimlaneActionType } from './swimlane'; import { getActionType as getServerLogActionType } from './server_log'; import { getActionType as getSlackActionType } from './slack'; import { getActionType as getWebhookActionType } from './webhook'; @@ -65,6 +66,7 @@ export function registerBuiltInActionTypes({ ); actionTypeRegistry.register(getIndexActionType({ logger })); actionTypeRegistry.register(getPagerDutyActionType({ logger, configurationUtilities })); + actionTypeRegistry.register(getSwimlaneActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getServerLogActionType({ logger })); actionTypeRegistry.register(getSlackActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities })); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts index 3161e97583b72f..aa439787ad96fa 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts @@ -25,7 +25,7 @@ import { JiraSecretConfigurationType, JiraExecutorResultData, ExecutorSubActionGetFieldsByIssueTypeParams, - ExecutorSubActionGetIssueTypesParams, + ExecutorSubActionCommonFieldsParams, ExecutorSubActionGetIssuesParams, ExecutorSubActionGetIssueParams, ExecutorSubActionGetIncidentParams, @@ -137,7 +137,7 @@ async function executor( } if (subAction === 'issueTypes') { - const getIssueTypesParams = subActionParams as ExecutorSubActionGetIssueTypesParams; + const getIssueTypesParams = subActionParams as ExecutorSubActionCommonFieldsParams; data = await api.issueTypes({ externalService, params: getIssueTypesParams, diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts index a81dfaeef8175a..eb2f540deaa9ad 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts @@ -25,14 +25,6 @@ export const ExternalIncidentServiceSecretConfigurationSchema = schema.object( ExternalIncidentServiceSecretConfiguration ); -export const ExecutorSubActionSchema = schema.oneOf([ - schema.literal('getIncident'), - schema.literal('pushToService'), - schema.literal('handshake'), - schema.literal('issueTypes'), - schema.literal('fieldsByIssueType'), -]); - export const ExecutorSubActionPushParamsSchema = schema.object({ incident: schema.object({ summary: schema.string(), diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts index f6462bac9d83e7..9430d734287d33 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts @@ -155,12 +155,12 @@ describe('Jira service', () => { ).toThrow(); }); - test('throws without username', () => { + test('throws without email/username', () => { expect(() => createExternalService( { - config: { apiUrl: 'test.com' }, - secrets: { apiToken: '', email: 'elastic@elastic.com' }, + config: { apiUrl: 'test.com', projectKey: 'CK' }, + secrets: { apiToken: 'token' }, }, logger, configurationUtilities @@ -168,12 +168,12 @@ describe('Jira service', () => { ).toThrow(); }); - test('throws without password', () => { + test('throws without apiToken/password', () => { expect(() => createExternalService( { - config: { apiUrl: 'test.com' }, - secrets: { apiToken: '', email: undefined }, + config: { apiUrl: 'test.com', projectKey: 'CK' }, + secrets: { email: 'elastic@elastic.com' }, }, logger, configurationUtilities diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts index 89a5551554c4a7..74d53901d55d91 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts @@ -16,10 +16,10 @@ import { ExecutorSubActionGetIncidentParamsSchema, ExecutorSubActionHandshakeParamsSchema, ExecutorSubActionGetCapabilitiesParamsSchema, - ExecutorSubActionGetIssueTypesParamsSchema, ExecutorSubActionGetFieldsByIssueTypeParamsSchema, ExecutorSubActionGetIssuesParamsSchema, ExecutorSubActionGetIssueParamsSchema, + ExecutorSubActionCommonFieldsParamsSchema, } from './schema'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { Logger } from '../../../../../../src/core/server'; @@ -124,8 +124,8 @@ export type ExecutorSubActionGetCapabilitiesParams = TypeOf< typeof ExecutorSubActionGetCapabilitiesParamsSchema >; -export type ExecutorSubActionGetIssueTypesParams = TypeOf< - typeof ExecutorSubActionGetIssueTypesParamsSchema +export type ExecutorSubActionCommonFieldsParams = TypeOf< + typeof ExecutorSubActionCommonFieldsParamsSchema >; export type ExecutorSubActionGetFieldsByIssueTypeParams = TypeOf< @@ -157,12 +157,12 @@ export interface HandshakeApiHandlerArgs extends ExternalServiceApiHandlerArgs { export interface GetIssueTypesHandlerArgs { externalService: ExternalService; - params: ExecutorSubActionGetIssueTypesParams; + params: ExecutorSubActionCommonFieldsParams; } export interface GetCommonFieldsHandlerArgs { externalService: ExternalService; - params: ExecutorSubActionGetIssueTypesParams; + params: ExecutorSubActionCommonFieldsParams; } export interface GetFieldsByIssueTypeHandlerArgs { diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts index 9095780fea17c2..9f76a236cacd5e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts @@ -25,14 +25,6 @@ export const ExternalIncidentServiceSecretConfigurationSchema = schema.object( ExternalIncidentServiceSecretConfiguration ); -export const ExecutorSubActionSchema = schema.oneOf([ - schema.literal('getIncident'), - schema.literal('pushToService'), - schema.literal('handshake'), - schema.literal('incidentTypes'), - schema.literal('severity'), -]); - export const ExecutorSubActionPushParamsSchema = schema.object({ incident: schema.object({ name: schema.string(), diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index 59b0803d189cdd..6fec30803d6d79 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -24,14 +24,6 @@ export const ExternalIncidentServiceSecretConfigurationSchema = schema.object( ExternalIncidentServiceSecretConfiguration ); -export const ExecutorSubActionSchema = schema.oneOf([ - schema.literal('getFields'), - schema.literal('getIncident'), - schema.literal('pushToService'), - schema.literal('handshake'), - schema.literal('getChoices'), -]); - const CommentsSchema = schema.nullable( schema.arrayOf( schema.object({ diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/api.test.ts new file mode 100644 index 00000000000000..1e633e21758084 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/api.test.ts @@ -0,0 +1,142 @@ +/* + * Copyright 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 { api } from './api'; +import { ExternalService } from './types'; +import { + apiParams, + externalServiceMock, + recordResponseCreate, + recordResponseUpdate, +} from './mocks'; +import { Logger } from '@kbn/logging'; + +let mockedLogger: jest.Mocked; + +describe('api', () => { + let externalService: jest.Mocked; + + beforeEach(() => { + externalService = externalServiceMock.create(); + }); + + describe('pushToService', () => { + test('it pushes a new record', async () => { + const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } }; + const res = await api.pushToService({ + externalService, + logger: mockedLogger, + params, + }); + + expect(externalService.createComment).toHaveBeenCalled(); + expect(externalService.createRecord).toHaveBeenCalled(); + expect(externalService.updateRecord).not.toHaveBeenCalled(); + + expect(res).toEqual({ + ...recordResponseCreate, + comments: [ + { + commentId: '123456', + pushedDate: '2021-06-01T17:29:51.092Z', + }, + { + commentId: '123456', + pushedDate: '2021-06-01T17:29:51.092Z', + }, + ], + }); + }); + + test('it pushes a new record without comment', async () => { + const params = { + ...apiParams, + incident: { ...apiParams.incident, externalId: null }, + comments: [], + }; + const res = await api.pushToService({ + externalService, + logger: mockedLogger, + params, + }); + + expect(externalService.createComment).not.toHaveBeenCalled(); + expect(externalService.createRecord).toHaveBeenCalled(); + expect(res).toEqual(recordResponseCreate); + }); + + test('updates existing record', async () => { + const res = await api.pushToService({ + externalService, + logger: mockedLogger, + params: apiParams, + }); + + expect(externalService.createComment).toHaveBeenCalled(); + expect(externalService.createRecord).not.toHaveBeenCalled(); + expect(externalService.updateRecord).toHaveBeenCalled(); + expect(res).toEqual({ + ...recordResponseUpdate, + comments: [ + { + commentId: '123456', + pushedDate: '2021-06-01T17:29:51.092Z', + }, + { + commentId: '123456', + pushedDate: '2021-06-01T17:29:51.092Z', + }, + ], + }); + }); + + test('it calls createRecord correctly', async () => { + const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } }; + await api.pushToService({ externalService, params, logger: mockedLogger }); + + expect(externalService.createRecord).toHaveBeenCalledWith({ + incident: { + alertId: '123456', + caseId: '123456', + caseName: 'case name', + description: 'case desc', + ruleName: 'rule name', + severity: 'critical', + }, + }); + }); + + test('it calls createComment correctly', async () => { + const mockedToISOString = jest + .spyOn(Date.prototype, 'toISOString') + .mockReturnValue('2021-06-15T18:02:29.404Z'); + + const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } }; + await api.pushToService({ externalService, params, logger: mockedLogger }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(1, { + createdDate: '2021-06-15T18:02:29.404Z', + incidentId: '123456', + comment: { + commentId: 'case-comment-1', + comment: 'A comment', + }, + }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(2, { + createdDate: '2021-06-15T18:02:29.404Z', + incidentId: '123456', + comment: { + commentId: 'case-comment-2', + comment: 'Another comment', + }, + }); + + mockedToISOString.mockRestore(); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/api.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/api.ts new file mode 100644 index 00000000000000..343a94e52711ff --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/api.ts @@ -0,0 +1,60 @@ +/* + * Copyright 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 { + ExternalServiceIncidentResponse, + ExternalServiceApi, + Incident, + PushToServiceApiHandlerArgs, + PushToServiceResponse, +} from './types'; + +const pushToServiceHandler = async ({ + externalService, + params, +}: PushToServiceApiHandlerArgs): Promise => { + const { comments } = params; + let res: PushToServiceResponse; + const { externalId, ...rest } = params.incident; + const incident: Incident = rest; + + if (externalId != null) { + res = await externalService.updateRecord({ + incidentId: externalId, + incident, + }); + } else { + res = await externalService.createRecord({ incident }); + } + + const createdDate = new Date().toISOString(); + + if (comments && Array.isArray(comments) && comments.length > 0) { + res.comments = []; + for (const currentComment of comments) { + const comment = await externalService.createComment({ + incidentId: res.id, + comment: currentComment, + createdDate, + }); + + res.comments = [ + ...(res.comments ?? []), + { + commentId: comment.commentId, + pushedDate: comment.pushedDate, + }, + ]; + } + } + + return res; +}; + +export const api: ExternalServiceApi = { + pushToService: pushToServiceHandler, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/helpers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/helpers.test.ts new file mode 100644 index 00000000000000..c2974ec28486ce --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/helpers.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getBodyForEventAction } from './helpers'; +import { mappings } from './mocks'; + +describe('Create Record Mapping', () => { + const appId = '45678'; + + test('it maps successfully', () => { + const params = { + alertId: 'al123', + ruleName: 'Rule Name', + severity: 'Critical', + caseName: 'Case Name', + caseId: 'es3456789', + description: 'case desc', + externalId: null, + }; + + const data = getBodyForEventAction(appId, mappings, params); + expect(data.applicationId).toEqual(appId); + expect(data.id).not.toBeDefined(); + expect(data.values?.[mappings.alertIdConfig?.id ?? 0]).toEqual(params.alertId); + expect(data.values?.[mappings.ruleNameConfig.id]).toEqual(params.ruleName); + expect(data.values?.[mappings.caseNameConfig?.id ?? 0]).toEqual(params.caseName); + expect(data.values?.[mappings.caseIdConfig?.id ?? 0]).toEqual(params.caseId); + expect(data.values?.[mappings?.severityConfig?.id ?? 0]).toEqual(params.severity); + expect(data.values?.[mappings?.descriptionConfig?.id ?? 0]).toEqual(params.description); + }); + + test('it contains the id if defined', () => { + const params = { + alertId: 'al123', + ruleName: 'Rule Name', + severity: 'Critical', + caseName: 'Case Name', + caseId: 'es3456789', + description: 'case desc', + externalId: null, + }; + const data = getBodyForEventAction(appId, mappings, params, '123'); + expect(data.id).toEqual('123'); + }); + + test('it does not includes null mappings', () => { + const params = { + alertId: 'al123', + ruleName: 'Rule Name', + severity: 'Critical', + caseName: 'Case Name', + caseId: 'es3456789', + description: 'case desc', + externalId: null, + }; + + // @ts-expect-error + const data = getBodyForEventAction(appId, { ...mappings, test: null }, params); + expect(data.values?.test).not.toBeDefined(); + }); + + test('it converts a numeric values correctly', () => { + const params = { + alertId: 'thisIsNotANumber', + ruleName: 'Rule Name', + severity: 'Critical', + caseName: 'Case Name', + caseId: '123', + description: 'case desc', + externalId: null, + }; + + const data = getBodyForEventAction( + appId, + { + ...mappings, + caseIdConfig: { ...mappings.caseIdConfig, fieldType: 'numeric' }, + alertIdConfig: { ...mappings.alertIdConfig, fieldType: 'numeric' }, + }, + params + ); + + expect(data.values?.[mappings.alertIdConfig?.id ?? 0]).toBe(0); + expect(data.values?.[mappings.caseIdConfig?.id ?? 0]).toBe(123); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/helpers.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/helpers.ts new file mode 100644 index 00000000000000..13b2df1c97f16c --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/helpers.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 { CreateRecordParams, Incident, SwimlaneRecordPayload, MappingConfigType } from './types'; + +type ConfigMapping = Omit; + +const mappingKeysToIncidentKeys: Record = { + ruleNameConfig: 'ruleName', + alertIdConfig: 'alertId', + caseIdConfig: 'caseId', + caseNameConfig: 'caseName', + severityConfig: 'severity', + descriptionConfig: 'description', +}; + +export const getBodyForEventAction = ( + applicationId: string, + mappingConfig: MappingConfigType, + params: CreateRecordParams['incident'], + incidentId?: string +): SwimlaneRecordPayload => { + const data: SwimlaneRecordPayload = { + applicationId, + ...(incidentId ? { id: incidentId } : {}), + values: {}, + }; + + return (Object.keys(mappingConfig) as Array).reduce((acc, key) => { + const fieldMap = mappingConfig[key]; + + if (!fieldMap) { + return acc; + } + + const { id, fieldType } = fieldMap; + const paramName = mappingKeysToIncidentKeys[key]; + const value = params[paramName]; + + if (value) { + switch (fieldType) { + case 'numeric': { + const number = Number(value); + return { ...acc, values: { ...acc.values, [id]: isNaN(number) ? 0 : number } }; + } + default: { + return { ...acc, values: { ...acc.values, [id]: value } }; + } + } + } + + return acc; + }, data); +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/index.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/index.ts new file mode 100644 index 00000000000000..de5010436b6b3d --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/index.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { curry } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; +import { Logger } from '@kbn/logging'; +import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { + SwimlaneExecutorResultData, + SwimlanePublicConfigurationType, + SwimlaneSecretConfigurationType, + ExecutorParams, + ExecutorSubActionPushParams, +} from './types'; +import { validate } from './validators'; +import { + ExecutorParamsSchema, + SwimlaneSecretsConfiguration, + SwimlaneServiceConfiguration, +} from './schema'; +import { createExternalService } from './service'; +import { api } from './api'; + +interface GetActionTypeParams { + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; +} + +const supportedSubActions: string[] = ['pushToService']; + +// action type definition +export function getActionType( + params: GetActionTypeParams +): ActionType< + SwimlanePublicConfigurationType, + SwimlaneSecretConfigurationType, + ExecutorParams, + SwimlaneExecutorResultData | {} +> { + const { logger, configurationUtilities } = params; + + return { + id: '.swimlane', + minimumLicenseRequired: 'gold', + name: i18n.translate('xpack.actions.builtin.swimlaneTitle', { + defaultMessage: 'Swimlane', + }), + validate: { + config: schema.object(SwimlaneServiceConfiguration, { + validate: curry(validate.config)(configurationUtilities), + }), + secrets: schema.object(SwimlaneSecretsConfiguration, { + validate: curry(validate.secrets)(configurationUtilities), + }), + params: ExecutorParamsSchema, + }, + executor: curry(executor)({ logger, configurationUtilities }), + }; +} + +async function executor( + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, + execOptions: ActionTypeExecutorOptions< + SwimlanePublicConfigurationType, + SwimlaneSecretConfigurationType, + ExecutorParams + > +): Promise> { + const { actionId, config, params, secrets } = execOptions; + const { subAction, subActionParams } = params as ExecutorParams; + let data: SwimlaneExecutorResultData | null = null; + + const externalService = createExternalService( + { + config, + secrets, + }, + logger, + configurationUtilities + ); + + if (!api[subAction]) { + const errorMessage = `[Action][ExternalService] -> [Swimlane] Unsupported subAction type ${subAction}.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + if (!supportedSubActions.includes(subAction)) { + const errorMessage = `[Action][ExternalService] -> [Swimlane] subAction ${subAction} not implemented.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + if (subAction === 'pushToService') { + const pushToServiceParams = subActionParams as ExecutorSubActionPushParams; + + data = await api.pushToService({ + externalService, + params: pushToServiceParams, + logger, + }); + + logger.debug(`response push to service for incident id: ${data.id}`); + } + + return { status: 'ok', data: data ?? {}, actionId }; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/mocks.ts new file mode 100644 index 00000000000000..f9931049d81c2b --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/mocks.ts @@ -0,0 +1,124 @@ +/* + * Copyright 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 { ExecutorSubActionPushParams, ExternalService, PushToServiceApiParams } from './types'; + +export const applicationFields = [ + { + id: 'adnlas', + name: 'Severity', + key: 'severity', + fieldType: 'text', + }, + { + id: 'adnfls', + name: 'Rule Name', + key: 'rule-name', + fieldType: 'text', + }, + { + id: 'a6sst', + name: 'Case Id', + key: 'case-id-name', + fieldType: 'text', + }, + { + id: 'a6fst', + name: 'Case Name', + key: 'case-name', + fieldType: 'text', + }, + { + id: 'a6fdf', + name: 'Comments', + key: 'comments', + fieldType: 'notes', + }, + { + id: 'a6fde', + name: 'Description', + key: 'description', + fieldType: 'text', + }, + { + id: 'dfnkls', + name: 'Alert ID', + key: 'alert-id', + fieldType: 'text', + }, +]; + +export const mappings = { + severityConfig: applicationFields[0], + ruleNameConfig: applicationFields[1], + caseIdConfig: applicationFields[2], + caseNameConfig: applicationFields[3], + commentsConfig: applicationFields[4], + descriptionConfig: applicationFields[5], + alertIdConfig: applicationFields[6], +}; + +export const getApplicationResponse = { fields: applicationFields }; + +export const recordResponseCreate = { + id: '123456', + title: 'neato', + url: 'swimlane.com', + pushedDate: '2021-06-01T17:29:51.092Z', +}; + +export const recordResponseUpdate = { + id: '98765', + title: 'not neato', + url: 'laneswim.com', + pushedDate: '2021-06-01T17:29:51.092Z', +}; + +export const commentResponse = { + commentId: '123456', + pushedDate: '2021-06-01T17:29:51.092Z', +}; + +const createMock = (): jest.Mocked => { + return { + createComment: jest.fn().mockImplementation(() => Promise.resolve(commentResponse)), + createRecord: jest.fn().mockImplementation(() => Promise.resolve(recordResponseCreate)), + updateRecord: jest.fn().mockImplementation(() => Promise.resolve(recordResponseUpdate)), + }; +}; + +const externalServiceMock = { + create: createMock, +}; + +const executorParams: ExecutorSubActionPushParams = { + incident: { + ruleName: 'rule name', + alertId: '123456', + caseName: 'case name', + severity: 'critical', + caseId: '123456', + description: 'case desc', + externalId: 'incident-3', + }, + comments: [ + { + commentId: 'case-comment-1', + comment: 'A comment', + }, + { + commentId: 'case-comment-2', + comment: 'Another comment', + }, + ], +}; + +const apiParams: PushToServiceApiParams = { + ...executorParams, +}; + +export { externalServiceMock, executorParams, apiParams }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/schema.ts new file mode 100644 index 00000000000000..7f4bdc8ca6c0d7 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/schema.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const ConfigMap = { + id: schema.string(), + key: schema.string(), + name: schema.string(), + fieldType: schema.string(), +}; + +export const ConfigMapSchema = schema.object(ConfigMap); + +export const ConfigMapping = { + ruleNameConfig: schema.nullable(ConfigMapSchema), + alertIdConfig: schema.nullable(ConfigMapSchema), + caseIdConfig: schema.nullable(ConfigMapSchema), + caseNameConfig: schema.nullable(ConfigMapSchema), + commentsConfig: schema.nullable(ConfigMapSchema), + severityConfig: schema.nullable(ConfigMapSchema), + descriptionConfig: schema.nullable(ConfigMapSchema), +}; + +export const ConfigMappingSchema = schema.object(ConfigMapping); + +export const SwimlaneServiceConfiguration = { + apiUrl: schema.string(), + appId: schema.string(), + connectorType: schema.string(), + mappings: ConfigMappingSchema, +}; + +export const SwimlaneServiceConfigurationSchema = schema.object(SwimlaneServiceConfiguration); + +export const SwimlaneSecretsConfiguration = { + apiToken: schema.string(), +}; + +export const SwimlaneSecretsConfigurationSchema = schema.object(SwimlaneSecretsConfiguration); + +const SwimlaneFields = { + alertId: schema.nullable(schema.string()), + ruleName: schema.nullable(schema.string()), + caseId: schema.nullable(schema.string()), + caseName: schema.nullable(schema.string()), + severity: schema.nullable(schema.string()), + description: schema.nullable(schema.string()), +}; + +export const ExecutorSubActionPushParamsSchema = schema.object({ + incident: schema.object({ + ...SwimlaneFields, + externalId: schema.nullable(schema.string()), + }), + comments: schema.nullable( + schema.arrayOf( + schema.object({ + comment: schema.string(), + commentId: schema.string(), + }) + ) + ), +}); + +export const ExecutorParamsSchema = schema.oneOf([ + schema.object({ + subAction: schema.literal('pushToService'), + subActionParams: ExecutorSubActionPushParamsSchema, + }), +]); diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.test.ts new file mode 100644 index 00000000000000..77f4686f8acd04 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.test.ts @@ -0,0 +1,434 @@ +/* + * Copyright 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 axios from 'axios'; + +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { Logger } from '../../../../../../src/core/server'; +import { actionsConfigMock } from '../../actions_config.mock'; +import * as utils from '../lib/axios_utils'; +import { createExternalService } from './service'; +import { mappings } from './mocks'; +import { ExternalService } from './types'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +jest.mock('axios'); +jest.mock('../lib/axios_utils', () => { + const originalUtils = jest.requireActual('../lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; +const configurationUtilities = actionsConfigMock.create(); + +describe('Swimlane Service', () => { + let service: ExternalService; + const config = { + apiUrl: 'https://test.swimlane.com/', + appId: 'bcq16kdTbz5jlwM6h', + connectorType: 'all', + mappings, + }; + const apiToken = 'token'; + + const headers = { + 'Content-Type': 'application/json', + 'Private-Token': apiToken, + }; + + const incident = { + ruleName: 'Rule Name', + caseId: 'Case Id', + caseName: 'Case Name', + severity: 'Severity', + externalId: null, + description: 'Description', + alertId: 'Alert Id', + }; + + const url = config.apiUrl.slice(0, -1); + + beforeAll(() => { + service = createExternalService( + { + // The trailing slash at the end of the url is intended. + // All API calls need to have the trailing slash removed. + config, + secrets: { apiToken }, + }, + logger, + configurationUtilities + ); + }); + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createExternalService', () => { + test('throws without url', () => { + expect(() => + createExternalService( + { + config: { + // @ts-ignore + apiUrl: null, + appId: '99999', + mappings, + }, + secrets: { apiToken }, + }, + logger, + configurationUtilities + ) + ).toThrow(); + }); + + test('throws without app id', () => { + expect(() => + createExternalService( + { + config: { + apiUrl: 'test.com', + // @ts-ignore + appId: null, + }, + secrets: { apiToken }, + }, + logger, + configurationUtilities + ) + ).toThrow(); + }); + + test('throws without mappings', () => { + expect(() => + createExternalService( + { + config: { + apiUrl: 'test.com', + appId: '987987', + // @ts-ignore + mappings: null, + }, + secrets: { apiToken }, + }, + logger, + configurationUtilities + ) + ).toThrow(); + }); + + test('throws without api token', () => { + expect(() => { + return createExternalService( + { + config: { apiUrl: 'test.com', appId: '78978', mappings, connectorType: 'all' }, + secrets: { + // @ts-ignore + apiToken: null, + }, + }, + logger, + configurationUtilities + ); + }).toThrow(); + }); + }); + + describe('createRecord', () => { + const data = { + id: '123', + name: 'title', + createdDate: '2021-06-01T17:29:51.092Z', + }; + + test('it creates a record correctly', async () => { + requestMock.mockImplementation(() => ({ + data, + })); + + const res = await service.createRecord({ + incident, + }); + + expect(res).toEqual({ + id: '123', + title: 'title', + pushedDate: '2021-06-01T17:29:51.092Z', + url: `${url}/record/${config.appId}/123`, + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data, + })); + + await service.createRecord({ + incident, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + headers, + data: { + applicationId: config.appId, + values: { + [mappings.ruleNameConfig.id]: 'Rule Name', + [mappings.caseNameConfig.id]: 'Case Name', + [mappings.caseIdConfig.id]: 'Case Id', + [mappings.severityConfig.id]: 'Severity', + [mappings.descriptionConfig.id]: 'Description', + [mappings.alertIdConfig.id]: 'Alert Id', + }, + }, + url: `${url}/api/app/${config.appId}/record`, + method: 'post', + configurationUtilities, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect(service.createRecord({ incident })).rejects.toThrow( + `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown` + ); + }); + }); + + describe('updateRecord', () => { + const data = { + id: '123', + name: 'title', + modifiedDate: '2021-06-01T17:29:51.092Z', + }; + const incidentId = '123'; + + test('it updates a record correctly', async () => { + requestMock.mockImplementation(() => ({ + data, + })); + + const res = await service.updateRecord({ + incident, + incidentId, + }); + + expect(res).toEqual({ + id: '123', + title: 'title', + pushedDate: '2021-06-01T17:29:51.092Z', + url: `${url}/record/${config.appId}/123`, + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data, + })); + + await service.updateRecord({ + incident, + incidentId, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + headers, + data: { + applicationId: config.appId, + id: incidentId, + values: { + [mappings.ruleNameConfig.id]: 'Rule Name', + [mappings.caseNameConfig.id]: 'Case Name', + [mappings.caseIdConfig.id]: 'Case Id', + [mappings.severityConfig.id]: 'Severity', + [mappings.descriptionConfig.id]: 'Description', + [mappings.alertIdConfig.id]: 'Alert Id', + }, + }, + url: `${url}/api/app/${config.appId}/record/${incidentId}`, + method: 'patch', + configurationUtilities, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect(service.updateRecord({ incident, incidentId })).rejects.toThrow( + `[Action][Swimlane]: Unable to update record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown` + ); + }); + }); + + describe('createComment', () => { + const data = { + id: '123', + name: 'title', + modifiedDate: '2021-06-01T17:29:51.092Z', + }; + const incidentId = '123'; + const comment = { commentId: '456', comment: 'A comment' }; + const createdDate = '2021-06-01T17:29:51.092Z'; + + test('it updates a record correctly', async () => { + requestMock.mockImplementation(() => ({ + data, + })); + + const res = await service.createComment({ + comment, + incidentId, + createdDate, + }); + + expect(res).toEqual({ + commentId: '456', + pushedDate: '2021-06-01T17:29:51.092Z', + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data, + })); + + await service.createComment({ + comment, + incidentId, + createdDate, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + headers, + data: { + createdDate, + fieldId: mappings.commentsConfig.id, + isRichText: true, + message: comment.comment, + }, + url: `${url}/api/app/${config.appId}/record/${incidentId}/${mappings.commentsConfig.id}/comment`, + method: 'post', + configurationUtilities, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect(service.createComment({ comment, incidentId, createdDate })).rejects.toThrow( + `[Action][Swimlane]: Unable to create comment in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown` + ); + }); + }); + + describe('error messages', () => { + const errorResponse = { ErrorCode: '1', Argument: 'Invalid field' }; + + test('it contains the response error', async () => { + requestMock.mockImplementation(() => { + const error = new Error('An error has occurred'); + // @ts-ignore + error.response = { data: errorResponse }; + throw error; + }); + + await expect( + service.createRecord({ + incident, + }) + ).rejects.toThrow( + `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: Invalid field (1)` + ); + }); + + test('it shows an empty string for reason if the ErrorCode is undefined', async () => { + requestMock.mockImplementation(() => { + const error = new Error('An error has occurred'); + // @ts-ignore + error.response = { data: { ErrorCode: '1' } }; + throw error; + }); + + await expect( + service.createRecord({ + incident, + }) + ).rejects.toThrow( + `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown` + ); + }); + + test('it shows an empty string for reason if the Argument is undefined', async () => { + requestMock.mockImplementation(() => { + const error = new Error('An error has occurred'); + // @ts-ignore + error.response = { data: { Argument: 'Invalid field' } }; + throw error; + }); + + await expect( + service.createRecord({ + incident, + }) + ).rejects.toThrow( + `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown` + ); + }); + + test('it shows an empty string for reason if data is undefined', async () => { + requestMock.mockImplementation(() => { + const error = new Error('An error has occurred'); + // @ts-ignore + error.response = {}; + throw error; + }); + + await expect( + service.createRecord({ + incident, + }) + ).rejects.toThrow( + `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown` + ); + }); + + test('it shows the status code', async () => { + requestMock.mockImplementation(() => { + const error = new Error('An error has occurred'); + // @ts-ignore + error.response = { data: errorResponse, status: 400 }; + throw error; + }); + + await expect( + service.createRecord({ + incident, + }) + ).rejects.toThrow( + `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 400. Error: An error has occurred. Reason: Invalid field (1)` + ); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.ts new file mode 100644 index 00000000000000..f68d22121dbcc9 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.ts @@ -0,0 +1,196 @@ +/* + * Copyright 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/logging'; +import axios from 'axios'; + +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { getErrorMessage, request } from '../lib/axios_utils'; +import { getBodyForEventAction } from './helpers'; +import { + CreateCommentParams, + CreateRecordParams, + ExternalService, + ExternalServiceCredentials, + ExternalServiceIncidentResponse, + MappingConfigType, + ResponseError, + SwimlanePublicConfigurationType, + SwimlaneRecordPayload, + SwimlaneSecretConfigurationType, + UpdateRecordParams, +} from './types'; +import * as i18n from './translations'; + +const createErrorMessage = (errorResponse: ResponseError | null | undefined): string => { + if (errorResponse == null) { + return 'unknown'; + } + + const { ErrorCode, Argument } = errorResponse; + return Argument != null && ErrorCode != null ? `${Argument} (${ErrorCode})` : 'unknown'; +}; + +export const createExternalService = ( + { config, secrets }: ExternalServiceCredentials, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities +): ExternalService => { + const { apiUrl: url, appId, mappings } = config as SwimlanePublicConfigurationType; + const { apiToken } = secrets as SwimlaneSecretConfigurationType; + + const axiosInstance = axios.create(); + + if (!url || !appId || !apiToken || !mappings) { + throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); + } + + const headers: Record = { + 'Content-Type': 'application/json', + 'Private-Token': `${secrets.apiToken}`, + }; + + const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + const apiUrl = urlWithoutTrailingSlash.endsWith('api') + ? urlWithoutTrailingSlash + : urlWithoutTrailingSlash + '/api'; + + const getPostRecordUrl = (id: string) => `${apiUrl}/app/${id}/record`; + + const getPostRecordIdUrl = (id: string, recordId: string) => + `${getPostRecordUrl(id)}/${recordId}`; + + const getRecordIdUrl = (id: string, recordId: string) => + `${urlWithoutTrailingSlash}/record/${id}/${recordId}`; + + const getPostCommentUrl = (id: string, recordId: string, commentFieldId: string) => + `${getPostRecordIdUrl(id, recordId)}/${commentFieldId}/comment`; + + const getCommentFieldId = (fieldMappings: MappingConfigType): string | null => + fieldMappings.commentsConfig?.id || null; + + const createRecord = async ( + params: CreateRecordParams + ): Promise => { + try { + const mappingConfig = mappings as MappingConfigType; + const data = getBodyForEventAction(appId, mappingConfig, params.incident); + + const res = await request({ + axios: axiosInstance, + configurationUtilities, + data, + headers, + logger, + method: 'post', + url: getPostRecordUrl(appId), + }); + return { + id: res.data.id, + title: res.data.name, + url: getRecordIdUrl(appId, res.data.id), + pushedDate: new Date(res.data.createdDate).toISOString(), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to create record in application with id ${appId}. Status: ${ + error.response?.status ?? 500 + }. Error: ${error.message}. Reason: ${createErrorMessage(error.response?.data)}` + ) + ); + } + }; + + const updateRecord = async ( + params: UpdateRecordParams + ): Promise => { + try { + const mappingConfig = mappings as MappingConfigType; + const data = getBodyForEventAction(appId, mappingConfig, params.incident, params.incidentId); + + const res = await request({ + axios: axiosInstance, + configurationUtilities, + data, + headers, + logger, + method: 'patch', + url: getPostRecordIdUrl(appId, params.incidentId), + }); + + return { + id: res.data.id, + title: res.data.name, + url: getRecordIdUrl(appId, params.incidentId), + pushedDate: new Date(res.data.modifiedDate).toISOString(), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to update record in application with id ${appId}. Status: ${ + error.response?.status ?? 500 + }. Error: ${error.message}. Reason: ${createErrorMessage(error.response?.data)}` + ) + ); + } + }; + + const createComment = async ({ incidentId, comment, createdDate }: CreateCommentParams) => { + try { + const mappingConfig = mappings as MappingConfigType; + const fieldId = getCommentFieldId(mappingConfig); + + if (fieldId == null) { + throw new Error(`No comment field mapped in ${i18n.NAME} connector`); + } + + const data = { + createdDate, + fieldId, + isRichText: true, + message: comment.comment, + }; + + await request({ + axios: axiosInstance, + configurationUtilities, + data, + headers, + logger, + method: 'post', + url: getPostCommentUrl(appId, incidentId, fieldId), + }); + + /** + * Swimlane response does not contain any data. + * We cannot get an externalCommentId + */ + return { + commentId: comment.commentId, + pushedDate: createdDate, + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to create comment in application with id ${appId}. Status: ${ + error.response?.status ?? 500 + }. Error: ${error.message}. Reason: ${createErrorMessage(error.response?.data)}` + ) + ); + } + }; + + return { + createComment, + createRecord, + updateRecord, + }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/translations.ts new file mode 100644 index 00000000000000..671cf224448f66 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/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.case.swimlaneTitle', { + defaultMessage: 'Swimlane', +}); + +export const ALLOWED_HOSTS_ERROR = (message: string) => + i18n.translate('xpack.actions.builtin.swimlane.configuration.apiAllowedHostsError', { + defaultMessage: 'error configuring connector action: {message}', + values: { + message, + }, + }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/types.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/types.ts new file mode 100644 index 00000000000000..5cb3b109896215 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/types.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { TypeOf } from '@kbn/config-schema'; +import { Logger } from '@kbn/logging'; +import { + ConfigMappingSchema, + ExecutorParamsSchema, + ExecutorSubActionPushParamsSchema, + SwimlaneSecretsConfigurationSchema, + SwimlaneServiceConfigurationSchema, +} from './schema'; +import { ActionsConfigurationUtilities } from '../../actions_config'; + +export type SwimlanePublicConfigurationType = TypeOf; +export type SwimlaneSecretConfigurationType = TypeOf; + +export type MappingConfigType = TypeOf; +export type ExecutorParams = TypeOf; +export type ExecutorSubActionPushParams = TypeOf; + +export interface ExternalServiceCredentials { + config: SwimlanePublicConfigurationType; + secrets: SwimlaneSecretConfigurationType; +} + +export interface ExternalServiceValidation { + config: (configurationUtilities: ActionsConfigurationUtilities, configObject: any) => void; + secrets: (configurationUtilities: ActionsConfigurationUtilities, secrets: any) => void; +} + +export interface CreateRecordParams { + incident: Incident; +} +export interface UpdateRecordParams extends CreateRecordParams { + incidentId: string; +} + +export type PushToServiceApiParams = ExecutorSubActionPushParams; +export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs { + params: PushToServiceApiParams; + logger: Logger; +} + +export interface ExternalServiceIncidentResponse { + id: string; + title: string; + url: string; + pushedDate: string; +} +export interface ExternalServiceCommentResponse { + commentId: string; + pushedDate: string; + externalCommentId?: string; +} + +export interface FieldConfig { + id: string; + name: string; + key: string; + fieldType: string; +} + +export interface SwimlaneRecordPayload { + applicationId: string; + values: SwimlaneDataValues; + id?: string; +} + +export interface ExternalService { + createComment: (params: CreateCommentParams) => Promise; + createRecord: (params: CreateRecordParams) => Promise; + updateRecord: (params: UpdateRecordParams) => Promise; +} + +export type Incident = Omit; + +export interface ExternalServiceApiHandlerArgs { + externalService: ExternalService; +} + +export interface GetApplicationHandlerArgs { + externalService: ExternalService; +} + +export interface PushToServiceResponse extends ExternalServiceIncidentResponse { + comments?: ExternalServiceCommentResponse[]; +} + +export interface ExternalServiceApi { + pushToService: (args: PushToServiceApiHandlerArgs) => Promise; +} + +export type SwimlaneExecutorResultData = ExternalServiceIncidentResponse; +export type SwimlaneDataValues = Record; +export interface SwimlaneComment { + fieldId: string; + message: string | number; + createdDate: string; + isRichText: boolean; +} +export type SwimlaneDataComments = Record; + +export interface SimpleComment { + comment: SwimlaneComment['message']; + commentId: string; +} + +export interface CreateCommentParams { + incidentId: string; + comment: SimpleComment; + createdDate: string; +} + +export interface ResponseError { + ErrorCode: number; + Argument: string; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/validators.ts new file mode 100644 index 00000000000000..1972cd7e6af0bd --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/validators.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { ExternalServiceValidation, SwimlanePublicConfigurationType } from './types'; +import * as i18n from './translations'; + +export const validateCommonConfig = ( + configurationUtilities: ActionsConfigurationUtilities, + configObject: SwimlanePublicConfigurationType +) => { + try { + configurationUtilities.ensureUriAllowed(configObject.apiUrl); + } catch (allowedListError) { + return i18n.ALLOWED_HOSTS_ERROR(allowedListError.message); + } +}; + +export const validateCommonSecrets = () => {}; + +export const validate: ExternalServiceValidation = { + config: validateCommonConfig, + secrets: validateCommonSecrets, +}; diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index bcfc91d673bcc4..230ed826cb1083 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -47,7 +47,6 @@ export type { TeamsActionTypeId, TeamsActionParams, } from './builtin_action_types'; - export type { PluginSetupContract, PluginStartContract } from './plugin'; export { asSavedObjectExecutionSource, asHttpRequestExecutionSource } from './lib'; diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index a191728a204892..7c05d16923b9d2 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -22,7 +22,7 @@ export { ActionTypeExecutorResult } from '../common'; export { GetFieldsByIssueTypeResponse as JiraGetFieldsResponse } from './builtin_action_types/jira/types'; export { GetCommonFieldsResponse as ServiceNowGetFieldsResponse } from './builtin_action_types/servicenow/types'; export { GetCommonFieldsResponse as ResilientGetFieldsResponse } from './builtin_action_types/resilient/types'; - +export { SwimlanePublicConfigurationType } from './builtin_action_types/swimlane/types'; export type WithoutQueryAndParams = Pick>; export type GetServicesFunction = (request: KibanaRequest) => Services; export type ActionTypeRegistryContract = PublicMethodsOf; diff --git a/x-pack/plugins/actions/server/usage/actions_usage_collector.ts b/x-pack/plugins/actions/server/usage/actions_usage_collector.ts index 06248e1fa95a88..80e0c19092c781 100644 --- a/x-pack/plugins/actions/server/usage/actions_usage_collector.ts +++ b/x-pack/plugins/actions/server/usage/actions_usage_collector.ts @@ -18,6 +18,7 @@ const byTypeSchema: MakeSchemaFrom['count_by_type'] = { __email: { type: 'long' }, __index: { type: 'long' }, __pagerduty: { type: 'long' }, + __swimlane: { type: 'long' }, '__server-log': { type: 'long' }, __slack: { type: 'long' }, __webhook: { type: 'long' }, diff --git a/x-pack/plugins/cases/README.md b/x-pack/plugins/cases/README.md index a1660911567da3..cfff8c79ee2d47 100644 --- a/x-pack/plugins/cases/README.md +++ b/x-pack/plugins/cases/README.md @@ -215,7 +215,7 @@ This action type has no `secrets` properties. | -------- | ------------------------------------------------------------------------------------------------- | ----------------- | | id | ID of the connector used for pushing case updates to external systems. | string | | name | The connector name. | string | -| type | The type of the connector. Must be one of these: `.servicenow`, `jira`, `.resilient`, and `.none` | string | +| type | The type of the connector. Must be one of these: `.servicenow`, `.servicenow-sir`, `.swimlane`, `jira`, `.resilient`, and `.none` | string | | fields | Object containing the connector’s fields. | [fields](#fields) | #### `fields` diff --git a/x-pack/plugins/cases/common/api/connectors/index.ts b/x-pack/plugins/cases/common/api/connectors/index.ts index 2a81396025d9af..cee432b17933b9 100644 --- a/x-pack/plugins/cases/common/api/connectors/index.ts +++ b/x-pack/plugins/cases/common/api/connectors/index.ts @@ -12,12 +12,14 @@ import { JiraFieldsRT } from './jira'; import { ResilientFieldsRT } from './resilient'; import { ServiceNowITSMFieldsRT } from './servicenow_itsm'; import { ServiceNowSIRFieldsRT } from './servicenow_sir'; +import { SwimlaneFieldsRT } from './swimlane'; export * from './jira'; export * from './servicenow_itsm'; export * from './servicenow_sir'; export * from './resilient'; export * from './mappings'; +export * from './swimlane'; export type ActionConnector = ActionResult; export type ActionTypeConnector = ActionType; @@ -32,10 +34,11 @@ export const ConnectorFieldsRt = rt.union([ export enum ConnectorTypes { jira = '.jira', + none = '.none', resilient = '.resilient', serviceNowITSM = '.servicenow', serviceNowSIR = '.servicenow-sir', - none = '.none', + swimlane = '.swimlane', } export const connectorTypes = Object.values(ConnectorTypes); @@ -55,6 +58,11 @@ const ConnectorServiceNowITSMTypeFieldsRt = rt.type({ fields: rt.union([ServiceNowITSMFieldsRT, rt.null]), }); +const ConnectorSwimlaneTypeFieldsRt = rt.type({ + type: rt.literal(ConnectorTypes.swimlane), + fields: rt.union([SwimlaneFieldsRT, rt.null]), +}); + const ConnectorServiceNowSIRTypeFieldsRt = rt.type({ type: rt.literal(ConnectorTypes.serviceNowSIR), fields: rt.union([ServiceNowSIRFieldsRT, rt.null]), @@ -67,10 +75,11 @@ const ConnectorNoneTypeFieldsRt = rt.type({ export const ConnectorTypeFieldsRt = rt.union([ ConnectorJiraTypeFieldsRt, + ConnectorNoneTypeFieldsRt, ConnectorResillientTypeFieldsRt, ConnectorServiceNowITSMTypeFieldsRt, ConnectorServiceNowSIRTypeFieldsRt, - ConnectorNoneTypeFieldsRt, + ConnectorSwimlaneTypeFieldsRt, ]); export const CaseConnectorRt = rt.intersection([ @@ -85,6 +94,7 @@ export type CaseConnector = rt.TypeOf; export type ConnectorTypeFields = rt.TypeOf; export type ConnectorJiraTypeFields = rt.TypeOf; export type ConnectorResillientTypeFields = rt.TypeOf; +export type ConnectorSwimlaneTypeFields = rt.TypeOf; export type ConnectorServiceNowITSMTypeFields = rt.TypeOf< typeof ConnectorServiceNowITSMTypeFieldsRt >; diff --git a/x-pack/plugins/cases/common/api/connectors/mappings.ts b/x-pack/plugins/cases/common/api/connectors/mappings.ts index e0fdd2d7e62dc4..8737a6c5a64628 100644 --- a/x-pack/plugins/cases/common/api/connectors/mappings.ts +++ b/x-pack/plugins/cases/common/api/connectors/mappings.ts @@ -48,9 +48,6 @@ const ConnectorFieldRt = rt.type({ export type ConnectorField = rt.TypeOf; -const GetFieldsResponseRt = rt.type({ - defaultMappings: rt.array(ConnectorMappingsAttributesRT), - fields: rt.array(ConnectorFieldRt), -}); +const GetDefaultMappingsResponseRt = rt.array(ConnectorMappingsAttributesRT); -export type GetFieldsResponse = rt.TypeOf; +export type GetDefaultMappingsResponse = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/api/connectors/swimlane.ts b/x-pack/plugins/cases/common/api/connectors/swimlane.ts new file mode 100644 index 00000000000000..bc4d9df9ae6a0f --- /dev/null +++ b/x-pack/plugins/cases/common/api/connectors/swimlane.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; + +// New fields should also be added at: x-pack/plugins/cases/server/connectors/case/schema.ts +export const SwimlaneFieldsRT = rt.type({ + caseId: rt.union([rt.string, rt.null]), +}); + +export enum SwimlaneConnectorType { + All = 'all', + Alerts = 'alerts', + Cases = 'cases', +} + +export type SwimlaneFieldsType = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index 317fe1d8ed144a..5d7ee47bb8ea0e 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -4,6 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { ConnectorTypes } from './api'; + export const DEFAULT_DATE_FORMAT = 'dateFormat'; export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz'; @@ -59,16 +61,12 @@ export const CASE_DETAILS_ALERTS_URL = `${CASE_DETAILS_URL}/alerts`; export const ACTION_URL = '/api/actions'; export const ACTION_TYPES_URL = '/api/actions/list_action_types'; -export const SERVICENOW_ITSM_ACTION_TYPE_ID = '.servicenow'; -export const SERVICENOW_SIR_ACTION_TYPE_ID = '.servicenow-sir'; -export const JIRA_ACTION_TYPE_ID = '.jira'; -export const RESILIENT_ACTION_TYPE_ID = '.resilient'; - export const SUPPORTED_CONNECTORS = [ - SERVICENOW_ITSM_ACTION_TYPE_ID, - SERVICENOW_SIR_ACTION_TYPE_ID, - JIRA_ACTION_TYPE_ID, - RESILIENT_ACTION_TYPE_ID, + `${ConnectorTypes.serviceNowITSM}`, + `${ConnectorTypes.serviceNowSIR}`, + `${ConnectorTypes.jira}`, + `${ConnectorTypes.resilient}`, + `${ConnectorTypes.swimlane}`, ]; /** diff --git a/x-pack/plugins/cases/public/common/shared_imports.ts b/x-pack/plugins/cases/public/common/shared_imports.ts index 675204076b02a0..4641fcfa2167cb 100644 --- a/x-pack/plugins/cases/public/common/shared_imports.ts +++ b/x-pack/plugins/cases/public/common/shared_imports.ts @@ -24,6 +24,8 @@ export { ValidationError, ValidationFunc, VALIDATION_TYPES, + FieldConfig, + ValidationConfig, } from '../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; export { Field, diff --git a/x-pack/plugins/cases/public/components/case_view/index.test.tsx b/x-pack/plugins/cases/public/components/case_view/index.test.tsx index 55de4d07b13b92..1fafbac50c2b9a 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.test.tsx @@ -608,6 +608,7 @@ describe('CaseView ', () => { ).toBe(connectorName); }); }); + it('should update connector', async () => { const wrapper = mount( @@ -628,15 +629,19 @@ describe('CaseView ', () => { wrapper.find('[data-test-subj="connector-edit"] button').simulate('click'); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); - await waitFor(() => wrapper.update()); + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy(); + }); + wrapper.find(`button[data-test-subj="edit-connectors-submit"]`).first().simulate('click'); await waitFor(() => { - const updateObject = updateCaseProperty.mock.calls[0][0]; + wrapper.update(); expect(updateCaseProperty).toHaveBeenCalledTimes(1); + const updateObject = updateCaseProperty.mock.calls[0][0]; expect(updateObject.updateKey).toEqual('connector'); expect(updateObject.updateValue).toEqual({ id: 'resilient-2', diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx index 05f1c6727b1680..9c6e9442c8f564 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -31,17 +31,14 @@ import { useGetCaseUserActions } from '../../containers/use_get_case_user_action import { usePushToService } from '../use_push_to_service'; import { EditConnector } from '../edit_connector'; import { useConnectors } from '../../containers/configure/use_connectors'; -import { - getConnectorById, - normalizeActionConnector, - getNoneConnector, -} from '../configure_cases/utils'; +import { normalizeActionConnector, getNoneConnector } from '../configure_cases/utils'; import { StatusActionButton } from '../status/button'; import * as i18n from './translations'; import { CasesTimelineIntegration, CasesTimelineIntegrationProvider } from '../timeline_context'; import { useTimelineContext } from '../timeline_context/use_timeline_context'; import { CasesNavigation } from '../links'; import { OwnerProvider } from '../owner_context'; +import { getConnectorById } from '../utils'; import { DoesNotExist } from './does_not_exist'; const gutterTimeline = '70px'; // seems to be a timeline reference from the original file diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.tsx index 3ee4bc77cd237c..ac43ec05319a0b 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -24,15 +24,11 @@ import { ActionConnectorTableItem } from '../../../../triggers_actions_ui/public import { SectionWrapper } from '../wrappers'; import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; -import { - getConnectorById, - getNoneConnector, - normalizeActionConnector, - normalizeCaseConnector, -} from './utils'; +import { getNoneConnector, normalizeActionConnector, normalizeCaseConnector } from './utils'; import * as i18n from './translations'; import { Owner } from '../../types'; import { OwnerProvider } from '../owner_context'; +import { getConnectorById } from '../utils'; const FormWrapper = styled.div` ${({ theme }) => css` diff --git a/x-pack/plugins/cases/public/components/configure_cases/utils.ts b/x-pack/plugins/cases/public/components/configure_cases/utils.ts index ade1a5e0c2bbab..6597417b5068ab 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/utils.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/utils.ts @@ -10,10 +10,10 @@ import { CaseField, ActionType, ThirdPartyField, - ActionConnector, CaseConnector, CaseConnectorMapping, } from '../../containers/configure/types'; +import { CaseActionConnector } from '../types'; export const setActionTypeToMapping = ( caseField: CaseField, @@ -54,13 +54,8 @@ export const getNoneConnector = (): CaseConnector => ({ fields: null, }); -export const getConnectorById = ( - id: string, - connectors: ActionConnector[] -): ActionConnector | null => connectors.find((c) => c.id === id) ?? null; - export const normalizeActionConnector = ( - actionConnector: ActionConnector, + actionConnector: CaseActionConnector, fields: CaseConnector['fields'] = null ): CaseConnector => { const caseConnectorFieldsType = { @@ -75,6 +70,6 @@ export const normalizeActionConnector = ( }; export const normalizeCaseConnector = ( - connectors: ActionConnector[], + connectors: CaseActionConnector[], caseConnector: CaseConnector -): ActionConnector | null => connectors.find((c) => c.id === caseConnector.id) ?? null; +): CaseActionConnector | null => connectors.find((c) => c.id === caseConnector.id) ?? null; diff --git a/x-pack/plugins/cases/public/components/connector_selector/form.tsx b/x-pack/plugins/cases/public/components/connector_selector/form.tsx index 210334e93adb8e..71a65ae030d9d8 100644 --- a/x-pack/plugins/cases/public/components/connector_selector/form.tsx +++ b/x-pack/plugins/cases/public/components/connector_selector/form.tsx @@ -8,6 +8,7 @@ import React, { useCallback } from 'react'; import { isEmpty } from 'lodash/fp'; import { EuiFormRow } from '@elastic/eui'; +import styled from 'styled-components'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../common/shared_imports'; import { ConnectorsDropdown } from '../configure_cases/connectors_dropdown'; @@ -24,6 +25,13 @@ interface ConnectorSelectorProps { handleChange?: (newValue: string) => void; hideConnectorServiceNowSir?: boolean; } + +const EuiFormRowWrapper = styled(EuiFormRow)` + .euiFormErrorText { + display: none; + } +`; + export const ConnectorSelector = ({ connectors, dataTestSubj, @@ -47,7 +55,7 @@ export const ConnectorSelector = ({ ); return isEdit ? ( - - + ) : null; }; diff --git a/x-pack/plugins/cases/public/components/connectors/fields_form.tsx b/x-pack/plugins/cases/public/components/connectors/fields_form.tsx index d71da6f87689d3..062695fa41cc28 100644 --- a/x-pack/plugins/cases/public/components/connectors/fields_form.tsx +++ b/x-pack/plugins/cases/public/components/connectors/fields_form.tsx @@ -8,7 +8,8 @@ import React, { memo, Suspense } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; -import { CaseActionConnector, ConnectorFieldsProps } from './types'; +import { CaseActionConnector } from '../types'; +import { ConnectorFieldsProps } from './types'; import { getCaseConnectors } from '.'; import { ConnectorTypeFields } from '../../../common'; diff --git a/x-pack/plugins/cases/public/components/connectors/index.ts b/x-pack/plugins/cases/public/components/connectors/index.ts index ad202365ae9679..3aa10c56dd8e99 100644 --- a/x-pack/plugins/cases/public/components/connectors/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/index.ts @@ -8,6 +8,7 @@ import { CaseConnectorsRegistry } from './types'; import { createCaseConnectorsRegistry } from './connectors_registry'; import { getCaseConnector as getJiraCaseConnector } from './jira'; +import { getCaseConnector as getSwimlaneCaseConnector } from './swimlane'; import { getCaseConnector as getResilientCaseConnector } from './resilient'; import { getServiceNowITSMCaseConnector, getServiceNowSIRCaseConnector } from './servicenow'; import { @@ -15,6 +16,7 @@ import { ServiceNowITSMFieldsType, ServiceNowSIRFieldsType, ResilientFieldsType, + SwimlaneFieldsType, } from '../../../common'; export { getActionType as getCaseConnectorUi } from './case'; @@ -40,6 +42,7 @@ class CaseConnectors { getServiceNowITSMCaseConnector() ); this.caseConnectorsRegistry.register(getServiceNowSIRCaseConnector()); + this.caseConnectorsRegistry.register(getSwimlaneCaseConnector()); } registry(): CaseConnectorsRegistry { diff --git a/x-pack/plugins/cases/public/components/connectors/jira/index.ts b/x-pack/plugins/cases/public/components/connectors/jira/index.ts index f987d9823af8e4..d59d20177c14d3 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/jira/index.ts @@ -8,13 +8,13 @@ import { lazy } from 'react'; import { CaseConnector } from '../types'; -import { JiraFieldsType } from '../../../../common'; +import { ConnectorTypes, JiraFieldsType } from '../../../../common'; import * as i18n from './translations'; export * from './types'; export const getCaseConnector = (): CaseConnector => ({ - id: '.jira', + id: ConnectorTypes.jira, fieldsComponent: lazy(() => import('./case_fields')), }); export const fieldLabels = { diff --git a/x-pack/plugins/cases/public/components/connectors/mock.ts b/x-pack/plugins/cases/public/components/connectors/mock.ts index f5429fa2396aa6..663b397e6f4fec 100644 --- a/x-pack/plugins/cases/public/components/connectors/mock.ts +++ b/x-pack/plugins/cases/public/components/connectors/mock.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { SwimlaneConnectorType } from '../../../common'; + export const connector = { id: '123', name: 'My connector', @@ -13,6 +15,22 @@ export const connector = { isPreconfigured: false, }; +export const swimlaneConnector = { + id: '123', + name: 'My connector', + actionTypeId: '.swimlane', + config: { + connectorType: SwimlaneConnectorType.Cases, + mappings: { + caseIdConfig: {}, + caseNameConfig: {}, + descriptionConfig: {}, + commentsConfig: {}, + }, + }, + isPreconfigured: false, +}; + export const issues = [ { id: 'personId', title: 'Person Task', key: 'personKey' }, { id: 'womanId', title: 'Woman Task', key: 'womanKey' }, diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/index.ts b/x-pack/plugins/cases/public/components/connectors/resilient/index.ts index 9bf96b16f358cb..8a429c0dea0914 100644 --- a/x-pack/plugins/cases/public/components/connectors/resilient/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/resilient/index.ts @@ -8,13 +8,13 @@ import { lazy } from 'react'; import { CaseConnector } from '../types'; -import { ResilientFieldsType } from '../../../../common'; +import { ConnectorTypes, ResilientFieldsType } from '../../../../common'; import * as i18n from './translations'; export * from './types'; export const getCaseConnector = (): CaseConnector => ({ - id: '.resilient', + id: ConnectorTypes.resilient, fieldsComponent: lazy(() => import('./case_fields')), }); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/index.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/index.ts index 9df5f87b416e1c..88afd902ccf602 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/index.ts @@ -8,16 +8,20 @@ import { lazy } from 'react'; import { CaseConnector } from '../types'; -import { ServiceNowITSMFieldsType, ServiceNowSIRFieldsType } from '../../../../common'; +import { + ConnectorTypes, + ServiceNowITSMFieldsType, + ServiceNowSIRFieldsType, +} from '../../../../common'; import * as i18n from './translations'; export const getServiceNowITSMCaseConnector = (): CaseConnector => ({ - id: '.servicenow', + id: ConnectorTypes.serviceNowITSM, fieldsComponent: lazy(() => import('./servicenow_itsm_case_fields')), }); export const getServiceNowSIRCaseConnector = (): CaseConnector => ({ - id: '.servicenow-sir', + id: ConnectorTypes.serviceNowSIR, fieldsComponent: lazy(() => import('./servicenow_sir_case_fields')), }); diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/swimlane/case_fields.test.tsx new file mode 100644 index 00000000000000..1a035d92611bdd --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/case_fields.test.tsx @@ -0,0 +1,53 @@ +/* + * Copyright 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 { render, screen } from '@testing-library/react'; + +import { SwimlaneConnectorType } from '../../../../common'; +import Fields from './case_fields'; +import * as i18n from './translations'; +import { swimlaneConnector as connector } from '../mock'; + +const fields = { + caseId: '123', +}; + +const onChange = jest.fn(); + +describe('Swimlane Cases Fields', () => { + test('it does not shows the mapping error callout', () => { + render(); + expect(screen.queryByText(i18n.EMPTY_MAPPING_WARNING_TITLE)).toBeFalsy(); + }); + + test('it shows the mapping error callout when mapping is invalid', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + mappings: {}, + }, + }; + + render(); + expect(screen.queryByText(i18n.EMPTY_MAPPING_WARNING_TITLE)).toBeTruthy(); + }); + + test('it shows the mapping error callout when the connector is of type alerts', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + connectorType: SwimlaneConnectorType.Alerts, + }, + }; + + render(); + expect(screen.queryByText(i18n.EMPTY_MAPPING_WARNING_TITLE)).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/swimlane/case_fields.tsx new file mode 100644 index 00000000000000..b6370504edbb61 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/case_fields.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiCallOut } from '@elastic/eui'; +import * as i18n from './translations'; + +import { ConnectorTypes, SwimlaneFieldsType } from '../../../../common'; +import { ConnectorFieldsProps } from '../types'; +import { ConnectorCard } from '../card'; +import { connectorValidator } from './validator'; + +const SwimlaneComponent: React.FunctionComponent> = ({ + connector, + isEdit = true, +}) => { + const showMappingWarning = useMemo(() => connectorValidator(connector) != null, [connector]); + + return ( + <> + {!isEdit && ( + + )} + {showMappingWarning && ( + + {i18n.EMPTY_MAPPING_WARNING_DESC} + + )} + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SwimlaneComponent as default }; diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/index.ts b/x-pack/plugins/cases/public/components/connectors/swimlane/index.ts new file mode 100644 index 00000000000000..bd2eaae9e01741 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/index.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 { lazy } from 'react'; + +import { CaseConnector } from '../types'; +import { ConnectorTypes, SwimlaneFieldsType } from '../../../../common'; +import * as i18n from './translations'; + +export const getCaseConnector = (): CaseConnector => { + return { + id: ConnectorTypes.swimlane, + fieldsComponent: lazy(() => import('./case_fields')), + }; +}; + +export const fieldLabels = { + caseId: i18n.CASE_ID_LABEL, + caseName: i18n.CASE_NAME_LABEL, + severity: i18n.SEVERITY_LABEL, +}; diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/translations.ts b/x-pack/plugins/cases/public/components/connectors/swimlane/translations.ts new file mode 100644 index 00000000000000..eb6cd168fab991 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/translations.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ALERT_SOURCE_LABEL = i18n.translate( + 'xpack.cases.connectors.swimlane.alertSourceLabel', + { + defaultMessage: 'Alert Source', + } +); + +export const CASE_ID_LABEL = i18n.translate('xpack.cases.connectors.swimlane.caseIdLabel', { + defaultMessage: 'Case Id', +}); + +export const CASE_NAME_LABEL = i18n.translate('xpack.cases.connectors.swimlane.caseNameLabel', { + defaultMessage: 'Case Name', +}); + +export const SEVERITY_LABEL = i18n.translate('xpack.cases.connectors.swimlane.severityLabel', { + defaultMessage: 'Severity', +}); + +export const EMPTY_MAPPING_WARNING_TITLE = i18n.translate( + 'xpack.cases.connectors.swimlane.emptyMappingWarningTitle', + { + defaultMessage: 'This connector has missing field mappings', + } +); + +export const EMPTY_MAPPING_WARNING_DESC = i18n.translate( + 'xpack.cases.connectors.swimlane.emptyMappingWarningDesc', + { + defaultMessage: + 'This connector cannot be selected because it is missing the required case field mappings. You can edit this connector to add required field mappings or select a connector of type Cases.', + } +); diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts new file mode 100644 index 00000000000000..552d988c26330d --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright 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 { SwimlaneConnectorType } from '../../../../common'; +import { swimlaneConnector as connector } from '../mock'; +import { isAnyRequiredFieldNotSet, connectorValidator } from './validator'; + +describe('Swimlane validator', () => { + describe('isAnyRequiredFieldNotSet', () => { + test('it returns true if a required field is not set', () => { + expect(isAnyRequiredFieldNotSet({ notRequired: 'test' })).toBeTruthy(); + }); + + test('it returns false if all required fields are set', () => { + expect(isAnyRequiredFieldNotSet(connector.config.mappings)).toBeFalsy(); + }); + }); + + describe('connectorValidator', () => { + test('it returns an error message if the mapping is not correct', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + mappings: {}, + }, + }; + expect(connectorValidator(invalidConnector)).toEqual({ message: 'Invalid connector' }); + }); + + test('it returns an error message if the connector is of type alerts', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + connectorType: SwimlaneConnectorType.Alerts, + }, + }; + expect(connectorValidator(invalidConnector)).toEqual({ message: 'Invalid connector' }); + }); + + test.each([SwimlaneConnectorType.Cases, SwimlaneConnectorType.All])( + 'it does not return an error message if the connector is of type %s', + (connectorType) => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + connectorType, + }, + }; + expect(connectorValidator(invalidConnector)).toBe(undefined); + } + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts new file mode 100644 index 00000000000000..4ead75e5854f96 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SwimlaneConnectorType } from '../../../../common'; +import { ValidationConfig } from '../../../common/shared_imports'; +import { CaseActionConnector } from '../../types'; + +const casesRequiredFields = [ + 'caseIdConfig', + 'caseNameConfig', + 'descriptionConfig', + 'commentsConfig', +]; + +export const isAnyRequiredFieldNotSet = (mapping: Record | undefined) => + casesRequiredFields.some((field) => mapping?.[field] == null); + +/** + * The user can use either a connector of type cases or all. + * If the connector is of type all we should check if all + * required field have been configured. + */ + +export const connectorValidator = ( + connector: CaseActionConnector +): ReturnType => { + const { + config: { mappings, connectorType }, + } = connector; + if (connectorType === SwimlaneConnectorType.Alerts || isAnyRequiredFieldNotSet(mappings)) { + return { + message: 'Invalid connector', + }; + } +}; diff --git a/x-pack/plugins/cases/public/components/connectors/types.ts b/x-pack/plugins/cases/public/components/connectors/types.ts index 4eb97513b9f58e..5bbd77c7909012 100644 --- a/x-pack/plugins/cases/public/components/connectors/types.ts +++ b/x-pack/plugins/cases/public/components/connectors/types.ts @@ -11,12 +11,11 @@ import React from 'react'; import { ActionType as ThirdPartySupportedActions, CaseField, - ActionConnector, ConnectorTypeFields, } from '../../../common'; +import { CaseActionConnector } from '../types'; export { ThirdPartyField as AllThirdPartyFields } from '../../../common'; -export type CaseActionConnector = ActionConnector; export interface ThirdPartyField { label: string; diff --git a/x-pack/plugins/cases/public/components/create/connector.test.tsx b/x-pack/plugins/cases/public/components/create/connector.test.tsx index c453838f6cd7a1..bc6d5c8717eced 100644 --- a/x-pack/plugins/cases/public/components/create/connector.test.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.test.tsx @@ -18,6 +18,9 @@ import { useGetSeverity } from '../connectors/resilient/use_get_severity'; import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { incidentTypes, severity, choices } from '../connectors/mock'; import { schema, FormProps } from './schema'; +import { TestProviders } from '../../common/mock'; +import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { useCaseConfigureResponse } from '../configure_cases/__mock__'; jest.mock('../../common/lib/kibana', () => ({ useKibana: () => ({ @@ -39,10 +42,12 @@ jest.mock('../../common/lib/kibana', () => ({ jest.mock('../connectors/resilient/use_get_incident_types'); jest.mock('../connectors/resilient/use_get_severity'); jest.mock('../connectors/servicenow/use_get_choices'); +jest.mock('../../containers/configure/use_configure'); const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; const useGetChoicesMock = useGetChoices as jest.Mock; +const useCaseConfigureMock = useCaseConfigure as jest.Mock; const useGetIncidentTypesResponse = { isLoading: false, @@ -87,35 +92,30 @@ describe('Connector', () => { useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); }); it('it renders', async () => { const wrapper = mount( - - - + + + + + ); expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="connector-fields"]`).exists()).toBeTruthy(); - - await waitFor(() => { - expect(wrapper.find(`button[data-test-subj="dropdown-connectors"]`).first().text()).toBe( - 'My Connector' - ); - }); - - await waitFor(() => { - wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeTruthy(); - }); + // Selected connector is set to none so no fields should be displayed + expect(wrapper.find(`[data-test-subj="connector-fields"]`).exists()).toBeFalsy(); }); it('it is disabled and loading when isLoadingConnectors=true', async () => { const wrapper = mount( - - - + + + + + ); expect( @@ -129,9 +129,11 @@ describe('Connector', () => { it('it is disabled and loading when isLoading=true', async () => { const wrapper = mount( - - - + + + + + ); expect( @@ -144,9 +146,11 @@ describe('Connector', () => { it(`it should change connector`, async () => { const wrapper = mount( - - - + + + + + ); expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeFalsy(); diff --git a/x-pack/plugins/cases/public/components/create/connector.tsx b/x-pack/plugins/cases/public/components/create/connector.tsx index 2049f2a083a6ff..2ec6d1ffef23d4 100644 --- a/x-pack/plugins/cases/public/components/create/connector.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.tsx @@ -5,15 +5,22 @@ * 2.0. */ -import React, { memo, useCallback } from 'react'; +import React, { memo, useCallback, useMemo, useEffect } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { ActionConnector, ConnectorTypes } from '../../../common'; -import { UseField, useFormData, FieldHook, useFormContext } from '../../common/shared_imports'; +import { ConnectorTypes, ActionConnector } from '../../../common'; +import { + UseField, + useFormData, + FieldHook, + useFormContext, + FieldConfig, +} from '../../common/shared_imports'; import { ConnectorSelector } from '../connector_selector/form'; import { ConnectorFieldsForm } from '../connectors/fields_form'; -import { getConnectorById } from '../configure_cases/utils'; -import { FormProps } from './schema'; +import { FormProps, schema } from './schema'; +import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { getConnectorById, getConnectorsFormValidators } from '../utils'; interface Props { connectors: ActionConnector[]; @@ -26,6 +33,7 @@ interface ConnectorsFieldProps { connectors: ActionConnector[]; field: FieldHook; isEdit: boolean; + setErrors: (errors: boolean) => void; hideConnectorServiceNowSir?: boolean; } @@ -33,11 +41,13 @@ const ConnectorFields = ({ connectors, isEdit, field, + setErrors, hideConnectorServiceNowSir = false, }: ConnectorsFieldProps) => { const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); const { setValue } = field; let connector = getConnectorById(connectorId, connectors) ?? null; + if ( connector && hideConnectorServiceNowSir && @@ -61,18 +71,49 @@ const ConnectorComponent: React.FC = ({ isLoading, isLoadingConnectors, }) => { - const { getFields } = useFormContext(); + const { getFields, setFieldValue } = useFormContext(); + const { connector: configurationConnector } = useCaseConfigure(); + const handleConnectorChange = useCallback(() => { const { fields } = getFields(); fields.setValue(null); }, [getFields]); + const defaultConnectorId = useMemo(() => { + if ( + hideConnectorServiceNowSir && + configurationConnector.type === ConnectorTypes.serviceNowSIR + ) { + return 'none'; + } + return connectors.some((connector) => connector.id === configurationConnector.id) + ? configurationConnector.id + : 'none'; + }, [ + configurationConnector.id, + configurationConnector.type, + connectors, + hideConnectorServiceNowSir, + ]); + + useEffect(() => setFieldValue('connectorId', defaultConnectorId), [ + defaultConnectorId, + setFieldValue, + ]); + + const connectorIdConfig = getConnectorsFormValidators({ + config: schema.connectorId as FieldConfig, + connectors, + }); + return ( { jest.resetAllMocks(); useGetTagsMock.mockReturnValue({ tags: ['test'] }); useConnectorsMock.mockReturnValue({ loading: false, connectors: connectorsMock }); + useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); }); it('it renders with steps', async () => { diff --git a/x-pack/plugins/cases/public/components/create/form_context.tsx b/x-pack/plugins/cases/public/components/create/form_context.tsx index 30a60fb5c1e47f..65c102583455a1 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.tsx @@ -5,23 +5,19 @@ * 2.0. */ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { schema, FormProps } from './schema'; import { Form, useForm } from '../../common/shared_imports'; -import { - getConnectorById, - getNoneConnector, - normalizeActionConnector, -} from '../configure_cases/utils'; +import { getNoneConnector, normalizeActionConnector } from '../configure_cases/utils'; import { usePostCase } from '../../containers/use_post_case'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { useConnectors } from '../../containers/configure/use_connectors'; -import { useCaseConfigure } from '../../containers/configure/use_configure'; import { Case } from '../../containers/types'; -import { CaseType, ConnectorTypes } from '../../../common'; +import { CaseType } from '../../../common'; import { UsePostComment, usePostComment } from '../../containers/use_post_comment'; import { useOwnerContext } from '../owner_context/use_owner_context'; +import { getConnectorById } from '../utils'; const initialCaseValue: FormProps = { description: '', @@ -49,28 +45,10 @@ export const FormContext: React.FC = ({ }) => { const { connectors, loading: isLoadingConnectors } = useConnectors(); const owner = useOwnerContext(); - const { connector: configurationConnector } = useCaseConfigure(); const { postCase } = usePostCase(); const { postComment } = usePostComment(); const { pushCaseToExternalService } = usePostPushToService(); - const connectorId = useMemo(() => { - if ( - hideConnectorServiceNowSir && - configurationConnector.type === ConnectorTypes.serviceNowSIR - ) { - return 'none'; - } - return connectors.some((connector) => connector.id === configurationConnector.id) - ? configurationConnector.id - : 'none'; - }, [ - configurationConnector.id, - configurationConnector.type, - connectors, - hideConnectorServiceNowSir, - ]); - const submitCase = useCallback( async ( { connectorId: dataConnectorId, fields, syncAlerts = true, ...dataWithoutConnectorId }, @@ -125,9 +103,6 @@ export const FormContext: React.FC = ({ schema, onSubmit: submitCase, }); - const { setFieldValue } = form; - // Set the selected connector to the configuration connector - useEffect(() => setFieldValue('connectorId', connectorId), [connectorId, setFieldValue]); const childrenWithExtraProp = useMemo( () => diff --git a/x-pack/plugins/cases/public/components/create/schema.tsx b/x-pack/plugins/cases/public/components/create/schema.tsx index 6e6d1a414280eb..bea1a46d93760c 100644 --- a/x-pack/plugins/cases/public/components/create/schema.tsx +++ b/x-pack/plugins/cases/public/components/create/schema.tsx @@ -49,7 +49,9 @@ export const schema: FormSchema = { label: i18n.CONNECTORS, defaultValue: 'none', }, - fields: {}, + fields: { + defaultValue: null, + }, syncAlerts: { helpText: i18n.SYNC_ALERTS_HELP, type: FIELD_TYPES.TOGGLE, diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.tsx index 570f6e34d25287..8057d188b8c047 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.tsx @@ -20,15 +20,15 @@ import { import styled from 'styled-components'; import { noop } from 'lodash/fp'; -import { Form, UseField, useForm } from '../../common/shared_imports'; +import { FieldConfig, Form, UseField, useForm } from '../../common/shared_imports'; import { ActionConnector, ConnectorTypeFields } from '../../../common'; import { ConnectorSelector } from '../connector_selector/form'; import { ConnectorFieldsForm } from '../connectors/fields_form'; -import { getConnectorById } from '../configure_cases/utils'; import { CaseUserActions } from '../../containers/types'; import { schema } from './schema'; import { getConnectorFieldsFromUserActions } from './helpers'; import * as i18n from './translations'; +import { getConnectorById, getConnectorsFormValidators } from '../utils'; export interface EditConnectorProps { caseFields: ConnectorTypeFields['fields']; @@ -205,6 +205,11 @@ export const EditConnector = React.memo( }); }, [dispatch]); + const connectorIdConfig = getConnectorsFormValidators({ + config: schema.connectorId as FieldConfig, + connectors, + }); + /** * if this evaluates to true it means that the connector was likely deleted because the case connector was set to something * other than none but we don't find it in the list of connectors returned from the actions plugin @@ -243,6 +248,7 @@ export const EditConnector = React.memo( connectors.find((c) => c.id === id) ?? null; + +const validators: Record< + string, + (connector: CaseActionConnector) => ReturnType +> = { + [ConnectorTypes.swimlane]: swimlaneConnectorValidator, +}; + +export const getConnectorsFormValidators = ({ + connectors = [], + config = {}, +}: { + connectors: CaseActionConnector[]; + config: FieldConfig; +}): FieldConfig => ({ + ...config, + validations: [ + { + validator: ({ value: connectorId }) => { + const connector = getConnectorById(connectorId as string, connectors); + if (connector != null) { + return validators[connector.actionTypeId]?.(connector); + } + }, + }, + ], +}); diff --git a/x-pack/plugins/cases/public/containers/use_get_action_license.tsx b/x-pack/plugins/cases/public/containers/use_get_action_license.tsx index 4f28d88c14b259..e4ea6d05011a78 100644 --- a/x-pack/plugins/cases/public/containers/use_get_action_license.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_action_license.tsx @@ -11,6 +11,7 @@ import { useToasts } from '../common/lib/kibana'; import { getActionLicense } from './api'; import * as i18n from './translations'; import { ActionLicense } from './types'; +import { ConnectorTypes } from '../../common'; export interface ActionLicenseState { actionLicense: ActionLicense | null; @@ -24,7 +25,7 @@ export const initialData: ActionLicenseState = { isError: false, }; -const MINIMUM_LICENSE_REQUIRED_CONNECTOR = '.jira'; +const MINIMUM_LICENSE_REQUIRED_CONNECTOR = ConnectorTypes.jira; export const useGetActionLicense = (): ActionLicenseState => { const [actionLicenseState, setActionLicensesState] = useState(initialData); diff --git a/x-pack/plugins/cases/server/client/cases/get.ts b/x-pack/plugins/cases/server/client/cases/get.ts index 3df1891391c75e..4f8713704361b7 100644 --- a/x-pack/plugins/cases/server/client/cases/get.ts +++ b/x-pack/plugins/cases/server/client/cases/get.ts @@ -173,7 +173,6 @@ export const get = async ( let theCase: SavedObject; let subCaseIds: string[] = []; - if (ENABLE_CASE_CONNECTOR) { const [caseInfo, subCasesForCaseId] = await Promise.all([ caseService.getCase({ diff --git a/x-pack/plugins/cases/server/client/cases/utils.ts b/x-pack/plugins/cases/server/client/cases/utils.ts index d920c517a00044..f5a10d705e095d 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.ts @@ -252,6 +252,7 @@ export const prepareFieldsForTransformation = ({ mappings.reduce( (acc: PipedField[], mapping) => mapping != null && + mapping.target != null && mapping.target !== 'not_mapped' && mapping.action_type !== 'nothing' && mapping.source !== 'comments' diff --git a/x-pack/plugins/cases/server/connectors/case/index.test.ts b/x-pack/plugins/cases/server/connectors/case/index.test.ts index 7b8f57bf0d3bfb..51c45bd25444e9 100644 --- a/x-pack/plugins/cases/server/connectors/case/index.test.ts +++ b/x-pack/plugins/cases/server/connectors/case/index.test.ts @@ -60,7 +60,7 @@ describe('case connector', () => { connector: { id: 'jira', name: 'Jira', - type: '.jira', + type: ConnectorTypes.jira, fields: { issueType: '10006', priority: 'High', @@ -99,7 +99,7 @@ describe('case connector', () => { connector: { id: 'jira', name: 'Jira', - type: '.jira', + type: ConnectorTypes.jira, fields: { issueType: '10006', priority: 'High', @@ -293,7 +293,7 @@ describe('case connector', () => { connector: { id: 'jira', name: 'Jira', - type: '.jira', + type: ConnectorTypes.jira, fields: { priority: 'High', parent: null, @@ -438,7 +438,7 @@ describe('case connector', () => { connector: { id: 'jira', name: 'Jira', - type: '.jira', + type: ConnectorTypes.jira, fields: { issueType: '10006', priority: 'High', @@ -640,7 +640,7 @@ describe('case connector', () => { connector: { id: 'jira', name: 'Jira', - type: '.jira', + type: ConnectorTypes.jira, fields: { priority: 'High', parent: null, @@ -974,7 +974,7 @@ describe('case connector', () => { connector: { id: 'jira', name: 'Jira', - type: '.jira', + type: ConnectorTypes.jira, fields: { issueType: '10006', priority: 'High', @@ -1003,7 +1003,7 @@ describe('case connector', () => { connector: { id: 'jira', name: 'Jira', - type: '.jira', + type: ConnectorTypes.jira, fields: { issueType: '10006', priority: 'High', diff --git a/x-pack/plugins/cases/server/connectors/case/schema.ts b/x-pack/plugins/cases/server/connectors/case/schema.ts index 596a5a4aae45ed..79d3bf62e8a9e7 100644 --- a/x-pack/plugins/cases/server/connectors/case/schema.ts +++ b/x-pack/plugins/cases/server/connectors/case/schema.ts @@ -6,7 +6,7 @@ */ import { schema } from '@kbn/config-schema'; -import { CommentType } from '../../../common'; +import { CommentType, ConnectorTypes } from '../../../common'; import { validateConnector } from './validators'; // Reserved for future implementation @@ -77,23 +77,29 @@ const ServiceNowSIRFieldsSchema = schema.object({ subcategory: schema.nullable(schema.string()), }); +const SwimlaneFieldsSchema = schema.object({ + caseId: schema.nullable(schema.string()), +}); + const NoneFieldsSchema = schema.nullable(schema.object({})); const ReducedConnectorFieldsSchema: { [x: string]: any } = { - '.jira': JiraFieldsSchema, - '.resilient': ResilientFieldsSchema, - '.servicenow-sir': ServiceNowSIRFieldsSchema, + [ConnectorTypes.jira]: JiraFieldsSchema, + [ConnectorTypes.resilient]: ResilientFieldsSchema, + [ConnectorTypes.serviceNowSIR]: ServiceNowSIRFieldsSchema, + [ConnectorTypes.swimlane]: SwimlaneFieldsSchema, }; export const ConnectorProps = { id: schema.string(), name: schema.string(), type: schema.oneOf([ - schema.literal('.servicenow'), - schema.literal('.jira'), - schema.literal('.resilient'), - schema.literal('.servicenow-sir'), - schema.literal('.none'), + schema.literal(ConnectorTypes.jira), + schema.literal(ConnectorTypes.none), + schema.literal(ConnectorTypes.resilient), + schema.literal(ConnectorTypes.serviceNowITSM), + schema.literal(ConnectorTypes.serviceNowSIR), + schema.literal(ConnectorTypes.swimlane), ]), // Chain of conditional schemes fields: Object.keys(ReducedConnectorFieldsSchema).reduce( @@ -106,7 +112,7 @@ export const ConnectorProps = { ), schema.conditional( schema.siblingRef('type'), - '.servicenow', + ConnectorTypes.serviceNowITSM, ServiceNowITSMFieldsSchema, NoneFieldsSchema ) diff --git a/x-pack/plugins/cases/server/connectors/case/validators.ts b/x-pack/plugins/cases/server/connectors/case/validators.ts index 03110d15c9d3f5..6ab4f3a21a24ff 100644 --- a/x-pack/plugins/cases/server/connectors/case/validators.ts +++ b/x-pack/plugins/cases/server/connectors/case/validators.ts @@ -6,9 +6,10 @@ */ import { Connector } from './types'; +import { ConnectorTypes } from '../../../common'; export const validateConnector = (connector: Connector) => { - if (connector.type === '.none' && connector.fields !== null) { + if (connector.type === ConnectorTypes.none && connector.fields !== null) { return 'Fields must be set to null for connectors of type .none'; } }; diff --git a/x-pack/plugins/cases/server/connectors/factory.ts b/x-pack/plugins/cases/server/connectors/factory.ts index 5ed7eb4ade4caa..d0ae7154fe5d99 100644 --- a/x-pack/plugins/cases/server/connectors/factory.ts +++ b/x-pack/plugins/cases/server/connectors/factory.ts @@ -6,16 +6,18 @@ */ import { ConnectorTypes } from '../../common'; +import { ICasesConnector, CasesConnectorsMap } from './types'; import { getCaseConnector as getJiraCaseConnector } from './jira'; import { getCaseConnector as getResilientCaseConnector } from './resilient'; import { getServiceNowITSMCaseConnector, getServiceNowSIRCaseConnector } from './servicenow'; -import { ICasesConnector, CasesConnectorsMap } from './types'; +import { getCaseConnector as getSwimlaneCaseConnector } from './swimlane'; const mapping: Record = { [ConnectorTypes.jira]: getJiraCaseConnector(), [ConnectorTypes.serviceNowITSM]: getServiceNowITSMCaseConnector(), [ConnectorTypes.serviceNowSIR]: getServiceNowSIRCaseConnector(), [ConnectorTypes.resilient]: getResilientCaseConnector(), + [ConnectorTypes.swimlane]: getSwimlaneCaseConnector(), [ConnectorTypes.none]: null, }; diff --git a/x-pack/plugins/cases/server/connectors/swimlane/format.test.ts b/x-pack/plugins/cases/server/connectors/swimlane/format.test.ts new file mode 100644 index 00000000000000..55cbbdb68691e8 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/swimlane/format.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseResponse } from '../../../common'; +import { format } from './format'; + +describe('Swimlane formatter', () => { + const theCase = { + id: 'case-id', + connector: { fields: null }, + } as CaseResponse; + + it('it formats correctly', async () => { + const res = await format(theCase, []); + expect(res).toEqual({ caseId: theCase.id }); + }); +}); diff --git a/x-pack/plugins/cases/server/connectors/swimlane/format.ts b/x-pack/plugins/cases/server/connectors/swimlane/format.ts new file mode 100644 index 00000000000000..9531e4099a4f4e --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/swimlane/format.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConnectorSwimlaneTypeFields } from '../../../common'; +import { Format } from './types'; + +export const format: Format = (theCase) => { + const { caseId = theCase.id } = + (theCase.connector.fields as ConnectorSwimlaneTypeFields['fields']) ?? {}; + return { caseId }; +}; diff --git a/x-pack/plugins/cases/server/connectors/swimlane/index.ts b/x-pack/plugins/cases/server/connectors/swimlane/index.ts new file mode 100644 index 00000000000000..2cad92391bdec0 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/swimlane/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getMapping } from './mapping'; +import { format } from './format'; +import { SwimlaneCaseConnector } from './types'; + +export const getCaseConnector = (): SwimlaneCaseConnector => ({ + getMapping, + format, +}); diff --git a/x-pack/plugins/cases/server/connectors/swimlane/mapping.ts b/x-pack/plugins/cases/server/connectors/swimlane/mapping.ts new file mode 100644 index 00000000000000..e1e34054463e5a --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/swimlane/mapping.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GetMapping } from './types'; + +export const getMapping: GetMapping = () => { + return [ + { + source: 'title', + target: 'caseName', + action_type: 'overwrite', + }, + { + source: 'description', + target: 'description', + action_type: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + action_type: 'append', + }, + ]; +}; diff --git a/x-pack/plugins/cases/server/connectors/swimlane/types.ts b/x-pack/plugins/cases/server/connectors/swimlane/types.ts new file mode 100644 index 00000000000000..22a1e9f6372d5c --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/swimlane/types.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SwimlaneFieldsType } from '../../../common/api'; +import { ICasesConnector } from '../types'; + +export type SwimlaneCaseConnector = ICasesConnector; +export type Format = ICasesConnector['format']; +export type GetMapping = ICasesConnector['getMapping']; diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index d112630facbc6f..d59d7e7b7da4f8 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -241,6 +241,7 @@ export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [ '.email', '.slack', '.pagerduty', + '.swimlane', '.webhook', '.servicenow', '.jira', diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 9230b4d8298537..39852ebaeb46be 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -31,6 +31,9 @@ "__index": { "type": "long" }, + "__swimlane": { + "type": "long" + }, "__pagerduty": { "type": "long" }, @@ -68,6 +71,9 @@ "__index": { "type": "long" }, + "__swimlane": { + "type": "long" + }, "__pagerduty": { "type": "long" }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts index 2eda435d045a4a..4266822bda1fc1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts @@ -10,6 +10,7 @@ import { getSlackActionType } from './slack'; import { getEmailActionType } from './email'; import { getIndexActionType } from './es_index'; import { getPagerDutyActionType } from './pagerduty'; +import { getSwimlaneActionType } from './swimlane'; import { getWebhookActionType } from './webhook'; import { TypeRegistry } from '../../type_registry'; import { ActionTypeModel } from '../../../types'; @@ -28,6 +29,7 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getEmailActionType()); actionTypeRegistry.register(getIndexActionType()); actionTypeRegistry.register(getPagerDutyActionType()); + actionTypeRegistry.register(getSwimlaneActionType()); actionTypeRegistry.register(getWebhookActionType()); actionTypeRegistry.register(getServiceNowITSMActionType()); actionTypeRegistry.register(getServiceNowSIRActionType()); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx index b89f71b0fc3548..be5250ccf8b293 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx @@ -12,7 +12,7 @@ import { JiraActionConnector } from './types'; jest.mock('../../../../common/lib/kibana'); describe('JiraActionConnectorFields renders', () => { - test('alerting Jira connector fields is rendered', () => { + test('alerting Jira connector fields are rendered', () => { const actionConnector = { secrets: { email: 'email', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx index 5897de46f94df7..99d7e9510454f4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx @@ -63,6 +63,7 @@ const JiraParamsFields: React.FunctionComponent { if (key === 'issueType') { @@ -75,9 +76,11 @@ const JiraParamsFields: React.FunctionComponent { if (incident.issueType != null && fields != null) { const priorities = fields.priority != null ? fields.priority.allowedValues : []; @@ -141,6 +145,7 @@ const JiraParamsFields: React.FunctionComponent { if (!hasPriority && incident.priority != null) { editSubActionProperty('priority', null); @@ -167,6 +172,7 @@ const JiraParamsFields: React.FunctionComponent { if (!actionParams.subAction) { editAction('subAction', 'pushToService', index); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx index b7b68b9485d8a7..bbd237a7cec897 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx @@ -12,7 +12,7 @@ import { ResilientActionConnector } from './types'; jest.mock('../../../../common/lib/kibana'); describe('ResilientActionConnectorFields renders', () => { - test('alerting Resilient connector fields is rendered', () => { + test('alerting Resilient connector fields are rendered', () => { const actionConnector = { secrets: { apiKeyId: 'key', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx index 54a138a2bc7cfc..b0f5198b6b5fde 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx @@ -147,6 +147,7 @@ const ResilientParamsFields: React.FunctionComponent { if (!actionParams.subAction) { editAction('subAction', 'pushToService', index); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx index 330844b93b6b5a..4993c51f350ad6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx @@ -12,7 +12,7 @@ import { ServiceNowActionConnector } from './types'; jest.mock('../../../../common/lib/kibana'); describe('ServiceNowActionConnectorFields renders', () => { - test('alerting servicenow connector fields is rendered', () => { + test('alerting servicenow connector fields are rendered', () => { const actionConnector = { secrets: { username: 'user', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts new file mode 100644 index 00000000000000..90bab65b83bfdb --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright 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 { getApplication } from './api'; + +const getApplicationResponse = { + fields: [], +}; + +describe('Swimlane API', () => { + let fetchMock: jest.SpyInstance>; + + beforeAll(() => jest.spyOn(window, 'fetch')); + beforeEach(() => { + jest.resetAllMocks(); + fetchMock = jest.spyOn(window, 'fetch'); + }); + + describe('getApplication', () => { + it('should call getApplication API correctly', async () => { + const abortCtrl = new AbortController(); + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => getApplicationResponse, + }); + const res = await getApplication({ + signal: abortCtrl.signal, + apiToken: '', + appId: '', + url: '', + }); + + expect(res).toEqual(getApplicationResponse); + }); + + it('returns an error when the response fails', async () => { + const abortCtrl = new AbortController(); + + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => getApplicationResponse, + }); + + try { + await getApplication({ + signal: abortCtrl.signal, + apiToken: '', + appId: '', + url: '', + }); + } catch (e) { + expect(e.message).toContain('Received status:'); + } + }); + + it('returns an error when parsing the json fails', async () => { + const abortCtrl = new AbortController(); + + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { + throw new Error('bad'); + }, + }); + + try { + await getApplication({ + signal: abortCtrl.signal, + apiToken: '', + appId: '', + url: '', + }); + } catch (e) { + expect(e.message).toContain('bad'); + } + }); + + it('it removes unsafe fields', async () => { + const abortCtrl = new AbortController(); + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + fields: [ + { + id: '__proto__', + name: 'Alert Id', + key: 'alert-id', + fieldType: 'text', + }, + { + id: 'a6ide', + name: '__proto__', + key: 'alert-id', + fieldType: 'text', + }, + { + id: 'a6ide', + name: 'Alert Id', + key: '__proto__', + fieldType: 'text', + }, + { + id: 'a6ide', + name: 'Alert Id', + key: 'alert-id', + fieldType: '__proto__', + }, + { + id: 'safe-id', + name: 'Safe', + key: 'safe-key', + fieldType: 'safe-text', + }, + ], + }), + }); + + const res = await getApplication({ + signal: abortCtrl.signal, + apiToken: '', + appId: '', + url: '', + }); + + expect(res).toEqual({ + fields: [ + { + id: 'safe-id', + name: 'Safe', + key: 'safe-key', + fieldType: 'safe-text', + }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.ts new file mode 100644 index 00000000000000..c6f9d4bee3e138 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SwimlaneFieldMappingConfig } from './types'; + +const removeUnsafeFields = (fields: SwimlaneFieldMappingConfig[]): SwimlaneFieldMappingConfig[] => + fields.filter( + (filter) => + filter.id !== '__proto__' && + filter.key !== '__proto__' && + filter.name !== '__proto__' && + filter.fieldType !== '__proto__' + ); +export async function getApplication({ + signal, + url, + appId, + apiToken, +}: { + signal: AbortSignal; + url: string; + appId: string; + apiToken: string; +}): Promise> { + const headers: Record = { + 'Content-Type': 'application/json', + 'Private-Token': `${apiToken}`, + }; + + const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + const apiUrl = urlWithoutTrailingSlash.endsWith('api') + ? urlWithoutTrailingSlash + : urlWithoutTrailingSlash + '/api'; + const applicationUrl = `${apiUrl}/app/{appId}`; + + const getApplicationUrl = (id: string) => applicationUrl.replace('{appId}', id); + + try { + const response = await fetch(getApplicationUrl(appId), { + method: 'GET', + headers, + signal, + }); + + /** + * Fetch do not throw when there is an HTTP error (status >= 400). + * We need to do it manually. + */ + + if (!response.ok) { + throw new Error( + `Received status: ${response.status} when attempting to get application with id: ${appId}` + ); + } + + const data = await response.json(); + return { ...data, fields: removeUnsafeFields(data?.fields ?? []) }; + } catch (error) { + throw new Error(`Unable to get application with id ${appId}. Error: ${error.message}`); + } +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/helpers.ts new file mode 100644 index 00000000000000..413b952675b8ce --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/helpers.ts @@ -0,0 +1,62 @@ +/* + * Copyright 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 { SwimlaneConnectorType, SwimlaneMappingConfig, MappingConfigurationKeys } from './types'; +import * as i18n from './translations'; + +const casesRequiredFields: MappingConfigurationKeys[] = [ + 'caseNameConfig', + 'descriptionConfig', + 'commentsConfig', + 'caseIdConfig', +]; +const casesFields = [...casesRequiredFields]; +const alertsRequiredFields: MappingConfigurationKeys[] = ['ruleNameConfig', 'alertIdConfig']; +const alertsFields = ['severityConfig', 'commentsConfig', ...alertsRequiredFields]; + +const translationMapping: Record = { + caseIdConfig: i18n.SW_REQUIRED_CASE_ID, + alertIdConfig: i18n.SW_REQUIRED_ALERT_ID, + caseNameConfig: i18n.SW_REQUIRED_CASE_NAME, + descriptionConfig: i18n.SW_REQUIRED_DESCRIPTION, + commentsConfig: i18n.SW_REQUIRED_COMMENTS, + ruleNameConfig: i18n.SW_REQUIRED_RULE_NAME, + severityConfig: i18n.SW_REQUIRED_SEVERITY, +}; + +export const isValidFieldForConnector = ( + connector: SwimlaneConnectorType, + field: MappingConfigurationKeys +): boolean => { + if (connector === SwimlaneConnectorType.All) { + return true; + } + + return connector === SwimlaneConnectorType.Alerts + ? alertsFields.includes(field) + : casesFields.includes(field); +}; + +export const validateMappingForConnector = ( + connectorType: SwimlaneConnectorType, + mapping: SwimlaneMappingConfig +): Record => { + if (connectorType === SwimlaneConnectorType.All || connectorType == null) { + return {}; + } + + const requiredFields = + connectorType === SwimlaneConnectorType.Alerts ? alertsRequiredFields : casesRequiredFields; + + return requiredFields.reduce((errors, field) => { + if (mapping?.[field] == null) { + errors = { ...errors, [field]: translationMapping[field] }; + } + + return errors; + }, {} as Record); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/index.ts new file mode 100644 index 00000000000000..39a57e1bccb610 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/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 { getActionType as getSwimlaneActionType } from './swimlane'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/logo.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/logo.tsx new file mode 100644 index 00000000000000..d22ff809fe74dd --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/logo.tsx @@ -0,0 +1,53 @@ +/* + * Copyright 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'; + +const Logo = () => { + return ( + + + + + + + + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { Logo as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/mocks.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/mocks.ts new file mode 100644 index 00000000000000..1574dfe2f5384d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/mocks.ts @@ -0,0 +1,61 @@ +/* + * Copyright 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 applicationFields = [ + { + id: 'a6ide', + name: 'Alert Id', + key: 'alert-id', + fieldType: 'text', + }, + { + id: 'adnlas', + name: 'Severity', + key: 'severity', + fieldType: 'text', + }, + { + id: 'adnfls', + name: 'Rule Name', + key: 'rule-name', + fieldType: 'text', + }, + { + id: 'a6sst', + name: 'Case Id', + key: 'case-id-name', + fieldType: 'text', + }, + { + id: 'a6fst', + name: 'Case Name', + key: 'case-name', + fieldType: 'text', + }, + { + id: 'a6fdf', + name: 'Comments', + key: 'notes', + fieldType: 'comments', + }, + { + id: 'a6fde', + name: 'Description', + key: 'description', + fieldType: 'text', + }, +]; + +export const mappings = { + alertIdConfig: applicationFields[0], + severityConfig: applicationFields[1], + ruleNameConfig: applicationFields[2], + caseIdConfig: applicationFields[3], + caseNameConfig: applicationFields[4], + commentsConfig: applicationFields[5], + descriptionConfig: applicationFields[6], +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/index.ts new file mode 100644 index 00000000000000..ca7c39bf1378cc --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SwimlaneConnection } from './swimlane_connection'; +export { SwimlaneFields } from './swimlane_fields'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/swimlane_connection.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/swimlane_connection.tsx new file mode 100644 index 00000000000000..cd29037e3535fd --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/swimlane_connection.tsx @@ -0,0 +1,201 @@ +/* + * Copyright 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 { + EuiButton, + EuiCallOut, + EuiFieldText, + EuiFormRow, + EuiLink, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { FormattedMessage } from 'react-intl'; +import * as i18n from '../translations'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { useGetApplication } from '../use_get_application'; +import { SwimlaneActionConnector, SwimlaneFieldMappingConfig } from '../types'; +import { IErrorObject } from '../../../../../types'; + +interface Props { + action: SwimlaneActionConnector; + editActionConfig: (property: string, value: any) => void; + editActionSecrets: (property: string, value: any) => void; + errors: IErrorObject; + readOnly: boolean; + updateCurrentStep: (step: number) => void; + updateFields: (items: SwimlaneFieldMappingConfig[]) => void; +} + +const SwimlaneConnectionComponent: React.FunctionComponent = ({ + action, + editActionConfig, + editActionSecrets, + errors, + readOnly, + updateCurrentStep, + updateFields, +}) => { + const { + notifications: { toasts }, + } = useKibana().services; + const { apiUrl, appId } = action.config; + const { apiToken } = action.secrets; + const { docLinks } = useKibana().services; + const { getApplication } = useGetApplication({ + toastNotifications: toasts, + apiToken, + appId, + apiUrl, + }); + const isValid = apiUrl && apiToken && appId; + + const connectSwimlane = useCallback(async () => { + // fetch swimlane application configuration + const application = await getApplication(); + + if (application?.fields) { + const allFields = application.fields; + updateFields(allFields); + updateCurrentStep(2); + } + }, [getApplication, updateCurrentStep, updateFields]); + + const onChangeConfig = useCallback( + (e: React.ChangeEvent, key: 'apiUrl' | 'appId') => { + editActionConfig(key, e.target.value); + }, + [editActionConfig] + ); + + const onBlurConfig = useCallback( + (key: 'apiUrl' | 'appId') => { + if (!action.config[key]) { + editActionConfig(key, ''); + } + }, + [action.config, editActionConfig] + ); + + const onChangeSecrets = useCallback( + (e: React.ChangeEvent) => { + editActionSecrets('apiToken', e.target.value); + }, + [editActionSecrets] + ); + + const onBlurSecrets = useCallback(() => { + if (!apiToken) { + editActionSecrets('apiToken', ''); + } + }, [apiToken, editActionSecrets]); + + const isApiUrlInvalid = errors.apiUrl?.length > 0 && apiToken !== undefined; + const isAppIdInvalid = errors.appId?.length > 0 && apiToken !== undefined; + const isApiTokenInvalid = errors.apiToken?.length > 0 && apiToken !== undefined; + + return ( + <> + + onChangeConfig(e, 'apiUrl')} + onBlur={() => onBlurConfig('apiUrl')} + /> + + + onChangeConfig(e, 'appId')} + onBlur={() => onBlurConfig('appId')} + /> + + + + + } + error={errors.apiToken} + isInvalid={isApiTokenInvalid} + label={i18n.SW_API_TOKEN_TEXT_FIELD_LABEL} + > + <> + {!action.id ? ( + <> + + + {i18n.SW_REMEMBER_VALUE_LABEL} + + + + ) : ( + <> + + + + + )} + + + + + + {i18n.SW_RETRIEVE_CONFIGURATION_LABEL} + + + ); +}; + +export const SwimlaneConnection = React.memo(SwimlaneConnectionComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/swimlane_fields.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/swimlane_fields.tsx new file mode 100644 index 00000000000000..87d0964322e140 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/steps/swimlane_fields.tsx @@ -0,0 +1,313 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useCallback, useEffect, useRef } from 'react'; +import { + EuiButton, + EuiFormRow, + EuiComboBox, + EuiComboBoxOptionOption, + EuiButtonGroup, +} from '@elastic/eui'; +import * as i18n from '../translations'; +import { + SwimlaneActionConnector, + SwimlaneConnectorType, + SwimlaneFieldMappingConfig, + SwimlaneMappingConfig, +} from '../types'; +import { IErrorObject } from '../../../../../types'; +import { isValidFieldForConnector } from '../helpers'; + +const SINGLE_SELECTION = { asPlainText: true }; +const EMPTY_COMBO_BOX_ARRAY: Array> | undefined = []; + +const formatOption = (field: SwimlaneFieldMappingConfig) => ({ + label: `${field.name} (${field.key})`, + value: field.id, +}); + +const createSelectedOption = (field: SwimlaneFieldMappingConfig | null | undefined) => + field != null ? [formatOption(field)] : EMPTY_COMBO_BOX_ARRAY; + +interface Props { + action: SwimlaneActionConnector; + editActionConfig: (property: string, value: any) => void; + updateCurrentStep: (step: number) => void; + fields: SwimlaneFieldMappingConfig[]; + errors: IErrorObject; +} + +const connectorTypeButtons = [ + { id: 'all', label: 'All' }, + { id: 'alerts', label: 'Alerts' }, + { id: 'cases', label: 'Cases' }, +]; + +const SwimlaneFieldsComponent: React.FC = ({ + action, + editActionConfig, + updateCurrentStep, + fields, + errors, +}) => { + const { mappings, connectorType = SwimlaneConnectorType.All } = action.config; + const prevConnectorType = useRef(connectorType); + const hasChangedConnectorType = connectorType !== prevConnectorType.current; + + const [fieldTypeMap, fieldIdMap] = useMemo( + () => + fields.reduce( + ([typeMap, idMap], field) => { + if (field != null) { + typeMap.set(field.fieldType, [ + ...(typeMap.get(field.fieldType) ?? []), + formatOption(field), + ]); + idMap.set(field.id, field); + } + + return [typeMap, idMap]; + }, + [ + new Map>>(), + new Map(), + ] + ), + [fields] + ); + + const textOptions = useMemo(() => fieldTypeMap.get('text') ?? [], [fieldTypeMap]); + const commentsOptions = useMemo(() => fieldTypeMap.get('comments') ?? [], [fieldTypeMap]); + + const state = useMemo( + () => ({ + alertIdConfig: createSelectedOption(mappings?.alertIdConfig), + severityConfig: createSelectedOption(mappings?.severityConfig), + ruleNameConfig: createSelectedOption(mappings?.ruleNameConfig), + caseIdConfig: createSelectedOption(mappings?.caseIdConfig), + caseNameConfig: createSelectedOption(mappings?.caseNameConfig), + commentsConfig: createSelectedOption(mappings?.commentsConfig), + descriptionConfig: createSelectedOption(mappings?.descriptionConfig), + }), + [mappings] + ); + + const mappingErrors: Record = useMemo( + () => (Array.isArray(errors?.mappings) ? errors?.mappings[0] : {}), + [errors] + ); + + const resetConnection = useCallback(() => { + updateCurrentStep(1); + }, [updateCurrentStep]); + + const editMappings = useCallback( + (key: keyof SwimlaneMappingConfig, e: Array>) => { + if (e.length === 0) { + const newProps = { + ...mappings, + [key]: null, + }; + editActionConfig('mappings', newProps); + return; + } + + const option = e[0]; + const item = fieldIdMap.get(option.value ?? ''); + if (!item) { + return; + } + + const newProps = { + ...mappings, + [key]: { id: item.id, name: item.name, key: item.key, fieldType: item.fieldType }, + }; + editActionConfig('mappings', newProps); + }, + [editActionConfig, fieldIdMap, mappings] + ); + + /** + * Connector type needs to be updated on mount to All. + * Otherwise it is undefined and this will cause an error + * if the user saves the connector without any mapping + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => editActionConfig('connectorType', connectorType), []); + + useEffect(() => { + if (connectorType !== prevConnectorType.current) { + prevConnectorType.current = connectorType; + } + }, [connectorType]); + + return ( + <> + + editActionConfig('connectorType', type)} + buttonSize="compressed" + /> + + {isValidFieldForConnector(connectorType as SwimlaneConnectorType.All, 'alertIdConfig') && ( + <> + + editMappings('alertIdConfig', e)} + isInvalid={mappingErrors?.alertIdConfig != null && !hasChangedConnectorType} + /> + + + )} + {isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'ruleNameConfig') && ( + <> + + editMappings('ruleNameConfig', e)} + isInvalid={mappingErrors?.ruleNameConfig != null && !hasChangedConnectorType} + /> + + + )} + {isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'severityConfig') && ( + <> + + editMappings('severityConfig', e)} + isInvalid={mappingErrors?.severityConfig != null && !hasChangedConnectorType} + /> + + + )} + {isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'caseIdConfig') && ( + <> + + editMappings('caseIdConfig', e)} + isInvalid={mappingErrors?.caseIdConfig != null && !hasChangedConnectorType} + /> + + + )} + {isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'caseNameConfig') && ( + <> + + editMappings('caseNameConfig', e)} + isInvalid={mappingErrors?.caseNameConfig != null && !hasChangedConnectorType} + /> + + + )} + {isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'commentsConfig') && ( + <> + + editMappings('commentsConfig', e)} + isInvalid={mappingErrors?.commentsConfig != null && !hasChangedConnectorType} + /> + + + )} + {isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'descriptionConfig') && ( + <> + + editMappings('descriptionConfig', e)} + isInvalid={mappingErrors?.descriptionConfig != null && !hasChangedConnectorType} + /> + + + )} + {i18n.SW_CONFIGURE_API_LABEL} + + ); +}; + +export const SwimlaneFields = React.memo(SwimlaneFieldsComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.test.tsx new file mode 100644 index 00000000000000..07d78a8885c510 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.test.tsx @@ -0,0 +1,219 @@ +/* + * Copyright 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 { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '.././index'; +import { ActionTypeModel } from '../../../../types'; +import { SwimlaneActionConnector } from './types'; + +const ACTION_TYPE_ID = '.swimlane'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + }); +}); + +describe('swimlane connector validation', () => { + test('connector validation succeeds when connector is valid', async () => { + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'all', + mappings: { + alertIdConfig: { id: '1234' }, + severityConfig: { id: '1234' }, + ruleNameConfig: { id: '1234' }, + caseIdConfig: { id: '1234' }, + caseNameConfig: { id: '1234' }, + descriptionConfig: { id: '1234' }, + commentsConfig: { id: '1234' }, + }, + }, + } as SwimlaneActionConnector; + + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ + config: { errors: { apiUrl: [], appId: [], mappings: [], connectorType: [] } }, + secrets: { errors: { apiToken: [] } }, + }); + }); + + test('it validates correctly when connectorType=all', async () => { + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'all', + mappings: {}, + }, + } as SwimlaneActionConnector; + + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ + config: { errors: { apiUrl: [], appId: [], mappings: [], connectorType: [] } }, + secrets: { errors: { apiToken: [] } }, + }); + }); + + test('it validates correctly when connectorType=cases', async () => { + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'cases', + mappings: {}, + }, + } as SwimlaneActionConnector; + + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ + config: { + errors: { + apiUrl: [], + appId: [], + mappings: [ + { + caseIdConfig: 'Case ID is required.', + caseNameConfig: 'Case name is required.', + commentsConfig: 'Comments are required.', + descriptionConfig: 'Description is required.', + }, + ], + connectorType: [], + }, + }, + secrets: { errors: { apiToken: [] } }, + }); + }); + + test('it validates correctly when connectorType=alerts', async () => { + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'alerts', + mappings: {}, + }, + } as SwimlaneActionConnector; + + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ + config: { + errors: { + apiUrl: [], + appId: [], + mappings: [ + { + alertIdConfig: 'Alert ID is required.', + ruleNameConfig: 'Rule name is required.', + }, + ], + connectorType: [], + }, + }, + secrets: { errors: { apiToken: [] } }, + }); + }); + + test('it validates correctly required config/secrets fields', async () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: {}, + } as SwimlaneActionConnector; + + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ + config: { + errors: { + apiUrl: ['URL is required.'], + appId: ['An App ID is required.'], + mappings: [], + connectorType: [], + }, + }, + secrets: { errors: { apiToken: ['An API token is required.'] } }, + }); + }); +}); + +describe('swimlane action params validation', () => { + test('action params validation succeeds when action params is valid', async () => { + const actionParams = { + subActionParams: { + ruleName: 'Rule Name', + alertId: 'alert-id', + }, + }; + + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + 'subActionParams.incident.ruleName': [], + 'subActionParams.incident.alertId': [], + }, + }); + }); + + test('it validates correctly required fields', async () => { + const actionParams = { + subActionParams: { incident: {} }, + }; + + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + 'subActionParams.incident.ruleName': ['Rule name is required.'], + 'subActionParams.incident.alertId': ['Alert ID is required.'], + }, + }); + }); + + test('it succeeds when missing incident', async () => { + const actionParams = { + subActionParams: {}, + }; + + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + 'subActionParams.incident.ruleName': [], + 'subActionParams.incident.alertId': [], + }, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.tsx new file mode 100644 index 00000000000000..5e06e3935eebdd --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.tsx @@ -0,0 +1,106 @@ +/* + * Copyright 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 { isEmpty } from 'lodash'; +import { lazy } from 'react'; +import { + ActionTypeModel, + ConnectorValidationResult, + GenericValidationResult, +} from '../../../../types'; +import { + SwimlaneActionConnector, + SwimlaneConfig, + SwimlaneSecrets, + SwimlaneActionParams, +} from './types'; +import * as i18n from './translations'; +import { isValidUrl } from '../../../lib/value_validators'; +import { validateMappingForConnector } from './helpers'; + +export function getActionType(): ActionTypeModel< + SwimlaneConfig, + SwimlaneSecrets, + SwimlaneActionParams +> { + return { + id: '.swimlane', + iconClass: lazy(() => import('./logo')), + selectMessage: i18n.SW_SELECT_MESSAGE_TEXT, + actionTypeTitle: i18n.SW_ACTION_TYPE_TITLE, + validateConnector: async ( + action: SwimlaneActionConnector + ): Promise> => { + const configErrors = { + apiUrl: new Array(), + appId: new Array(), + connectorType: new Array(), + mappings: new Array>(), + }; + const secretsErrors = { + apiToken: new Array(), + }; + + const validationResult = { + config: { errors: configErrors }, + secrets: { errors: secretsErrors }, + }; + + if (!action.config.apiUrl) { + configErrors.apiUrl = [...configErrors.apiUrl, i18n.SW_API_URL_REQUIRED]; + } else if (action.config.apiUrl) { + if (!isValidUrl(action.config.apiUrl)) { + configErrors.apiUrl = [...configErrors.apiUrl, i18n.SW_API_URL_INVALID]; + } + } + + if (!action.secrets.apiToken) { + secretsErrors.apiToken = [...secretsErrors.apiToken, i18n.SW_REQUIRED_API_TOKEN_TEXT]; + } + + if (!action.config.appId) { + configErrors.appId = [...configErrors.appId, i18n.SW_REQUIRED_APP_ID_TEXT]; + } + + const mappingErrors = validateMappingForConnector( + action.config.connectorType, + action.config.mappings + ); + + if (!isEmpty(mappingErrors)) { + configErrors.mappings = [...configErrors.mappings, mappingErrors]; + } + + return validationResult; + }, + validateParams: async ( + actionParams: SwimlaneActionParams + ): Promise> => { + const errors = { + 'subActionParams.incident.ruleName': new Array(), + 'subActionParams.incident.alertId': new Array(), + }; + const validationResult = { + errors, + }; + + const hasIncident = actionParams.subActionParams && actionParams.subActionParams.incident; + + if (hasIncident && !actionParams.subActionParams.incident.ruleName?.length) { + errors['subActionParams.incident.ruleName'].push(i18n.SW_REQUIRED_RULE_NAME); + } + + if (hasIncident && !actionParams.subActionParams.incident.alertId?.length) { + errors['subActionParams.incident.alertId'].push(i18n.SW_REQUIRED_ALERT_ID); + } + + return validationResult; + }, + actionConnectorFields: lazy(() => import('./swimlane_connectors')), + actionParamsFields: lazy(() => import('./swimlane_params')), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx new file mode 100644 index 00000000000000..6740179d786f27 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx @@ -0,0 +1,319 @@ +/* + * Copyright 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 { mountWithIntl, nextTick } from '@kbn/test/jest'; +import { act } from 'react-dom/test-utils'; +import { SwimlaneActionConnector } from './types'; +import SwimlaneActionConnectorFields from './swimlane_connectors'; +import { useGetApplication } from './use_get_application'; +import { applicationFields, mappings } from './mocks'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('./use_get_application'); + +const useGetApplicationMock = useGetApplication as jest.Mock; +const getApplication = jest.fn(); + +describe('SwimlaneActionConnectorFields renders', () => { + beforeAll(() => { + useGetApplicationMock.mockReturnValue({ + getApplication, + isLoading: false, + }); + }); + + test('all connector fields are rendered', async () => { + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'all', + mappings, + }, + } as SwimlaneActionConnector; + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + /> + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="swimlaneApiUrlInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneAppIdInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneApiTokenInput"]').exists()).toBeTruthy(); + }); + + test('should display a message on create to remember credentials', () => { + const actionConnector = { + actionTypeId: '.swimlane', + secrets: {}, + config: {}, + } as SwimlaneActionConnector; + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toEqual(0); + }); + + test('should display a message on edit to re-enter credentials', () => { + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'all', + mappings, + }, + } as SwimlaneActionConnector; + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toEqual(0); + }); + + test('renders the mappings correctly - connector type all', async () => { + getApplication.mockResolvedValue({ + fields: applicationFields, + }); + + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'all', + mappings, + }, + } as SwimlaneActionConnector; + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + /> + ); + + await act(async () => { + wrapper.find('[data-test-subj="swimlaneConfigureMapping"]').first().simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="swimlaneAlertIdInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneAlertNameInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneSeverityInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneCaseIdConfig"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneCaseNameConfig"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneCommentsConfig"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneDescriptionConfig"]').exists()).toBeTruthy(); + }); + + test('renders the mappings correctly - connector type cases', async () => { + getApplication.mockResolvedValue({ + fields: applicationFields, + }); + + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'cases', + mappings, + }, + } as SwimlaneActionConnector; + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + /> + ); + + await act(async () => { + wrapper.find('[data-test-subj="swimlaneConfigureMapping"]').first().simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="swimlaneAlertIdInput"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="swimlaneAlertNameInput"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="swimlaneSeverityInput"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="swimlaneCaseIdConfig"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneCaseNameConfig"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneCommentsConfig"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneDescriptionConfig"]').exists()).toBeTruthy(); + }); + + test('renders the mappings correctly - connector type alerts', async () => { + getApplication.mockResolvedValue({ + fields: applicationFields, + }); + + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'alerts', + mappings, + }, + } as SwimlaneActionConnector; + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + /> + ); + + await act(async () => { + wrapper.find('[data-test-subj="swimlaneConfigureMapping"]').first().simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="swimlaneAlertIdInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneAlertNameInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneSeverityInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneCaseIdConfig"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="swimlaneCaseNameConfig"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="swimlaneCommentsConfig"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="swimlaneDescriptionConfig"]').exists()).toBeFalsy(); + }); + + test('renders the correct options per field', async () => { + getApplication.mockResolvedValue({ + fields: applicationFields, + }); + + const actionConnector = { + secrets: { + apiToken: 'test', + }, + id: 'test', + actionTypeId: '.swimlane', + name: 'swimlane', + config: { + apiUrl: 'http:\\test', + appId: '1234567asbd32', + connectorType: 'all', + mappings, + }, + } as SwimlaneActionConnector; + + const textOptions = [ + { label: 'Alert Id (alert-id)', value: 'a6ide' }, + { label: 'Severity (severity)', value: 'adnlas' }, + { label: 'Rule Name (rule-name)', value: 'adnfls' }, + { label: 'Case Id (case-id-name)', value: 'a6sst' }, + { label: 'Case Name (case-name)', value: 'a6fst' }, + { label: 'Description (description)', value: 'a6fde' }, + ]; + + const commentOptions = [{ label: 'Comments (notes)', value: 'a6fdf' }]; + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + /> + ); + + await act(async () => { + wrapper.find('[data-test-subj="swimlaneConfigureMapping"]').first().simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="swimlaneAlertIdInput"]').first().prop('options')).toEqual( + textOptions + ); + expect( + wrapper.find('[data-test-subj="swimlaneAlertNameInput"]').first().prop('options') + ).toEqual(textOptions); + expect( + wrapper.find('[data-test-subj="swimlaneSeverityInput"]').first().prop('options') + ).toEqual(textOptions); + expect(wrapper.find('[data-test-subj="swimlaneCaseIdConfig"]').first().prop('options')).toEqual( + textOptions + ); + expect( + wrapper.find('[data-test-subj="swimlaneCaseNameConfig"]').first().prop('options') + ).toEqual(textOptions); + expect( + wrapper.find('[data-test-subj="swimlaneCommentsConfig"]').first().prop('options') + ).toEqual(commentOptions); + expect( + wrapper.find('[data-test-subj="swimlaneDescriptionConfig"]').first().prop('options') + ).toEqual(textOptions); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.tsx new file mode 100644 index 00000000000000..acf9f38e9ba48b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.tsx @@ -0,0 +1,103 @@ +/* + * Copyright 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, { Fragment, useCallback, useMemo, useState } from 'react'; +import { EuiForm, EuiSpacer, EuiStepsHorizontal, EuiStepStatus } from '@elastic/eui'; +import * as i18n from './translations'; +import { ActionConnectorFieldsProps } from '../../../../types'; +import { SwimlaneActionConnector, SwimlaneFieldMappingConfig } from './types'; +import { SwimlaneConnection, SwimlaneFields } from './steps'; + +const SwimlaneActionConnectorFields: React.FunctionComponent< + ActionConnectorFieldsProps +> = ({ errors, action, editActionConfig, editActionSecrets, readOnly }) => { + const [currentStep, setCurrentStep] = useState(1); + const [stepsStatuses, setStepsStatuses] = useState<{ + connection: EuiStepStatus; + fields: EuiStepStatus; + }>({ connection: 'incomplete', fields: 'incomplete' }); + const [fields, setFields] = useState([]); + + const updateCurrentStep = useCallback( + (step: number) => { + setCurrentStep(step); + if (step === 2) { + setStepsStatuses((statuses) => ({ ...statuses, connection: 'complete' })); + } else if (step === 1) { + setStepsStatuses({ + fields: 'incomplete', + connection: 'incomplete', + }); + editActionConfig('mappings', action.config.mappings); + } + }, + [action.config.mappings, editActionConfig] + ); + + const setupSteps = useMemo( + () => [ + { + title: i18n.SW_CONFIGURE_CONNECTION_LABEL, + status: stepsStatuses.connection, + onClick: () => updateCurrentStep(1), + }, + { + title: i18n.SW_MAPPING_TITLE_TEXT_FIELD_LABEL, + disabled: stepsStatuses.connection !== 'complete', + status: stepsStatuses.fields, + onClick: () => updateCurrentStep(2), + }, + ], + [stepsStatuses.connection, stepsStatuses.fields, updateCurrentStep] + ); + + const editActionConfigCb = useCallback( + (k: string, v: string) => { + editActionConfig(k, v); + if ( + Object.values(errors?.mappings ?? {}).every((mappingError) => mappingError.length === 0) + ) { + setStepsStatuses((statuses) => ({ ...statuses, fields: 'complete' })); + } else { + setStepsStatuses((statuses) => ({ ...statuses, fields: 'incomplete' })); + } + }, + [editActionConfig, errors?.mappings] + ); + + return ( + + + + + {currentStep === 1 && ( + + )} + {currentStep === 2 && ( + + )} + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SwimlaneActionConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_params.test.tsx new file mode 100644 index 00000000000000..32cf2c3c786d39 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_params.test.tsx @@ -0,0 +1,137 @@ +/* + * Copyright 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 { mountWithIntl } from '@kbn/test/jest'; +import SwimlaneParamsFields from './swimlane_params'; +import { SwimlaneConnectorType } from './types'; +import { mappings } from './mocks'; + +describe('SwimlaneParamsFields renders', () => { + const editAction = jest.fn(); + const actionParams = { + subAction: 'pushToService', + subActionParams: { + incident: { + alertId: '3456789', + ruleName: 'rule name', + severity: 'critical', + caseId: null, + caseName: null, + description: null, + externalId: null, + }, + comments: [], + }, + }; + + const connector = { + secrets: {}, + config: { mappings, connectorType: SwimlaneConnectorType.All }, + id: 'test', + actionTypeId: '.test', + name: 'Test', + isPreconfigured: false, + }; + + const defaultProps = { + actionParams, + errors: { + 'subActionParams.incident.ruleName': [], + 'subActionParams.incident.alertId': [], + }, + editAction, + index: 0, + messageVariables: [], + actionConnector: connector, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('all params fields are rendered', () => { + const wrapper = mountWithIntl(); + + expect(wrapper.find('[data-test-subj="severity"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="comments"]').exists()).toBeTruthy(); + }); + + test('it set the correct default params', () => { + mountWithIntl(); + expect(editAction).toHaveBeenCalledWith('subAction', 'pushToService', 0); + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { + incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' }, + comments: [], + }, + 0 + ); + }); + + test('it reset the fields when connector changes', () => { + const wrapper = mountWithIntl(); + expect(editAction).not.toHaveBeenCalled(); + + wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { + incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' }, + comments: [], + }, + 0 + ); + }); + + test('it set the severity', () => { + const wrapper = mountWithIntl(); + expect(editAction).not.toHaveBeenCalled(); + + wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { + incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' }, + comments: [], + }, + 0 + ); + }); + + describe('UI updates', () => { + const changeEvent = { target: { value: 'Bug' } } as React.ChangeEvent; + const simpleFields = [ + { dataTestSubj: 'input[data-test-subj="severityInput"]', key: 'severity' }, + ]; + + simpleFields.forEach((field) => + test(`${field.key} update triggers editAction`, () => { + const wrapper = mountWithIntl(); + const theField = wrapper.find(field.dataTestSubj).first(); + theField.prop('onChange')!(changeEvent); + expect(editAction.mock.calls[0][1].incident[field.key]).toEqual(changeEvent.target.value); + }) + ); + + test('A comment triggers editAction', () => { + const wrapper = mountWithIntl(); + const comments = wrapper.find('textarea[data-test-subj="commentsTextArea"]'); + expect(comments.simulate('change', changeEvent)); + expect(editAction.mock.calls[0][1].comments.length).toEqual(1); + }); + + test('An empty comment does not trigger editAction', () => { + const wrapper = mountWithIntl(); + const emptyComment = { target: { value: '' } }; + const comments = wrapper.find('[data-test-subj="commentsTextArea"] textarea'); + expect(comments.simulate('change', emptyComment)); + expect(editAction.mock.calls.length).toEqual(0); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_params.tsx new file mode 100644 index 00000000000000..9bd14a06d657a3 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_params.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useRef, useMemo } from 'react'; +import { EuiCallOut, EuiFormRow, EuiSpacer } from '@elastic/eui'; +import * as i18n from './translations'; +import { ActionParamsProps } from '../../../../types'; +import { SwimlaneActionConnector, SwimlaneActionParams, SwimlaneConnectorType } from './types'; +import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; +import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; + +const SwimlaneParamsFields: React.FunctionComponent> = ({ + actionParams, + editAction, + index, + messageVariables, + actionConnector, +}) => { + const { incident, comments } = useMemo( + () => + actionParams.subActionParams ?? + (({ + incident: {}, + comments: [], + } as unknown) as SwimlaneActionParams['subActionParams']), + [actionParams.subActionParams] + ); + + const actionConnectorRef = useRef(actionConnector?.id ?? ''); + + const { + mappings, + connectorType, + } = ((actionConnector as unknown) as SwimlaneActionConnector).config; + const { hasAlertId, hasRuleName, hasComments, hasSeverity } = useMemo( + () => ({ + hasAlertId: mappings.alertIdConfig != null, + hasRuleName: mappings.ruleNameConfig != null, + hasComments: mappings.commentsConfig != null, + hasSeverity: mappings.severityConfig != null, + }), + [ + mappings.alertIdConfig, + mappings.ruleNameConfig, + mappings.commentsConfig, + mappings.severityConfig, + ] + ); + + /** + * The user can use either a connector of type alerts or all. + * If the connector is of type all we should check if all + * required field have been configured. + */ + const showMappingWarning = + connectorType === SwimlaneConnectorType.Cases || !hasRuleName || !hasAlertId; + + const editSubActionProperty = useCallback( + (key: string, value: any) => { + if (key === 'comments') { + return editAction('subActionParams', { incident, comments: value }, index); + } + + return editAction( + 'subActionParams', + { + incident: { ...incident, [key]: value }, + comments, + }, + index + ); + }, + [editAction, incident, comments, index] + ); + + const editComment = useCallback( + (key, value) => { + if (value.length > 0) { + editSubActionProperty(key, [{ commentId: '1', comment: value }]); + } + }, + [editSubActionProperty] + ); + + useEffect(() => { + if (actionConnector != null && actionConnectorRef.current !== actionConnector.id) { + actionConnectorRef.current = actionConnector.id; + editAction( + 'subActionParams', + { + incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' }, + comments: [], + }, + index + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionConnector]); + + useEffect(() => { + if (!actionParams.subAction) { + editAction('subAction', 'pushToService', index); + } + + if (!actionParams.subActionParams) { + editAction( + 'subActionParams', + { + incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' }, + comments: [], + }, + index + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionParams]); + + return !showMappingWarning ? ( + <> + {hasSeverity && ( + <> + + + + + + )} + {hasComments && ( + 0 ? comments[0].comment : undefined} + label={i18n.SW_COMMENTS_FIELD_LABEL} + /> + )} + + ) : ( + + {i18n.EMPTY_MAPPING_WARNING_DESC} + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SwimlaneParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/translations.ts new file mode 100644 index 00000000000000..726997cb4456a3 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/translations.ts @@ -0,0 +1,282 @@ +/* + * Copyright 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 SW_SELECT_MESSAGE_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.selectMessageText', + { + defaultMessage: 'Create record in Swimlane', + } +); + +export const SW_ACTION_TYPE_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.actionTypeTitle', + { + defaultMessage: 'Create Swimlane Record', + } +); + +export const SW_REQUIRED_RULE_NAME = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredRuleName', + { + defaultMessage: 'Rule name is required.', + } +); + +export const SW_REQUIRED_APP_ID_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredAppIdText', + { + defaultMessage: 'An App ID is required.', + } +); + +export const SW_REQUIRED_FIELD_MAPPINGS_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredFieldMappingsText', + { + defaultMessage: 'Field mappings are required.', + } +); + +export const SW_REQUIRED_API_TOKEN_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredApiTokenText', + { + defaultMessage: 'An API token is required.', + } +); + +export const SW_GET_APPLICATION_API_ERROR = (id: string | null) => + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlane.unableToGetApplicationMessage', + { + defaultMessage: 'Unable to get application with id {id}', + values: { id }, + } + ); + +export const SW_GET_APPLICATION_API_NO_FIELDS_ERROR = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlane.unableToGetApplicationFieldsMessage', + { + defaultMessage: 'Unable to get application fields', + } +); + +export const SW_API_URL_TEXT_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.apiUrlTextFieldLabel', + { + defaultMessage: 'API Url', + } +); + +export const SW_API_URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.requiredApiUrlTextField', + { + defaultMessage: 'URL is required.', + } +); + +export const SW_API_URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.invalidApiUrlTextField', + { + defaultMessage: 'URL is invalid.', + } +); + +export const SW_APP_ID_TEXT_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.appIdTextFieldLabel', + { + defaultMessage: 'Application ID', + } +); + +export const SW_API_TOKEN_TEXT_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.apiTokenTextFieldLabel', + { + defaultMessage: 'API Token', + } +); + +export const SW_MAPPING_TITLE_TEXT_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.mappingTitleTextFieldLabel', + { + defaultMessage: 'Configure Field Mappings', + } +); + +export const SW_ALERT_SOURCE_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.alertSourceFieldLabel', + { + defaultMessage: 'Alert source', + } +); + +export const SW_SEVERITY_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.severityFieldLabel', + { + defaultMessage: 'Severity', + } +); + +export const SW_MAPPING_DESCRIPTION_TEXT_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.mappingDescriptionTextFieldLabel', + { + defaultMessage: 'Used to specify the field names in the Swimlane Application', + } +); + +export const SW_RULE_NAME_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.ruleNameFieldLabel', + { + defaultMessage: 'Rule name', + } +); + +export const SW_ALERT_ID_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.alertIdFieldLabel', + { + defaultMessage: 'Alert ID', + } +); + +export const SW_CASE_ID_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.caseIdFieldLabel', + { + defaultMessage: 'Case ID', + } +); + +export const SW_CASE_NAME_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.caseNameFieldLabel', + { + defaultMessage: 'Case name', + } +); + +export const SW_COMMENTS_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.commentsFieldLabel', + { + defaultMessage: 'Comments', + } +); + +export const SW_DESCRIPTION_FIELD_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.descriptionFieldLabel', + { + defaultMessage: 'Description', + } +); + +export const SW_REMEMBER_VALUE_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.rememberValueLabel', + { defaultMessage: 'Remember this value. You must reenter it each time you edit the connector.' } +); + +export const SW_REENTER_VALUE_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.reenterValueLabel', + { defaultMessage: 'This key is encrypted. Please reenter a value for this field.' } +); + +export const SW_CONFIGURE_CONNECTION_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.configureConnectionLabel', + { defaultMessage: 'Configure API Connection' } +); + +export const SW_RETRIEVE_CONFIGURATION_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.retrieveConfigurationLabel', + { defaultMessage: 'Configure Fields' } +); + +export const SW_CONFIGURE_API_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.configureAPILabel', + { defaultMessage: 'Configure API' } +); + +export const SW_CONNECTOR_TYPE_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.connectorType', + { + defaultMessage: 'Connector Type', + } +); + +export const SW_FIELD_MAPPING_IS_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.mappingFieldRequired', + { + defaultMessage: 'Field mapping is required.', + } +); + +export const EMPTY_MAPPING_WARNING_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.emptyMappingWarningTitle', + { + defaultMessage: 'This connector has missing field mappings', + } +); + +export const EMPTY_MAPPING_WARNING_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.emptyMappingWarningDesc', + { + defaultMessage: + 'This connector cannot be selected because it is missing the required case field mappings. You can edit this connector to add required field mappings or select a connector of type Alerts.', + } +); + +export const SW_REQUIRED_ALERT_SOURCE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredAlertSource', + { + defaultMessage: 'Alert source is required.', + } +); + +export const SW_REQUIRED_SEVERITY = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredSeverity', + { + defaultMessage: 'Severity is required.', + } +); + +export const SW_REQUIRED_CASE_NAME = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredCaseName', + { + defaultMessage: 'Case name is required.', + } +); + +export const SW_REQUIRED_CASE_ID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredCaseID', + { + defaultMessage: 'Case ID is required.', + } +); + +export const SW_REQUIRED_COMMENTS = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredComments', + { + defaultMessage: 'Comments are required.', + } +); + +export const SW_REQUIRED_DESCRIPTION = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredDescription', + { + defaultMessage: 'Description is required.', + } +); + +export const SW_REQUIRED_ALERT_ID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredAlertID', + { + defaultMessage: 'Alert ID is required.', + } +); + +export const SW_ALERT_SOURCE_TOOLTIP = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.alertSourceTooltip', + { + defaultMessage: 'The index of the alert. Use {index} in Detections.', + values: { index: '{{context.rule.output_index}}' }, + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/types.ts new file mode 100644 index 00000000000000..f0a54e8b6c3bfe --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/types.ts @@ -0,0 +1,56 @@ +/* + * Copyright 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 @kbn/eslint/no-restricted-paths */ + +import { UserConfiguredActionConnector } from '../../../../types'; +import { + ExecutorSubActionPushParams, + MappingConfigType, +} from '../../../../../../actions/server/builtin_action_types/swimlane/types'; + +export type SwimlaneActionConnector = UserConfiguredActionConnector< + SwimlaneConfig, + SwimlaneSecrets +>; + +export interface SwimlaneConfig { + apiUrl: string; + appId: string; + connectorType: SwimlaneConnectorType; + mappings: SwimlaneMappingConfig; +} + +export type MappingConfigurationKeys = keyof MappingConfigType; +export type SwimlaneMappingConfig = Record; + +export interface SwimlaneFieldMappingConfig { + id: string; + key: string; + name: string; + fieldType: string; +} + +export interface SwimlaneSecrets { + apiToken: string; +} + +export interface SwimlaneActionParams { + subAction: string; + subActionParams: ExecutorSubActionPushParams; +} + +export interface SwimlaneFieldMap { + key: string; + name: string; +} + +export enum SwimlaneConnectorType { + All = 'all', + Alerts = 'alerts', + Cases = 'cases', +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/use_get_application.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/use_get_application.test.tsx new file mode 100644 index 00000000000000..4744c4d22fdc93 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/use_get_application.test.tsx @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { getApplication } from './api'; +import { SwimlaneActionConnector } from './types'; +import { useGetApplication, UseGetApplication } from './use_get_application'; + +jest.mock('./api'); +jest.mock('../../../../common/lib/kibana'); + +const useKibanaMock = useKibana as jest.Mocked; +const getApplicationMock = getApplication as jest.Mock; + +const action = { + secrets: { apiToken: 'token' }, + id: 'test', + actionTypeId: '.swimlane', + name: 'Swimlane', + isPreconfigured: false, + config: { + apiUrl: 'https://test.swimlane.com/', + appId: 'bcq16kdTbz5jlwM6h', + mappings: {}, + }, +} as SwimlaneActionConnector; + +describe('useGetApplication', () => { + const { services } = useKibanaMock(); + getApplicationMock.mockResolvedValue({ + data: { fields: [] }, + }); + const abortCtrl = new AbortController(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetApplication({ + appId: action.config.appId, + apiToken: action.secrets.apiToken, + apiUrl: action.config.apiUrl, + toastNotifications: services.notifications.toasts, + }) + ); + + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + getApplication: result.current.getApplication, + }); + }); + }); + + it('calls getApplication with correct arguments', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetApplication({ + appId: action.config.appId, + apiToken: action.secrets.apiToken, + apiUrl: action.config.apiUrl, + toastNotifications: services.notifications.toasts, + }) + ); + + await waitForNextUpdate(); + + result.current.getApplication(); + await waitForNextUpdate(); + expect(getApplicationMock).toBeCalledWith({ + signal: abortCtrl.signal, + appId: action.config.appId, + apiToken: action.secrets.apiToken, + url: action.config.apiUrl, + }); + }); + }); + + it('get application', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetApplication({ + appId: action.config.appId, + apiToken: action.secrets.apiToken, + apiUrl: action.config.apiUrl, + toastNotifications: services.notifications.toasts, + }) + ); + + await waitForNextUpdate(); + result.current.getApplication(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + isLoading: false, + getApplication: result.current.getApplication, + }); + }); + }); + + it('set isLoading to true when getting the application', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetApplication({ + appId: action.config.appId, + apiToken: action.secrets.apiToken, + apiUrl: action.config.apiUrl, + toastNotifications: services.notifications.toasts, + }) + ); + + await waitForNextUpdate(); + result.current.getApplication(); + + expect(result.current.isLoading).toBe(true); + }); + }); + + it('it displays an error when http throws an error', async () => { + getApplicationMock.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetApplication({ + appId: action.config.appId, + apiToken: action.secrets.apiToken, + apiUrl: action.config.apiUrl, + toastNotifications: services.notifications.toasts, + }) + ); + await waitForNextUpdate(); + result.current.getApplication(); + + expect(result.current).toEqual({ + isLoading: false, + getApplication: result.current.getApplication, + }); + + expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({ + title: 'Unable to get application with id bcq16kdTbz5jlwM6h', + text: 'Something went wrong', + }); + }); + }); + + it('it displays an error when the response does not contain the correct fields', async () => { + getApplicationMock.mockResolvedValue({}); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetApplication({ + appId: action.config.appId, + apiToken: action.secrets.apiToken, + apiUrl: action.config.apiUrl, + toastNotifications: services.notifications.toasts, + }) + ); + await waitForNextUpdate(); + result.current.getApplication(); + await waitForNextUpdate(); + + expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({ + title: 'Unable to get application with id bcq16kdTbz5jlwM6h', + text: 'Unable to get application fields', + }); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/use_get_application.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/use_get_application.tsx new file mode 100644 index 00000000000000..f18770067b8a86 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/use_get_application.tsx @@ -0,0 +1,82 @@ +/* + * Copyright 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, useRef } from 'react'; +import { ToastsApi } from 'kibana/public'; +import { getApplication as getApplicationApi } from './api'; +import * as i18n from './translations'; +import { SwimlaneFieldMappingConfig } from './types'; + +interface Props { + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + appId: string; + apiToken: string; + apiUrl: string; +} + +export interface UseGetApplication { + getApplication: () => Promise<{ fields?: SwimlaneFieldMappingConfig[] } | undefined>; + isLoading: boolean; +} + +export const useGetApplication = ({ + toastNotifications, + appId, + apiToken, + apiUrl, +}: Props): UseGetApplication => { + const [isLoading, setIsLoading] = useState(false); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + + const getApplication = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + setIsLoading(true); + + const data = await getApplicationApi({ + signal: abortCtrlRef.current.signal, + appId, + apiToken, + url: apiUrl, + }); + + if (!isCancelledRef.current) { + setIsLoading(false); + if (!data.fields) { + // If the response was malformed and fields doesn't exist, show an error toast + toastNotifications.addDanger({ + title: i18n.SW_GET_APPLICATION_API_ERROR(appId), + text: i18n.SW_GET_APPLICATION_API_NO_FIELDS_ERROR, + }); + return; + } + return data; + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.SW_GET_APPLICATION_API_ERROR(appId), + text: error.message, + }); + } + setIsLoading(false); + } + } + }, [apiToken, apiUrl, appId, toastNotifications]); + + return { + isLoading, + getApplication, + }; +}; diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/swimlane.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/swimlane.ts new file mode 100644 index 00000000000000..95e041bbeb03a7 --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/swimlane.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { + ExternalServiceSimulator, + getExternalServiceSimulatorPath, +} from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +// eslint-disable-next-line import/no-default-export +export default function swimlaneTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + const mockSwimlane = { + name: 'A swimlane action', + actionTypeId: '.swimlane', + config: { + apiUrl: 'http://swimlane.mynonexistent.co', + appId: '123456asdf', + connectorType: 'all', + mappings: { + severityConfig: { + id: 'adnlas', + name: 'Severity', + key: 'severity', + fieldType: 'text', + }, + ruleNameConfig: { + id: 'adnfls', + name: 'Rule Name', + key: 'rule-name', + fieldType: 'text', + }, + caseIdConfig: { + id: 'a6sst', + name: 'Case Id', + key: 'case-id-name', + fieldType: 'text', + }, + caseNameConfig: { + id: 'a6fst', + name: 'Case Name', + key: 'case-name', + fieldType: 'text', + }, + commentsConfig: { + id: 'a6fdf', + name: 'Comments', + key: 'comments', + fieldType: 'text', + }, + }, + }, + secrets: { + apiToken: 'swimlane-api-key', + }, + }; + + describe('swimlane', () => { + let swimlaneSimulatorURL: string = ''; + + // need to wait for kibanaServer to settle ... + before(() => { + swimlaneSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.SWIMLANE) + ); + }); + it('should return 403 when creating a swimlane action', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + ...mockSwimlane, + config: { + ...mockSwimlane.config, + apiUrl: swimlaneSimulatorURL, + }, + }) + .expect(403, { + statusCode: 403, + error: 'Forbidden', + message: + 'Action type .swimlane is disabled because your basic license does not support it. Please upgrade your license.', + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts index 3f0524750d5f88..21cb0db3057bbe 100644 --- a/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts @@ -14,6 +14,7 @@ export default function actionsTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./builtin_action_types/es_index')); loadTestFile(require.resolve('./builtin_action_types/jira')); loadTestFile(require.resolve('./builtin_action_types/pagerduty')); + loadTestFile(require.resolve('./builtin_action_types/swimlane')); loadTestFile(require.resolve('./builtin_action_types/server_log')); loadTestFile(require.resolve('./builtin_action_types/servicenow')); loadTestFile(require.resolve('./builtin_action_types/slack')); diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 61b452fc118358..3dcbde5f21149a 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -31,6 +31,7 @@ const enabledActionTypes = [ '.email', '.index', '.pagerduty', + '.swimlane', '.server-log', '.servicenow', '.jira', diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index 878507bcf4afc3..a479070c824f23 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -13,6 +13,7 @@ import { PluginSetupContract as FeaturesPluginSetup } from '../../../../../../.. import { PluginSetupContract as ActionsPluginSetupContract } from '../../../../../../../plugins/actions/server/plugin'; import { ActionType } from '../../../../../../../plugins/actions/server'; import { initPlugin as initPagerduty } from './pagerduty_simulation'; +import { initPlugin as initSwimlane } from './swimlane_simulation'; import { initPlugin as initServiceNow } from './servicenow_simulation'; import { initPlugin as initJira } from './jira_simulation'; import { initPlugin as initResilient } from './resilient_simulation'; @@ -23,6 +24,7 @@ export const NAME = 'actions-FTS-external-service-simulators'; export enum ExternalServiceSimulator { PAGERDUTY = 'pagerduty', + SWIMLANE = 'swimlane', SERVICENOW = 'servicenow', SLACK = 'slack', JIRA = 'jira', @@ -66,6 +68,10 @@ export async function getSlackServer(): Promise { return await initSlack(); } +export async function getSwimlaneServer(): Promise { + return await initSwimlane(); +} + interface FixtureSetupDeps { actions: ActionsPluginSetupContract; features: FeaturesPluginSetup; diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts new file mode 100644 index 00000000000000..afba550908ddcd --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import http from 'http'; + +export const initPlugin = async () => http.createServer(handler); + +const sendResponse = (response: http.ServerResponse, data: any) => { + response.statusCode = 200; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(data, null, 4)); +}; + +const handler = (request: http.IncomingMessage, response: http.ServerResponse) => { + if (request.method === 'POST') { + return sendResponse(response, { + id: 'wowzeronza', + name: 'ET-69', + createdDate: '2021-06-01T17:29:51.092Z', + }); + } + + if (request.method === 'PATCH') { + return sendResponse(response, { + id: 'wowzeronza', + name: 'ET-69', + modifiedDate: '2021-06-01T17:29:51.092Z', + }); + } + + // Return an 400 error if http method is not supported + response.statusCode = 400; + response.setHeader('Content-Type', 'application/json'); + response.end('Not supported http method to request slack simulator'); +}; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/swimlane.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/swimlane.ts new file mode 100644 index 00000000000000..92e99a9d504f31 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/swimlane.ts @@ -0,0 +1,482 @@ +/* + * Copyright 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 httpProxy from 'http-proxy'; +import expect from '@kbn/expect'; +import getPort from 'get-port'; +import http from 'http'; + +import { getHttpProxyServer } from '../../../../common/lib/get_proxy_server'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { getSwimlaneServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +// eslint-disable-next-line import/no-default-export +export default function swimlaneTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const configService = getService('config'); + + const mockSwimlane = { + name: 'A swimlane action', + actionTypeId: '.swimlane', + config: { + apiUrl: 'http://swimlane.mynonexistent.com', + appId: '123456asdf', + connectorType: 'all', + mappings: { + alertIdConfig: { + id: 'ednjls', + name: 'Alert id', + key: 'alert-id', + fieldType: 'text', + }, + severityConfig: { + id: 'adnlas', + name: 'Severity', + key: 'severity', + fieldType: 'text', + }, + ruleNameConfig: { + id: 'adnfls', + name: 'Rule Name', + key: 'rule-name', + fieldType: 'text', + }, + caseIdConfig: { + id: 'a6sst', + name: 'Case Id', + key: 'case-id-name', + fieldType: 'text', + }, + caseNameConfig: { + id: 'a6fst', + name: 'Case Name', + key: 'case-name', + fieldType: 'text', + }, + commentsConfig: { + id: 'a6fdf', + name: 'Comments', + key: 'comments', + fieldType: 'notes', + }, + descriptionConfig: { + id: 'a6fdf', + name: 'Description', + key: 'description', + fieldType: 'text', + }, + }, + }, + secrets: { + apiToken: 'swimlane-api-key', + }, + params: { + subAction: 'pushToService', + subActionParams: { + incident: { + alertId: 'fs345f78g', + ruleName: 'Rule Name', + severity: 'Critical', + caseName: 'Case Name', + caseId: 'es3456789', + description: 'This is a description', + externalId: null, + }, + comments: [ + { + comment: 'first comment', + commentId: '123', + }, + ], + }, + }, + }; + + describe('Swimlane', () => { + let simulatedActionId = ''; + let swimlaneSimulatorURL: string = ''; + let swimlaneServer: http.Server; + let proxyServer: httpProxy | undefined; + let proxyHaveBeenCalled = false; + + before(async () => { + swimlaneServer = await getSwimlaneServer(); + const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) }); + if (!swimlaneServer.listening) { + swimlaneServer.listen(availablePort); + } + swimlaneSimulatorURL = `http://localhost:${availablePort}`; + proxyServer = await getHttpProxyServer( + swimlaneSimulatorURL, + configService.get('kbnTestServer.serverArgs'), + () => { + proxyHaveBeenCalled = true; + } + ); + }); + + after(() => { + swimlaneServer.close(); + if (proxyServer) { + proxyServer.close(); + } + }); + + describe('Swimlane - Action Creation', () => { + it('should return 200 when creating a swimlane action successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A swimlane action', + connector_type_id: '.swimlane', + config: { + ...mockSwimlane.config, + apiUrl: swimlaneSimulatorURL, + }, + secrets: mockSwimlane.secrets, + }) + .expect(200); + + expect(createdAction).to.eql({ + config: { + ...mockSwimlane.config, + apiUrl: swimlaneSimulatorURL, + }, + connector_type_id: '.swimlane', + id: createdAction.id, + is_missing_secrets: false, + is_preconfigured: false, + name: 'A swimlane action', + }); + + expect(typeof createdAction.id).to.be('string'); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + is_preconfigured: false, + is_missing_secrets: false, + name: 'A swimlane action', + connector_type_id: '.swimlane', + config: { + ...mockSwimlane.config, + apiUrl: swimlaneSimulatorURL, + }, + }); + }); + + it('should respond with a 400 Bad Request when creating a swimlane action with no apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A swimlane action', + connector_type_id: '.swimlane', + config: { + appId: mockSwimlane.config.appId, + mappings: mockSwimlane.config.mappings, + }, + secrets: mockSwimlane.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a swimlane action with no appId', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A swimlane action', + connector_type_id: '.swimlane', + config: { + mappings: mockSwimlane.config.mappings, + apiUrl: swimlaneSimulatorURL, + }, + secrets: mockSwimlane.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [appId]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a swimlane action without secrets', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A swimlane action', + connector_type_id: '.swimlane', + config: { + ...mockSwimlane.config, + apiUrl: swimlaneSimulatorURL, + }, + secrets: {}, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [apiToken]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request default swimlane url is not present in allowedHosts', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A swimlane action', + connector_type_id: '.swimlane', + config: mockSwimlane.config, + secrets: mockSwimlane.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: `error validating action type config: error configuring connector action: target url "${mockSwimlane.config.apiUrl}" is not added to the Kibana config xpack.actions.allowedHosts`, + }); + }); + }); + }); + + describe('Swimlane - Executor', () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A swimlane simulator', + connector_type_id: '.swimlane', + config: { + ...mockSwimlane.config, + apiUrl: swimlaneSimulatorURL, + }, + secrets: mockSwimlane.secrets, + }); + simulatedActionId = body.id; + }); + + describe('Validation', () => { + it('should handle failing with a simulated success without action', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .then((resp: any) => { + expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); + expect(resp.body.connector_id).to.eql(simulatedActionId); + expect(resp.body.status).to.eql('error'); + expect(resp.body.retry).to.eql(false); + // Node.js 12 oddity: + // + // The first time after the server is booted, the error message will be: + // + // undefined is not iterable (cannot read property Symbol(Symbol.iterator)) + // + // After this, the error will be: + // + // Cannot destructure property 'value' of 'undefined' as it is undefined. + // + // The error seems to come from the exact same place in the code based on the + // exact same circomstances: + // + // https://github.com/elastic/kibana/blob/b0a223ebcbac7e404e8ae6da23b2cc6a4b509ff1/packages/kbn-config-schema/src/types/literal_type.ts#L28 + // + // What triggers the error is that the `handleError` function expects its 2nd + // argument to be an object containing a `valids` property of type array. + // + // In this test the object does not contain a `valids` property, so hence the + // error. + // + // Why the error message isn't the same in all scenarios is unknown to me and + // could be a bug in V8. + expect(resp.body.message).to.match( + /^error validating action params: (undefined is not iterable \(cannot read property Symbol\(Symbol.iterator\)\)|Cannot destructure property 'value' of 'undefined' as it is undefined\.)$/ + ); + }); + }); + + it('should handle failing with a simulated success without unsupported action', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'non-supported' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: [subAction]: expected value to equal [pushToService]', + }); + }); + }); + + /** + * All subActionParams are optional. + * If subActionParams is not provided all + * the subActionParams attributes will be set to null + * and the validation will succeed. For that reason, + * the subActionParams need to be set to null. + */ + it('should handle failing with a simulated success without subActionParams', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'pushToService', subActionParams: null }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: [subActionParams]: expected a plain object value, but found [null] instead.', + }); + }); + }); + + it('should handle failing with a simulated success without commentId', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockSwimlane.params, + subActionParams: { + ...mockSwimlane.params.subActionParams, + comments: [{ comment: 'comment' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: [subActionParams.comments]: types that failed validation:\n- [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n- [subActionParams.comments.1]: expected value to equal [null]', + }); + }); + }); + + it('should handle failing with a simulated success without comment message', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockSwimlane.params, + subActionParams: { + ...mockSwimlane.params.subActionParams, + comments: [{ commentId: 'success' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: [subActionParams.comments]: types that failed validation:\n- [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n- [subActionParams.comments.1]: expected value to equal [null]', + }); + }); + }); + }); + + describe('Execution', () => { + it('should handle creating an incident', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockSwimlane.params, + subActionParams: { + ...mockSwimlane.params.subActionParams, + comments: [], + }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(body).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + id: 'wowzeronza', + title: 'ET-69', + pushedDate: '2021-06-01T17:29:51.092Z', + url: `${swimlaneSimulatorURL}/record/123456asdf/wowzeronza`, + }, + }); + }); + + it('should handle updating an incident', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockSwimlane.params, + subActionParams: { + incident: { + ...mockSwimlane.params.subActionParams.incident, + externalId: 'wowzeronza', + }, + comments: [], + }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(body).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + id: 'wowzeronza', + title: 'ET-69', + pushedDate: '2021-06-01T17:29:51.092Z', + url: `${swimlaneSimulatorURL}/record/123456asdf/wowzeronza`, + }, + }); + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts index b5ff287ac58f6f..db57af0ba1a98d 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts @@ -23,6 +23,7 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo loadTestFile(require.resolve('./builtin_action_types/es_index')); loadTestFile(require.resolve('./builtin_action_types/es_index_preconfigured')); loadTestFile(require.resolve('./builtin_action_types/pagerduty')); + loadTestFile(require.resolve('./builtin_action_types/swimlane')); loadTestFile(require.resolve('./builtin_action_types/server_log')); loadTestFile(require.resolve('./builtin_action_types/servicenow')); loadTestFile(require.resolve('./builtin_action_types/jira')); diff --git a/x-pack/test/case_api_integration/common/config.ts b/x-pack/test/case_api_integration/common/config.ts index 6c81f1fcfa2640..887e6e7894f98e 100644 --- a/x-pack/test/case_api_integration/common/config.ts +++ b/x-pack/test/case_api_integration/common/config.ts @@ -26,6 +26,7 @@ const enabledActionTypes = [ '.index', '.jira', '.pagerduty', + '.swimlane', '.resilient', '.server-log', '.servicenow', diff --git a/x-pack/test/detection_engine_api_integration/common/config.ts b/x-pack/test/detection_engine_api_integration/common/config.ts index 5cbf9598dc4a14..ef822b0af2a290 100644 --- a/x-pack/test/detection_engine_api_integration/common/config.ts +++ b/x-pack/test/detection_engine_api_integration/common/config.ts @@ -20,6 +20,7 @@ const enabledActionTypes = [ '.email', '.index', '.pagerduty', + '.swimlane', '.server-log', '.servicenow', '.slack', diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index 3ed382053f561f..b8010c089ad03f 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -16,6 +16,7 @@ const enabledActionTypes = [ '.email', '.index', '.pagerduty', + '.swimlane', '.servicenow', '.slack', '.webhook', From 77fe1c10870a3fb72eb3643d373c3ba0e7405a1a Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Wed, 23 Jun 2021 22:40:56 +0300 Subject: [PATCH 49/61] [Query] Use a minimal index pattern interface for es query (#102364) * Move JSON utils to utils package * Imports from tests * delete * split package * docs * test * test * imports * minimal index pattern * move some functions out and use miniaml ip in all es-kuery * docs * docs * rename Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...na-plugin-plugins-data-public.esfilters.md | 8 +++--- ...bana-plugin-plugins-data-public.eskuery.md | 2 +- ...bana-plugin-plugins-data-public.esquery.md | 2 +- ...lugins-data-public.iindexpattern.fields.md | 11 -------- ...in-plugins-data-public.iindexpattern.id.md | 11 -------- ...lugin-plugins-data-public.iindexpattern.md | 4 +-- ...na-plugin-plugins-data-server.esfilters.md | 8 +++--- ...bana-plugin-plugins-data-server.eskuery.md | 2 +- ...bana-plugin-plugins-data-server.esquery.md | 2 +- .../es_query/es_query/build_es_query.ts | 4 +-- .../es_query/es_query/filter_matches_index.ts | 5 ++-- .../common/es_query/es_query/from_filters.ts | 4 +-- .../common/es_query/es_query/from_kuery.ts | 6 ++--- .../es_query/handle_nested_filter.test.ts | 7 ++--- .../es_query/es_query/handle_nested_filter.ts | 4 +-- .../data/common/es_query/es_query/index.ts | 1 + .../es_query/es_query/migrate_filter.ts | 4 +-- .../data/common/es_query/es_query/types.ts | 14 ++++++++++ .../common/es_query/filters/build_filters.ts | 6 ++--- .../common/es_query/filters/exists_filter.ts | 5 ++-- .../data/common/es_query/filters/index.ts | 2 -- .../common/es_query/filters/phrase_filter.ts | 5 ++-- .../common/es_query/filters/phrases_filter.ts | 5 ++-- .../common/es_query/filters/range_filter.ts | 5 ++-- .../data/common/es_query/kuery/ast/ast.ts | 4 +-- .../common/es_query/kuery/functions/and.ts | 4 +-- .../common/es_query/kuery/functions/exists.ts | 4 +-- .../kuery/functions/geo_bounding_box.ts | 4 +-- .../es_query/kuery/functions/geo_polygon.ts | 4 +-- .../common/es_query/kuery/functions/is.ts | 4 +-- .../common/es_query/kuery/functions/nested.ts | 4 +-- .../common/es_query/kuery/functions/not.ts | 4 +-- .../common/es_query/kuery/functions/or.ts | 4 +-- .../common/es_query/kuery/functions/range.ts | 4 +-- .../kuery/functions/utils/get_fields.ts | 4 +-- .../utils/get_full_field_name_node.ts | 4 +-- .../es_query/kuery/node_types/function.ts | 4 +-- .../common/es_query/kuery/node_types/types.ts | 4 +-- .../data/common/index_patterns/types.ts | 5 ++-- src/plugins/data/public/index.ts | 2 +- src/plugins/data/public/public.api.md | 27 +++++++++---------- .../data/public/query/filter_manager/index.ts | 2 ++ .../filter_manager/lib}/get_display_value.ts | 3 +-- .../get_index_pattern_from_filter.test.ts | 0 .../lib}/get_index_pattern_from_filter.ts | 3 +-- .../apply_filter_popover_content.tsx | 4 +-- .../ui/filter_bar/filter_editor/index.tsx | 2 +- .../data/public/ui/filter_bar/filter_item.tsx | 3 +-- src/plugins/data/server/server.api.md | 12 ++++----- 49 files changed, 118 insertions(+), 128 deletions(-) delete mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.fields.md delete mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.id.md create mode 100644 src/plugins/data/common/es_query/es_query/types.ts rename src/plugins/data/{common/es_query/filters => public/query/filter_manager/lib}/get_display_value.ts (95%) rename src/plugins/data/{common/es_query/filters => public/query/filter_manager/lib}/get_index_pattern_from_filter.test.ts (100%) rename src/plugins/data/{common/es_query/filters => public/query/filter_manager/lib}/get_index_pattern_from_filter.ts (88%) diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md index 54b5a33ccf6822..2ca4847d6dc398 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md @@ -13,11 +13,11 @@ esFilters: { FILTERS: typeof FILTERS; FilterStateStore: typeof FilterStateStore; buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("../common").Filter; - buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IIndexPattern) => import("../common").PhrasesFilter; - buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IIndexPattern) => import("../common").ExistsFilter; - buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IIndexPattern) => import("../common").PhraseFilter; + buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").MinimalIndexPattern) => import("../common").PhrasesFilter; + buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").MinimalIndexPattern) => import("../common").ExistsFilter; + buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").MinimalIndexPattern) => import("../common").PhraseFilter; buildQueryFilter: (query: any, index: string, alias: string) => import("../common").QueryStringFilter; - buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; + buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").MinimalIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; isPhraseFilter: (filter: any) => filter is import("../common").PhraseFilter; isExistsFilter: (filter: any) => filter is import("../common").ExistsFilter; isPhrasesFilter: (filter: any) => filter is import("../common").PhrasesFilter; diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.eskuery.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.eskuery.md index 2cde2b74555851..881a1fa803ca65 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.eskuery.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.eskuery.md @@ -10,6 +10,6 @@ esKuery: { nodeTypes: import("../common/es_query/kuery/node_types").NodeTypes; fromKueryExpression: (expression: any, parseOptions?: Partial) => import("../common").KueryNode; - toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; + toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").MinimalIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; } ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquery.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquery.md index 2430e6a93bd2b0..70805aaaaee8ca 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquery.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquery.md @@ -10,7 +10,7 @@ esQuery: { buildEsQuery: typeof buildEsQuery; getEsQueryConfig: typeof getEsQueryConfig; - buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { + buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").MinimalIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { must: never[]; filter: import("../common").Filter[]; should: never[]; diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.fields.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.fields.md deleted file mode 100644 index 792bee44f96a85..00000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.fields.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IIndexPattern](./kibana-plugin-plugins-data-public.iindexpattern.md) > [fields](./kibana-plugin-plugins-data-public.iindexpattern.fields.md) - -## IIndexPattern.fields property - -Signature: - -```typescript -fields: IFieldType[]; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.id.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.id.md deleted file mode 100644 index 917a80975df6c0..00000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.id.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IIndexPattern](./kibana-plugin-plugins-data-public.iindexpattern.md) > [id](./kibana-plugin-plugins-data-public.iindexpattern.id.md) - -## IIndexPattern.id property - -Signature: - -```typescript -id?: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md index bf7f88ab370395..88d8520a373c69 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md @@ -12,7 +12,7 @@ Signature: ```typescript -export interface IIndexPattern +export interface IIndexPattern extends MinimalIndexPattern ``` ## Properties @@ -20,9 +20,7 @@ export interface IIndexPattern | Property | Type | Description | | --- | --- | --- | | [fieldFormatMap](./kibana-plugin-plugins-data-public.iindexpattern.fieldformatmap.md) | Record<string, SerializedFieldFormat<unknown> | undefined> | | -| [fields](./kibana-plugin-plugins-data-public.iindexpattern.fields.md) | IFieldType[] | | | [getFormatterForField](./kibana-plugin-plugins-data-public.iindexpattern.getformatterforfield.md) | (field: IndexPatternField | IndexPatternField['spec'] | IFieldType) => FieldFormat | Look up a formatter for a given field | -| [id](./kibana-plugin-plugins-data-public.iindexpattern.id.md) | string | | | [timeFieldName](./kibana-plugin-plugins-data-public.iindexpattern.timefieldname.md) | string | | | [title](./kibana-plugin-plugins-data-public.iindexpattern.title.md) | string | | | [type](./kibana-plugin-plugins-data-public.iindexpattern.type.md) | string | Type is used for identifying rollup indices, otherwise left undefined | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md index d7e80d94db4e64..d951cb24269435 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md @@ -11,11 +11,11 @@ esFilters: { buildQueryFilter: (query: any, index: string, alias: string) => import("../common").QueryStringFilter; buildCustomFilter: typeof buildCustomFilter; buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("../common").Filter; - buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IIndexPattern) => import("../common").ExistsFilter; + buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").MinimalIndexPattern) => import("../common").ExistsFilter; buildFilter: typeof buildFilter; - buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IIndexPattern) => import("../common").PhraseFilter; - buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IIndexPattern) => import("../common").PhrasesFilter; - buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; + buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").MinimalIndexPattern) => import("../common").PhraseFilter; + buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").MinimalIndexPattern) => import("../common").PhrasesFilter; + buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").MinimalIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; isFilterDisabled: (filter: import("../common").Filter) => boolean; } ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.eskuery.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.eskuery.md index 4b96d8af756f37..6274eb5f4f4a5f 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.eskuery.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.eskuery.md @@ -10,6 +10,6 @@ esKuery: { nodeTypes: import("../common/es_query/kuery/node_types").NodeTypes; fromKueryExpression: (expression: any, parseOptions?: Partial) => import("../common").KueryNode; - toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; + toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").MinimalIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; } ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esquery.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esquery.md index ac9be23bc6b6fb..0d1baecb014f58 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esquery.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esquery.md @@ -8,7 +8,7 @@ ```typescript esQuery: { - buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { + buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").MinimalIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { must: never[]; filter: import("../common").Filter[]; should: never[]; diff --git a/src/plugins/data/common/es_query/es_query/build_es_query.ts b/src/plugins/data/common/es_query/es_query/build_es_query.ts index 45724796c3518c..d7b3c630d1a6ed 100644 --- a/src/plugins/data/common/es_query/es_query/build_es_query.ts +++ b/src/plugins/data/common/es_query/es_query/build_es_query.ts @@ -10,9 +10,9 @@ import { groupBy, has, isEqual } from 'lodash'; import { buildQueryFromKuery } from './from_kuery'; import { buildQueryFromFilters } from './from_filters'; import { buildQueryFromLucene } from './from_lucene'; -import { IIndexPattern } from '../../index_patterns'; import { Filter } from '../filters'; import { Query } from '../../query/types'; +import { IndexPatternBase } from './types'; export interface EsQueryConfig { allowLeadingWildcards: boolean; @@ -36,7 +36,7 @@ function removeMatchAll(filters: T[]) { * config contains dateformat:tz */ export function buildEsQuery( - indexPattern: IIndexPattern | undefined, + indexPattern: IndexPatternBase | undefined, queries: Query | Query[], filters: Filter | Filter[], config: EsQueryConfig = { diff --git a/src/plugins/data/common/es_query/es_query/filter_matches_index.ts b/src/plugins/data/common/es_query/es_query/filter_matches_index.ts index 478263d5ce6014..b376436756092d 100644 --- a/src/plugins/data/common/es_query/es_query/filter_matches_index.ts +++ b/src/plugins/data/common/es_query/es_query/filter_matches_index.ts @@ -6,15 +6,16 @@ * Side Public License, v 1. */ -import { IIndexPattern, IFieldType } from '../../index_patterns'; +import { IFieldType } from '../../index_patterns'; import { Filter } from '../filters'; +import { IndexPatternBase } from './types'; /* * TODO: We should base this on something better than `filter.meta.key`. We should probably modify * this to check if `filter.meta.index` matches `indexPattern.id` instead, but that's a breaking * change. */ -export function filterMatchesIndex(filter: Filter, indexPattern?: IIndexPattern | null) { +export function filterMatchesIndex(filter: Filter, indexPattern?: IndexPatternBase | null) { if (!filter.meta?.key || !indexPattern) { return true; } diff --git a/src/plugins/data/common/es_query/es_query/from_filters.ts b/src/plugins/data/common/es_query/es_query/from_filters.ts index e50862235af1d9..7b3c58d45a5693 100644 --- a/src/plugins/data/common/es_query/es_query/from_filters.ts +++ b/src/plugins/data/common/es_query/es_query/from_filters.ts @@ -10,7 +10,7 @@ import { isUndefined } from 'lodash'; import { migrateFilter } from './migrate_filter'; import { filterMatchesIndex } from './filter_matches_index'; import { Filter, cleanFilter, isFilterDisabled } from '../filters'; -import { IIndexPattern } from '../../index_patterns'; +import { IndexPatternBase } from './types'; import { handleNestedFilter } from './handle_nested_filter'; /** @@ -45,7 +45,7 @@ const translateToQuery = (filter: Filter) => { export const buildQueryFromFilters = ( filters: Filter[] = [], - indexPattern: IIndexPattern | undefined, + indexPattern: IndexPatternBase | undefined, ignoreFilterIfFieldNotInIndex: boolean = false ) => { filters = filters.filter((filter) => filter && !isFilterDisabled(filter)); diff --git a/src/plugins/data/common/es_query/es_query/from_kuery.ts b/src/plugins/data/common/es_query/es_query/from_kuery.ts index afedaae45872b4..3eccfd87761133 100644 --- a/src/plugins/data/common/es_query/es_query/from_kuery.ts +++ b/src/plugins/data/common/es_query/es_query/from_kuery.ts @@ -7,11 +7,11 @@ */ import { fromKueryExpression, toElasticsearchQuery, nodeTypes, KueryNode } from '../kuery'; -import { IIndexPattern } from '../../index_patterns'; +import { IndexPatternBase } from './types'; import { Query } from '../../query/types'; export function buildQueryFromKuery( - indexPattern: IIndexPattern | undefined, + indexPattern: IndexPatternBase | undefined, queries: Query[] = [], allowLeadingWildcards: boolean = false, dateFormatTZ?: string @@ -24,7 +24,7 @@ export function buildQueryFromKuery( } function buildQuery( - indexPattern: IIndexPattern | undefined, + indexPattern: IndexPatternBase | undefined, queryASTs: KueryNode[], config: Record = {} ) { diff --git a/src/plugins/data/common/es_query/es_query/handle_nested_filter.test.ts b/src/plugins/data/common/es_query/es_query/handle_nested_filter.test.ts index ee5305132042af..d312d034df5641 100644 --- a/src/plugins/data/common/es_query/es_query/handle_nested_filter.test.ts +++ b/src/plugins/data/common/es_query/es_query/handle_nested_filter.test.ts @@ -9,13 +9,14 @@ import { handleNestedFilter } from './handle_nested_filter'; import { fields } from '../../index_patterns/mocks'; import { buildPhraseFilter, buildQueryFilter } from '../filters'; -import { IFieldType, IIndexPattern } from '../../index_patterns'; +import { IndexPatternBase } from './types'; +import { IFieldType } from '../../index_patterns'; describe('handleNestedFilter', function () { - const indexPattern: IIndexPattern = ({ + const indexPattern: IndexPatternBase = { id: 'logstash-*', fields, - } as unknown) as IIndexPattern; + }; it("should return the filter's query wrapped in nested query if the target field is nested", () => { const field = getField('nestedField.child'); diff --git a/src/plugins/data/common/es_query/es_query/handle_nested_filter.ts b/src/plugins/data/common/es_query/es_query/handle_nested_filter.ts index 93927d81565ef0..60e92769503fb7 100644 --- a/src/plugins/data/common/es_query/es_query/handle_nested_filter.ts +++ b/src/plugins/data/common/es_query/es_query/handle_nested_filter.ts @@ -7,9 +7,9 @@ */ import { getFilterField, cleanFilter, Filter } from '../filters'; -import { IIndexPattern } from '../../index_patterns'; +import { IndexPatternBase } from './types'; -export const handleNestedFilter = (filter: Filter, indexPattern?: IIndexPattern) => { +export const handleNestedFilter = (filter: Filter, indexPattern?: IndexPatternBase) => { if (!indexPattern) return filter; const fieldName = getFilterField(filter); diff --git a/src/plugins/data/common/es_query/es_query/index.ts b/src/plugins/data/common/es_query/es_query/index.ts index 31529480c8ac97..c10ea5846ae3fd 100644 --- a/src/plugins/data/common/es_query/es_query/index.ts +++ b/src/plugins/data/common/es_query/es_query/index.ts @@ -11,3 +11,4 @@ export { buildQueryFromFilters } from './from_filters'; export { luceneStringToDsl } from './lucene_string_to_dsl'; export { decorateQuery } from './decorate_query'; export { getEsQueryConfig } from './get_es_query_config'; +export { IndexPatternBase } from './types'; diff --git a/src/plugins/data/common/es_query/es_query/migrate_filter.ts b/src/plugins/data/common/es_query/es_query/migrate_filter.ts index c7c44d019a31c7..9bd78b092fc18b 100644 --- a/src/plugins/data/common/es_query/es_query/migrate_filter.ts +++ b/src/plugins/data/common/es_query/es_query/migrate_filter.ts @@ -9,7 +9,7 @@ import { get, omit } from 'lodash'; import { getConvertedValueForField } from '../filters'; import { Filter } from '../filters'; -import { IIndexPattern } from '../../index_patterns'; +import { IndexPatternBase } from './types'; export interface DeprecatedMatchPhraseFilter extends Filter { query: { @@ -28,7 +28,7 @@ function isDeprecatedMatchPhraseFilter(filter: any): filter is DeprecatedMatchPh return Boolean(fieldName && get(filter, ['query', 'match', fieldName, 'type']) === 'phrase'); } -export function migrateFilter(filter: Filter, indexPattern?: IIndexPattern) { +export function migrateFilter(filter: Filter, indexPattern?: IndexPatternBase) { if (isDeprecatedMatchPhraseFilter(filter)) { const fieldName = Object.keys(filter.query.match)[0]; const params: Record = get(filter, ['query', 'match', fieldName]); diff --git a/src/plugins/data/common/es_query/es_query/types.ts b/src/plugins/data/common/es_query/es_query/types.ts new file mode 100644 index 00000000000000..21337365160491 --- /dev/null +++ b/src/plugins/data/common/es_query/es_query/types.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 { IFieldType } from '../../index_patterns'; + +export interface IndexPatternBase { + fields: IFieldType[]; + id?: string; +} diff --git a/src/plugins/data/common/es_query/filters/build_filters.ts b/src/plugins/data/common/es_query/filters/build_filters.ts index ba1bd0a6154939..369f9530fb92b2 100644 --- a/src/plugins/data/common/es_query/filters/build_filters.ts +++ b/src/plugins/data/common/es_query/filters/build_filters.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { IIndexPattern, IFieldType } from '../..'; +import { IFieldType, IndexPatternBase } from '../..'; import { Filter, FILTERS, @@ -19,7 +19,7 @@ import { } from '.'; export function buildFilter( - indexPattern: IIndexPattern, + indexPattern: IndexPatternBase, field: IFieldType, type: FILTERS, negate: boolean, @@ -59,7 +59,7 @@ export function buildCustomFilter( } function buildBaseFilter( - indexPattern: IIndexPattern, + indexPattern: IndexPatternBase, field: IFieldType, type: FILTERS, params: any diff --git a/src/plugins/data/common/es_query/filters/exists_filter.ts b/src/plugins/data/common/es_query/filters/exists_filter.ts index 441a6bcb924b72..4836950c3bb277 100644 --- a/src/plugins/data/common/es_query/filters/exists_filter.ts +++ b/src/plugins/data/common/es_query/filters/exists_filter.ts @@ -7,7 +7,8 @@ */ import { Filter, FilterMeta } from './meta_filter'; -import { IIndexPattern, IFieldType } from '../../index_patterns'; +import { IFieldType } from '../../index_patterns'; +import { IndexPatternBase } from '..'; export type ExistsFilterMeta = FilterMeta; @@ -26,7 +27,7 @@ export const getExistsFilterField = (filter: ExistsFilter) => { return filter.exists && filter.exists.field; }; -export const buildExistsFilter = (field: IFieldType, indexPattern: IIndexPattern) => { +export const buildExistsFilter = (field: IFieldType, indexPattern: IndexPatternBase) => { return { meta: { index: indexPattern.id, diff --git a/src/plugins/data/common/es_query/filters/index.ts b/src/plugins/data/common/es_query/filters/index.ts index 133f5cd232e6f6..fe7cdadabaee3e 100644 --- a/src/plugins/data/common/es_query/filters/index.ts +++ b/src/plugins/data/common/es_query/filters/index.ts @@ -14,10 +14,8 @@ export * from './custom_filter'; export * from './exists_filter'; export * from './geo_bounding_box_filter'; export * from './geo_polygon_filter'; -export * from './get_display_value'; export * from './get_filter_field'; export * from './get_filter_params'; -export * from './get_index_pattern_from_filter'; export * from './match_all_filter'; export * from './meta_filter'; export * from './missing_filter'; diff --git a/src/plugins/data/common/es_query/filters/phrase_filter.ts b/src/plugins/data/common/es_query/filters/phrase_filter.ts index 85562435e68d02..27c1e85562097c 100644 --- a/src/plugins/data/common/es_query/filters/phrase_filter.ts +++ b/src/plugins/data/common/es_query/filters/phrase_filter.ts @@ -8,7 +8,8 @@ import type { estypes } from '@elastic/elasticsearch'; import { get, isPlainObject } from 'lodash'; import { Filter, FilterMeta } from './meta_filter'; -import { IIndexPattern, IFieldType } from '../../index_patterns'; +import { IFieldType } from '../../index_patterns'; +import { IndexPatternBase } from '..'; export type PhraseFilterMeta = FilterMeta & { params?: { @@ -60,7 +61,7 @@ export const getPhraseFilterValue = (filter: PhraseFilter): PhraseFilterValue => export const buildPhraseFilter = ( field: IFieldType, value: any, - indexPattern: IIndexPattern + indexPattern: IndexPatternBase ): PhraseFilter => { const convertedValue = getConvertedValueForField(field, value); diff --git a/src/plugins/data/common/es_query/filters/phrases_filter.ts b/src/plugins/data/common/es_query/filters/phrases_filter.ts index 849c1b3faef2ad..8a794721544937 100644 --- a/src/plugins/data/common/es_query/filters/phrases_filter.ts +++ b/src/plugins/data/common/es_query/filters/phrases_filter.ts @@ -9,7 +9,8 @@ import { Filter, FilterMeta } from './meta_filter'; import { getPhraseScript } from './phrase_filter'; import { FILTERS } from './index'; -import { IIndexPattern, IFieldType } from '../../index_patterns'; +import { IFieldType } from '../../index_patterns'; +import { IndexPatternBase } from '../es_query'; export type PhrasesFilterMeta = FilterMeta & { params: string[]; // The unformatted values @@ -34,7 +35,7 @@ export const getPhrasesFilterField = (filter: PhrasesFilter) => { export const buildPhrasesFilter = ( field: IFieldType, params: any[], - indexPattern: IIndexPattern + indexPattern: IndexPatternBase ) => { const index = indexPattern.id; const type = FILTERS.PHRASES; diff --git a/src/plugins/data/common/es_query/filters/range_filter.ts b/src/plugins/data/common/es_query/filters/range_filter.ts index a082b93c0a79a1..7bc7a8cff7487b 100644 --- a/src/plugins/data/common/es_query/filters/range_filter.ts +++ b/src/plugins/data/common/es_query/filters/range_filter.ts @@ -8,7 +8,8 @@ import type { estypes } from '@elastic/elasticsearch'; import { map, reduce, mapValues, get, keys, pickBy } from 'lodash'; import { Filter, FilterMeta } from './meta_filter'; -import { IIndexPattern, IFieldType } from '../../index_patterns'; +import { IFieldType } from '../../index_patterns'; +import { IndexPatternBase } from '..'; const OPERANDS_IN_RANGE = 2; @@ -93,7 +94,7 @@ const format = (field: IFieldType, value: any) => export const buildRangeFilter = ( field: IFieldType, params: RangeFilterParams, - indexPattern: IIndexPattern, + indexPattern: IndexPatternBase, formattedValue?: string ): RangeFilter => { const filter: any = { meta: { index: indexPattern.id, params: {} } }; diff --git a/src/plugins/data/common/es_query/kuery/ast/ast.ts b/src/plugins/data/common/es_query/kuery/ast/ast.ts index be821289699689..3e7b25897cab75 100644 --- a/src/plugins/data/common/es_query/kuery/ast/ast.ts +++ b/src/plugins/data/common/es_query/kuery/ast/ast.ts @@ -10,10 +10,10 @@ import { JsonObject } from '@kbn/common-utils'; import { nodeTypes } from '../node_types/index'; import { KQLSyntaxError } from '../kuery_syntax_error'; import { KueryNode, DslQuery, KueryParseOptions } from '../types'; -import { IIndexPattern } from '../../../index_patterns/types'; // @ts-ignore import { parse as parseKuery } from './_generated_/kuery'; +import { IndexPatternBase } from '../..'; const fromExpression = ( expression: string | DslQuery, @@ -65,7 +65,7 @@ export const fromKueryExpression = ( */ export const toElasticsearchQuery = ( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config?: Record, context?: Record ): JsonObject => { diff --git a/src/plugins/data/common/es_query/kuery/functions/and.ts b/src/plugins/data/common/es_query/kuery/functions/and.ts index 1989704cb627e3..ba7d5d1f6645b9 100644 --- a/src/plugins/data/common/es_query/kuery/functions/and.ts +++ b/src/plugins/data/common/es_query/kuery/functions/and.ts @@ -7,7 +7,7 @@ */ import * as ast from '../ast'; -import { IIndexPattern, KueryNode } from '../../..'; +import { IndexPatternBase, KueryNode } from '../../..'; export function buildNodeParams(children: KueryNode[]) { return { @@ -17,7 +17,7 @@ export function buildNodeParams(children: KueryNode[]) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/exists.ts b/src/plugins/data/common/es_query/kuery/functions/exists.ts index 5238fb1d8ee7fe..fa6c37e6ba18f7 100644 --- a/src/plugins/data/common/es_query/kuery/functions/exists.ts +++ b/src/plugins/data/common/es_query/kuery/functions/exists.ts @@ -8,7 +8,7 @@ import { get } from 'lodash'; import * as literal from '../node_types/literal'; -import { IIndexPattern, KueryNode, IFieldType } from '../../..'; +import { KueryNode, IFieldType, IndexPatternBase } from '../../..'; export function buildNodeParams(fieldName: string) { return { @@ -18,7 +18,7 @@ export function buildNodeParams(fieldName: string) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/geo_bounding_box.ts b/src/plugins/data/common/es_query/kuery/functions/geo_bounding_box.ts index f2498f3ea2ad49..38a433b1b80ab0 100644 --- a/src/plugins/data/common/es_query/kuery/functions/geo_bounding_box.ts +++ b/src/plugins/data/common/es_query/kuery/functions/geo_bounding_box.ts @@ -9,7 +9,7 @@ import _ from 'lodash'; import { nodeTypes } from '../node_types'; import * as ast from '../ast'; -import { IIndexPattern, KueryNode, IFieldType, LatLon } from '../../..'; +import { IndexPatternBase, KueryNode, IFieldType, LatLon } from '../../..'; export function buildNodeParams(fieldName: string, params: any) { params = _.pick(params, 'topLeft', 'bottomRight'); @@ -26,7 +26,7 @@ export function buildNodeParams(fieldName: string, params: any) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/geo_polygon.ts b/src/plugins/data/common/es_query/kuery/functions/geo_polygon.ts index 584a315930d9c1..69de7248a7b380 100644 --- a/src/plugins/data/common/es_query/kuery/functions/geo_polygon.ts +++ b/src/plugins/data/common/es_query/kuery/functions/geo_polygon.ts @@ -8,7 +8,7 @@ import { nodeTypes } from '../node_types'; import * as ast from '../ast'; -import { IIndexPattern, KueryNode, IFieldType, LatLon } from '../../..'; +import { IndexPatternBase, KueryNode, IFieldType, LatLon } from '../../..'; import { LiteralTypeBuildNode } from '../node_types/types'; export function buildNodeParams(fieldName: string, points: LatLon[]) { @@ -25,7 +25,7 @@ export function buildNodeParams(fieldName: string, points: LatLon[]) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/is.ts b/src/plugins/data/common/es_query/kuery/functions/is.ts index a18ad230c3cae9..55d036c2156f9b 100644 --- a/src/plugins/data/common/es_query/kuery/functions/is.ts +++ b/src/plugins/data/common/es_query/kuery/functions/is.ts @@ -11,7 +11,7 @@ import { getPhraseScript } from '../../filters'; import { getFields } from './utils/get_fields'; import { getTimeZoneFromSettings } from '../../utils'; import { getFullFieldNameNode } from './utils/get_full_field_name_node'; -import { IIndexPattern, KueryNode, IFieldType } from '../../..'; +import { IndexPatternBase, KueryNode, IFieldType } from '../../..'; import * as ast from '../ast'; @@ -39,7 +39,7 @@ export function buildNodeParams(fieldName: string, value: any, isPhrase: boolean export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/nested.ts b/src/plugins/data/common/es_query/kuery/functions/nested.ts index bfd01ef39764c5..46ceeaf3e5de66 100644 --- a/src/plugins/data/common/es_query/kuery/functions/nested.ts +++ b/src/plugins/data/common/es_query/kuery/functions/nested.ts @@ -8,7 +8,7 @@ import * as ast from '../ast'; import * as literal from '../node_types/literal'; -import { IIndexPattern, KueryNode } from '../../..'; +import { IndexPatternBase, KueryNode } from '../../..'; export function buildNodeParams(path: any, child: any) { const pathNode = @@ -20,7 +20,7 @@ export function buildNodeParams(path: any, child: any) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/not.ts b/src/plugins/data/common/es_query/kuery/functions/not.ts index ef4456897bcdd9..f837cd261c8145 100644 --- a/src/plugins/data/common/es_query/kuery/functions/not.ts +++ b/src/plugins/data/common/es_query/kuery/functions/not.ts @@ -7,7 +7,7 @@ */ import * as ast from '../ast'; -import { IIndexPattern, KueryNode } from '../../..'; +import { IndexPatternBase, KueryNode } from '../../..'; export function buildNodeParams(child: KueryNode) { return { @@ -17,7 +17,7 @@ export function buildNodeParams(child: KueryNode) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/or.ts b/src/plugins/data/common/es_query/kuery/functions/or.ts index 416687e7cde9c3..7365cc39595e6b 100644 --- a/src/plugins/data/common/es_query/kuery/functions/or.ts +++ b/src/plugins/data/common/es_query/kuery/functions/or.ts @@ -7,7 +7,7 @@ */ import * as ast from '../ast'; -import { IIndexPattern, KueryNode } from '../../..'; +import { IndexPatternBase, KueryNode } from '../../..'; export function buildNodeParams(children: KueryNode[]) { return { @@ -17,7 +17,7 @@ export function buildNodeParams(children: KueryNode[]) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/range.ts b/src/plugins/data/common/es_query/kuery/functions/range.ts index 06b345e5821c34..caefa7e5373cac 100644 --- a/src/plugins/data/common/es_query/kuery/functions/range.ts +++ b/src/plugins/data/common/es_query/kuery/functions/range.ts @@ -13,7 +13,7 @@ import { getRangeScript, RangeFilterParams } from '../../filters'; import { getFields } from './utils/get_fields'; import { getTimeZoneFromSettings } from '../../utils'; import { getFullFieldNameNode } from './utils/get_full_field_name_node'; -import { IIndexPattern, KueryNode, IFieldType } from '../../..'; +import { IndexPatternBase, KueryNode, IFieldType } from '../../..'; export function buildNodeParams(fieldName: string, params: RangeFilterParams) { const paramsToMap = _.pick(params, 'gt', 'lt', 'gte', 'lte', 'format'); @@ -33,7 +33,7 @@ export function buildNodeParams(fieldName: string, params: RangeFilterParams) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/utils/get_fields.ts b/src/plugins/data/common/es_query/kuery/functions/utils/get_fields.ts index 4002a36648f04e..7dac1262d5062c 100644 --- a/src/plugins/data/common/es_query/kuery/functions/utils/get_fields.ts +++ b/src/plugins/data/common/es_query/kuery/functions/utils/get_fields.ts @@ -8,10 +8,10 @@ import * as literal from '../../node_types/literal'; import * as wildcard from '../../node_types/wildcard'; -import { KueryNode, IIndexPattern } from '../../../..'; +import { KueryNode, IndexPatternBase } from '../../../..'; import { LiteralTypeBuildNode } from '../../node_types/types'; -export function getFields(node: KueryNode, indexPattern?: IIndexPattern) { +export function getFields(node: KueryNode, indexPattern?: IndexPatternBase) { if (!indexPattern) return []; if (node.type === 'literal') { const fieldName = literal.toElasticsearchQuery(node as LiteralTypeBuildNode); diff --git a/src/plugins/data/common/es_query/kuery/functions/utils/get_full_field_name_node.ts b/src/plugins/data/common/es_query/kuery/functions/utils/get_full_field_name_node.ts index e623579226861a..644791637aa709 100644 --- a/src/plugins/data/common/es_query/kuery/functions/utils/get_full_field_name_node.ts +++ b/src/plugins/data/common/es_query/kuery/functions/utils/get_full_field_name_node.ts @@ -7,11 +7,11 @@ */ import { getFields } from './get_fields'; -import { IIndexPattern, IFieldType, KueryNode } from '../../../..'; +import { IndexPatternBase, IFieldType, KueryNode } from '../../../..'; export function getFullFieldNameNode( rootNameNode: any, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, nestedPath?: string ): KueryNode { const fullFieldNameNode = { diff --git a/src/plugins/data/common/es_query/kuery/node_types/function.ts b/src/plugins/data/common/es_query/kuery/node_types/function.ts index b9b7379dfb23d4..642089a101f31c 100644 --- a/src/plugins/data/common/es_query/kuery/node_types/function.ts +++ b/src/plugins/data/common/es_query/kuery/node_types/function.ts @@ -9,7 +9,7 @@ import _ from 'lodash'; import { functions } from '../functions'; -import { IIndexPattern, KueryNode } from '../../..'; +import { IndexPatternBase, KueryNode } from '../../..'; import { FunctionName, FunctionTypeBuildNode } from './types'; export function buildNode(functionName: FunctionName, ...args: any[]) { @@ -45,7 +45,7 @@ export function buildNodeWithArgumentNodes( export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config?: Record, context?: Record ) { diff --git a/src/plugins/data/common/es_query/kuery/node_types/types.ts b/src/plugins/data/common/es_query/kuery/node_types/types.ts index b3247a0ad8dc21..ea8eb5e8a0618e 100644 --- a/src/plugins/data/common/es_query/kuery/node_types/types.ts +++ b/src/plugins/data/common/es_query/kuery/node_types/types.ts @@ -11,8 +11,8 @@ */ import { JsonValue } from '@kbn/common-utils'; -import { IIndexPattern } from '../../../index_patterns'; import { KueryNode } from '..'; +import { IndexPatternBase } from '../..'; export type FunctionName = | 'is' @@ -30,7 +30,7 @@ interface FunctionType { buildNodeWithArgumentNodes: (functionName: FunctionName, args: any[]) => FunctionTypeBuildNode; toElasticsearchQuery: ( node: any, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config?: Record, context?: Record ) => JsonValue; diff --git a/src/plugins/data/common/index_patterns/types.ts b/src/plugins/data/common/index_patterns/types.ts index 07aa8967b905e4..a88f029c0c7cd9 100644 --- a/src/plugins/data/common/index_patterns/types.ts +++ b/src/plugins/data/common/index_patterns/types.ts @@ -9,6 +9,7 @@ import type { estypes } from '@elastic/elasticsearch'; import { ToastInputFields, ErrorToastOptions } from 'src/core/public/notifications'; // eslint-disable-next-line import type { SavedObject } from 'src/core/server'; +import type { IndexPatternBase } from '../es_query'; import { IFieldType } from './fields'; import { RUNTIME_FIELD_TYPES } from './constants'; import { SerializedFieldFormat } from '../../../expressions/common'; @@ -29,10 +30,8 @@ export interface RuntimeField { * IIndexPattern allows for an IndexPattern OR an index pattern saved object * Use IndexPattern or IndexPatternSpec instead */ -export interface IIndexPattern { - fields: IFieldType[]; +export interface IIndexPattern extends IndexPatternBase { title: string; - id?: string; /** * Type is used for identifying rollup indices, otherwise left undefined */ diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 078dd3a9b7c5ab..d7667f20d517e3 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -23,7 +23,6 @@ import { disableFilter, FILTERS, FilterStateStore, - getDisplayValueFromFilter, getPhraseFilterField, getPhraseFilterValue, isExistsFilter, @@ -43,6 +42,7 @@ import { FilterLabel } from './ui'; import { FilterItem } from './ui/filter_bar'; import { + getDisplayValueFromFilter, generateFilters, onlyDisabledFiltersChanged, changeTimeFilter, diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 7a5f323e51459a..2849b93b144835 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -808,11 +808,11 @@ export const esFilters: { FILTERS: typeof FILTERS; FilterStateStore: typeof FilterStateStore; buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("../common").Filter; - buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IIndexPattern) => import("../common").PhrasesFilter; - buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IIndexPattern) => import("../common").ExistsFilter; - buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IIndexPattern) => import("../common").PhraseFilter; + buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IndexPatternBase) => import("../common").PhrasesFilter; + buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IndexPatternBase) => import("../common").ExistsFilter; + buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IndexPatternBase) => import("../common").PhraseFilter; buildQueryFilter: (query: any, index: string, alias: string) => import("../common").QueryStringFilter; - buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; + buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IndexPatternBase, formattedValue?: string | undefined) => import("../common").RangeFilter; isPhraseFilter: (filter: any) => filter is import("../common").PhraseFilter; isExistsFilter: (filter: any) => filter is import("../common").ExistsFilter; isPhrasesFilter: (filter: any) => filter is import("../common").PhrasesFilter; @@ -858,7 +858,7 @@ export const esFilters: { export const esKuery: { nodeTypes: import("../common/es_query/kuery/node_types").NodeTypes; fromKueryExpression: (expression: any, parseOptions?: Partial) => import("../common").KueryNode; - toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; + toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IndexPatternBase | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; }; // Warning: (ae-missing-release-tag) "esQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -867,7 +867,7 @@ export const esKuery: { export const esQuery: { buildEsQuery: typeof buildEsQuery; getEsQueryConfig: typeof getEsQueryConfig; - buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { + buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IndexPatternBase | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { must: never[]; filter: import("../common").Filter[]; should: never[]; @@ -1286,22 +1286,19 @@ export interface IFieldType { visualizable?: boolean; } +// Warning: (ae-forgotten-export) The symbol "IndexPatternBase" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "IIndexPattern" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @deprecated (undocumented) -export interface IIndexPattern { +export interface IIndexPattern extends IndexPatternBase { // Warning: (ae-forgotten-export) The symbol "SerializedFieldFormat" needs to be exported by the entry point index.d.ts // // (undocumented) fieldFormatMap?: Record | undefined>; - // (undocumented) - fields: IFieldType[]; getFormatterForField?: (field: IndexPatternField | IndexPatternField['spec'] | IFieldType) => FieldFormat; // (undocumented) getTimeField?(): IFieldType | undefined; // (undocumented) - id?: string; - // (undocumented) timeFieldName?: string; // (undocumented) title: string; @@ -2731,13 +2728,13 @@ export interface WaitUntilNextSessionCompletesOptions { // Warnings were encountered during analysis: // -// src/plugins/data/common/es_query/filters/exists_filter.ts:19:3 - (ae-forgotten-export) The symbol "ExistsFilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/es_query/filters/exists_filter.ts:20:3 - (ae-forgotten-export) The symbol "FilterExistsProperty" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/es_query/filters/exists_filter.ts:20:3 - (ae-forgotten-export) The symbol "ExistsFilterMeta" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/es_query/filters/exists_filter.ts:21:3 - (ae-forgotten-export) The symbol "FilterExistsProperty" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/match_all_filter.ts:17:3 - (ae-forgotten-export) The symbol "MatchAllFilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/meta_filter.ts:43:3 - (ae-forgotten-export) The symbol "FilterState" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/meta_filter.ts:44:3 - (ae-forgotten-export) The symbol "FilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/es_query/filters/phrase_filter.ts:22:3 - (ae-forgotten-export) The symbol "PhraseFilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/es_query/filters/phrases_filter.ts:20:3 - (ae-forgotten-export) The symbol "PhrasesFilterMeta" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/es_query/filters/phrase_filter.ts:23:3 - (ae-forgotten-export) The symbol "PhraseFilterMeta" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/es_query/filters/phrases_filter.ts:21:3 - (ae-forgotten-export) The symbol "PhrasesFilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:65:5 - (ae-forgotten-export) The symbol "FormatFieldFn" needs to be exported by the entry point index.d.ts // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:138:7 - (ae-forgotten-export) The symbol "FieldAttrSet" needs to be exported by the entry point index.d.ts // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:169:7 - (ae-forgotten-export) The symbol "RuntimeField" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/query/filter_manager/index.ts b/src/plugins/data/public/query/filter_manager/index.ts index 327b9763541ac9..55dba640b07b6b 100644 --- a/src/plugins/data/public/query/filter_manager/index.ts +++ b/src/plugins/data/public/query/filter_manager/index.ts @@ -11,3 +11,5 @@ export { FilterManager } from './filter_manager'; export { mapAndFlattenFilters } from './lib/map_and_flatten_filters'; export { onlyDisabledFiltersChanged } from './lib/only_disabled'; export { generateFilters } from './lib/generate_filters'; +export { getDisplayValueFromFilter } from './lib/get_display_value'; +export { getIndexPatternFromFilter } from './lib/get_index_pattern_from_filter'; diff --git a/src/plugins/data/common/es_query/filters/get_display_value.ts b/src/plugins/data/public/query/filter_manager/lib/get_display_value.ts similarity index 95% rename from src/plugins/data/common/es_query/filters/get_display_value.ts rename to src/plugins/data/public/query/filter_manager/lib/get_display_value.ts index ee719843ae879d..45c6167f600bca 100644 --- a/src/plugins/data/common/es_query/filters/get_display_value.ts +++ b/src/plugins/data/public/query/filter_manager/lib/get_display_value.ts @@ -7,9 +7,8 @@ */ import { i18n } from '@kbn/i18n'; -import { IIndexPattern } from '../..'; +import { Filter, IIndexPattern } from '../../../../common'; import { getIndexPatternFromFilter } from './get_index_pattern_from_filter'; -import { Filter } from '../filters'; function getValueFormatter(indexPattern?: IIndexPattern, key?: string) { // checking getFormatterForField exists because there is at least once case where an index pattern diff --git a/src/plugins/data/common/es_query/filters/get_index_pattern_from_filter.test.ts b/src/plugins/data/public/query/filter_manager/lib/get_index_pattern_from_filter.test.ts similarity index 100% rename from src/plugins/data/common/es_query/filters/get_index_pattern_from_filter.test.ts rename to src/plugins/data/public/query/filter_manager/lib/get_index_pattern_from_filter.test.ts diff --git a/src/plugins/data/common/es_query/filters/get_index_pattern_from_filter.ts b/src/plugins/data/public/query/filter_manager/lib/get_index_pattern_from_filter.ts similarity index 88% rename from src/plugins/data/common/es_query/filters/get_index_pattern_from_filter.ts rename to src/plugins/data/public/query/filter_manager/lib/get_index_pattern_from_filter.ts index bceeb5f2793ecc..7a2ce29102e515 100644 --- a/src/plugins/data/common/es_query/filters/get_index_pattern_from_filter.ts +++ b/src/plugins/data/public/query/filter_manager/lib/get_index_pattern_from_filter.ts @@ -6,8 +6,7 @@ * Side Public License, v 1. */ -import { Filter } from '../filters'; -import { IIndexPattern } from '../..'; +import { Filter, IIndexPattern } from '../../../../common'; export function getIndexPatternFromFilter( filter: Filter, diff --git a/src/plugins/data/public/ui/apply_filters/apply_filter_popover_content.tsx b/src/plugins/data/public/ui/apply_filters/apply_filter_popover_content.tsx index 23de8327ce1f19..9cc9af04409f15 100644 --- a/src/plugins/data/public/ui/apply_filters/apply_filter_popover_content.tsx +++ b/src/plugins/data/public/ui/apply_filters/apply_filter_popover_content.tsx @@ -20,9 +20,9 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component } from 'react'; import { IIndexPattern } from '../..'; -import { getDisplayValueFromFilter, Filter } from '../../../common'; +import { Filter } from '../../../common'; import { FilterLabel } from '../filter_bar'; -import { mapAndFlattenFilters } from '../../query'; +import { mapAndFlattenFilters, getDisplayValueFromFilter } from '../../query'; interface Props { filters: Filter[]; diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx index 2b8978a125bcac..734161ea87232b 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx @@ -37,10 +37,10 @@ import { Operator } from './lib/filter_operators'; import { PhraseValueInput } from './phrase_value_input'; import { PhrasesValuesInput } from './phrases_values_input'; import { RangeValueInput } from './range_value_input'; +import { getIndexPatternFromFilter } from '../../../query'; import { IIndexPattern, IFieldType } from '../../..'; import { Filter, - getIndexPatternFromFilter, FieldFilter, buildFilter, buildCustomFilter, diff --git a/src/plugins/data/public/ui/filter_bar/filter_item.tsx b/src/plugins/data/public/ui/filter_bar/filter_item.tsx index 9e5090f9451829..09e0571c2a870e 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_item.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_item.tsx @@ -14,14 +14,13 @@ import { IUiSettingsClient } from 'src/core/public'; import { FilterEditor } from './filter_editor'; import { FilterView } from './filter_view'; import { IIndexPattern } from '../..'; +import { getDisplayValueFromFilter, getIndexPatternFromFilter } from '../../query'; import { Filter, isFilterPinned, - getDisplayValueFromFilter, toggleFilterNegated, toggleFilterPinned, toggleFilterDisabled, - getIndexPatternFromFilter, } from '../../../common'; import { getIndexPatterns } from '../../services'; diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 768c44d3e3e950..5ca19f9e1e5098 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -447,11 +447,11 @@ export const esFilters: { buildQueryFilter: (query: any, index: string, alias: string) => import("../common").QueryStringFilter; buildCustomFilter: typeof buildCustomFilter; buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("../common").Filter; - buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IIndexPattern) => import("../common").ExistsFilter; + buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IndexPatternBase) => import("../common").ExistsFilter; buildFilter: typeof buildFilter; - buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IIndexPattern) => import("../common").PhraseFilter; - buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IIndexPattern) => import("../common").PhrasesFilter; - buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; + buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IndexPatternBase) => import("../common").PhraseFilter; + buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IndexPatternBase) => import("../common").PhrasesFilter; + buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IndexPatternBase, formattedValue?: string | undefined) => import("../common").RangeFilter; isFilterDisabled: (filter: import("../common").Filter) => boolean; }; @@ -461,14 +461,14 @@ export const esFilters: { export const esKuery: { nodeTypes: import("../common/es_query/kuery/node_types").NodeTypes; fromKueryExpression: (expression: any, parseOptions?: Partial) => import("../common").KueryNode; - toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; + toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IndexPatternBase | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; }; // Warning: (ae-missing-release-tag) "esQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export const esQuery: { - buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { + buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IndexPatternBase | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { must: never[]; filter: import("../common").Filter[]; should: never[]; From 23666832091d0a30c00222fdd73d56af51224ff9 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Wed, 23 Jun 2021 14:43:17 -0500 Subject: [PATCH 50/61] [Enterprise Search] Add shared Users components and enable RBAC functionality (#102826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add RolesEmptyPrompt component * Move constants to shared Will be used in next commit so DRYing them out here * Add UserAddedInfo component * Add UsersEmptyPrompt component * Add UserInvitationCallout component * Add some shared types * Add UserSelector component * Fix imports from a previous commit Refactored these to shared but missed updating the implementation. See e2d3ec2ca4aba3cb6f7e8e2d2d2da96aa6bedf1b * Add UsersHeading component * Add UserFlyout component * Update UsersAndRolesRowActions with confirm modal Design calls for using a custom call out instead of window.confirm * Add pagination size and fix type - email can be null on bult-in elasticsearch users * Add UsersTable component * Remove window.confirm from logic files The UsersAndRolesRowActions component now uses an EUI prompt for this. Whitespace changes should be hidden for this commit * Add routes for enabling RBAC * Update App Search routes https://github.com/elastic/ent-search/pull/3862 added the ‘/as’ prefix to App Search role mappings routes * Add logic for enabling role-based access * Pass docsLink as a prop to the heading component * Add empty states to mappings landing pages * Fix a couple of missed i18ns * Remove unused translations * Remove EuiOverlayMask This was needed in ent-search because it uses an older EUI. The newer confirm modal has its own overlay * Update RoleMappingsTable to use new design Previously, we showed all engines/groups in the table but the new design calls for a truncated list with additional items so [‘foo’, ‘bar’, ‘baz’] would display as “foo, bar + 1” This is already in place for the users table * Lint fix * Another lint fix * Fix test name Co-authored-by: Jason Stoltzfus * Move test Co-authored-by: Jason Stoltzfus --- .../components/role_mappings/constants.ts | 8 - .../role_mappings/role_mappings.tsx | 22 +- .../role_mappings/role_mappings_logic.test.ts | 49 ++-- .../role_mappings/role_mappings_logic.ts | 37 ++- .../applications/shared/constants/index.ts | 1 + .../applications/shared/constants/labels.ts | 15 ++ .../__mocks__/elasticsearch_users.ts | 13 ++ .../shared/role_mapping/__mocks__/roles.ts | 19 ++ .../shared/role_mapping/constants.ts | 213 +++++++++++++++++- .../applications/shared/role_mapping/index.ts | 8 + .../role_mappings_heading.test.tsx | 8 +- .../role_mapping/role_mappings_heading.tsx | 8 +- .../role_mapping/role_mappings_table.test.tsx | 34 +-- .../role_mapping/role_mappings_table.tsx | 37 ++- .../role_mapping/roles_empty_prompt.test.tsx | 39 ++++ .../role_mapping/roles_empty_prompt.tsx | 48 ++++ .../role_mapping/user_added_info.test.tsx | 28 +++ .../shared/role_mapping/user_added_info.tsx | 40 ++++ .../shared/role_mapping/user_flyout.test.tsx | 70 ++++++ .../shared/role_mapping/user_flyout.tsx | 113 ++++++++++ .../user_invitation_callout.test.tsx | 46 ++++ .../role_mapping/user_invitation_callout.tsx | 47 ++++ .../role_mapping/user_selector.test.tsx | 112 +++++++++ .../shared/role_mapping/user_selector.tsx | 159 +++++++++++++ .../users_and_roles_row_actions.test.tsx | 22 +- .../users_and_roles_row_actions.tsx | 63 +++++- .../role_mapping/users_empty_prompt.test.tsx | 22 ++ .../role_mapping/users_empty_prompt.tsx | 43 ++++ .../role_mapping/users_heading.test.tsx | 32 +++ .../shared/role_mapping/users_heading.tsx | 37 +++ .../shared/role_mapping/users_table.test.tsx | 100 ++++++++ .../shared/role_mapping/users_table.tsx | 147 ++++++++++++ .../public/applications/shared/types.ts | 16 ++ .../groups/components/group_users_table.tsx | 18 +- .../views/role_mappings/constants.ts | 8 - .../views/role_mappings/role_mappings.tsx | 27 ++- .../role_mappings/role_mappings_logic.test.ts | 50 ++-- .../role_mappings/role_mappings_logic.ts | 37 ++- .../routes/app_search/role_mappings.test.ts | 37 ++- .../server/routes/app_search/role_mappings.ts | 24 +- .../workplace_search/role_mappings.test.ts | 29 ++- .../routes/workplace_search/role_mappings.ts | 16 ++ .../translations/translations/ja-JP.json | 9 +- .../translations/translations/zh-CN.json | 9 +- 44 files changed, 1748 insertions(+), 172 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/constants/labels.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/elasticsearch_users.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/roles_empty_prompt.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/roles_empty_prompt.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_empty_prompt.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_empty_prompt.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_heading.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_heading.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts index df1e19e264c756..cce18cbeffd0ae 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts @@ -9,14 +9,6 @@ import { i18n } from '@kbn/i18n'; import { AdvanceRoleType } from '../../types'; -export const DELETE_ROLE_MAPPING_MESSAGE = i18n.translate( - 'xpack.enterpriseSearch.appSearch.deleteRoleMappingMessage', - { - defaultMessage: - 'Are you sure you want to permanently delete this mapping? This action is not reversible and some users might lose access.', - } -); - export const ROLE_MAPPING_DELETED_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.appSearch.roleMappingDeletedMessage', { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx index db0e6e6dead111..03e2ae67eca9ea 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx @@ -10,16 +10,25 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; -import { RoleMappingsTable, RoleMappingsHeading } from '../../../shared/role_mapping'; +import { + RoleMappingsTable, + RoleMappingsHeading, + RolesEmptyPrompt, +} from '../../../shared/role_mapping'; import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; + +import { DOCS_PREFIX } from '../../routes'; import { AppSearchPageTemplate } from '../layout'; import { ROLE_MAPPINGS_ENGINE_ACCESS_HEADING } from './constants'; import { RoleMapping } from './role_mapping'; import { RoleMappingsLogic } from './role_mappings_logic'; +const ROLES_DOCS_LINK = `${DOCS_PREFIX}/security-and-users.html`; + export const RoleMappings: React.FC = () => { const { + enableRoleBasedAccess, initializeRoleMappings, initializeRoleMapping, handleDeleteMapping, @@ -37,10 +46,19 @@ export const RoleMappings: React.FC = () => { return resetState; }, []); + const rolesEmptyState = ( + + ); + const roleMappingsSection = (

initializeRoleMapping()} /> { pageChrome={[ROLE_MAPPINGS_TITLE]} pageHeader={{ pageTitle: ROLE_MAPPINGS_TITLE }} isLoading={dataLoading} + isEmptyState={roleMappings.length < 1} + emptyState={rolesEmptyState} > {roleMappingFlyoutOpen && } {roleMappingsSection} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts index 870e303a2930d8..6985f213d1dd56 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts @@ -87,6 +87,13 @@ describe('RoleMappingsLogic', () => { }); }); + it('setRoleMappings', () => { + RoleMappingsLogic.actions.setRoleMappings({ roleMappings: [asRoleMapping] }); + + expect(RoleMappingsLogic.values.roleMappings).toEqual([asRoleMapping]); + expect(RoleMappingsLogic.values.dataLoading).toEqual(false); + }); + it('handleRoleChange', () => { RoleMappingsLogic.actions.handleRoleChange('dev'); @@ -266,6 +273,30 @@ describe('RoleMappingsLogic', () => { }); describe('listeners', () => { + describe('enableRoleBasedAccess', () => { + it('calls API and sets values', async () => { + const setRoleMappingsSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMappings'); + http.post.mockReturnValue(Promise.resolve(mappingsServerProps)); + RoleMappingsLogic.actions.enableRoleBasedAccess(); + + expect(RoleMappingsLogic.values.dataLoading).toEqual(true); + + expect(http.post).toHaveBeenCalledWith( + '/api/app_search/role_mappings/enable_role_based_access' + ); + await nextTick(); + expect(setRoleMappingsSpy).toHaveBeenCalledWith(mappingsServerProps); + }); + + it('handles error', async () => { + http.post.mockReturnValue(Promise.reject('this is an error')); + RoleMappingsLogic.actions.enableRoleBasedAccess(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + describe('initializeRoleMappings', () => { it('calls API and sets values', async () => { const setRoleMappingsDataSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMappingsData'); @@ -400,18 +431,8 @@ describe('RoleMappingsLogic', () => { }); describe('handleDeleteMapping', () => { - let confirmSpy: any; const roleMappingId = 'r1'; - beforeEach(() => { - confirmSpy = jest.spyOn(window, 'confirm'); - confirmSpy.mockImplementation(jest.fn(() => true)); - }); - - afterEach(() => { - confirmSpy.mockRestore(); - }); - it('calls API and refreshes list', async () => { mount(mappingsServerProps); const initializeRoleMappingsSpy = jest.spyOn( @@ -436,14 +457,6 @@ describe('RoleMappingsLogic', () => { expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); - - it('will do nothing if not confirmed', () => { - mount(mappingsServerProps); - jest.spyOn(window, 'confirm').mockReturnValueOnce(false); - RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); - - expect(http.delete).not.toHaveBeenCalled(); - }); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts index fc0a235b23c77c..e2ef75897528c6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts @@ -22,7 +22,6 @@ import { roleHasScopedEngines } from '../../utils/role/has_scoped_engines'; import { Engine } from '../engine/types'; import { - DELETE_ROLE_MAPPING_MESSAGE, ROLE_MAPPING_DELETED_MESSAGE, ROLE_MAPPING_CREATED_MESSAGE, ROLE_MAPPING_UPDATED_MESSAGE, @@ -59,10 +58,16 @@ interface RoleMappingsActions { initializeRoleMappings(): void; resetState(): void; setRoleMapping(roleMapping: ASRoleMapping): { roleMapping: ASRoleMapping }; + setRoleMappings({ + roleMappings, + }: { + roleMappings: ASRoleMapping[]; + }): { roleMappings: ASRoleMapping[] }; setRoleMappingsData(data: RoleMappingsServerDetails): RoleMappingsServerDetails; openRoleMappingFlyout(): void; closeRoleMappingFlyout(): void; setRoleMappingErrors(errors: string[]): { errors: string[] }; + enableRoleBasedAccess(): void; } interface RoleMappingsValues { @@ -91,6 +96,7 @@ export const RoleMappingsLogic = kea data, setRoleMapping: (roleMapping: ASRoleMapping) => ({ roleMapping }), + setRoleMappings: ({ roleMappings }: { roleMappings: ASRoleMapping[] }) => ({ roleMappings }), setRoleMappingErrors: (errors: string[]) => ({ errors }), handleAuthProviderChange: (value: string) => ({ value }), handleRoleChange: (roleType: RoleTypes) => ({ roleType }), @@ -101,6 +107,7 @@ export const RoleMappingsLogic = kea ({ value }), handleAccessAllEnginesChange: (selected: boolean) => ({ selected }), + enableRoleBasedAccess: true, resetState: true, initializeRoleMappings: true, initializeRoleMapping: (roleMappingId) => ({ roleMappingId }), @@ -114,13 +121,16 @@ export const RoleMappingsLogic = kea false, + setRoleMappings: () => false, resetState: () => true, + enableRoleBasedAccess: () => true, }, ], roleMappings: [ [], { setRoleMappingsData: (_, { roleMappings }) => roleMappings, + setRoleMappings: (_, { roleMappings }) => roleMappings, resetState: () => [], }, ], @@ -267,6 +277,17 @@ export const RoleMappingsLogic = kea ({ + enableRoleBasedAccess: async () => { + const { http } = HttpLogic.values; + const route = '/api/app_search/role_mappings/enable_role_based_access'; + + try { + const response = await http.post(route); + actions.setRoleMappings(response); + } catch (e) { + flashAPIErrors(e); + } + }, initializeRoleMappings: async () => { const { http } = HttpLogic.values; const route = '/api/app_search/role_mappings'; @@ -286,14 +307,12 @@ export const RoleMappingsLogic = kea { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts index 70990727b8a625..b15bd9e1155cc8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts @@ -6,4 +6,5 @@ */ export * from './actions'; +export * from './labels'; export { DEFAULT_META } from './default_meta'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/labels.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/labels.ts new file mode 100644 index 00000000000000..8e6159d2b5b2a1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/labels.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const USERNAME_LABEL = i18n.translate('xpack.enterpriseSearch.usernameLabel', { + defaultMessage: 'Username', +}); +export const EMAIL_LABEL = i18n.translate('xpack.enterpriseSearch.emailLabel', { + defaultMessage: 'Email', +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/elasticsearch_users.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/elasticsearch_users.ts new file mode 100644 index 00000000000000..500f5606756790 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/elasticsearch_users.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const elasticsearchUsers = [ + { + email: 'user1@user.com', + username: 'user1', + }, +]; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts index 15dec753351ba6..486c1ba6c9af6e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts @@ -9,6 +9,8 @@ import { engines } from '../../../app_search/__mocks__/engines.mock'; import { AttributeName } from '../../types'; +import { elasticsearchUsers } from './elasticsearch_users'; + export const asRoleMapping = { id: 'sdgfasdgadf123', attributeName: 'role' as AttributeName, @@ -70,3 +72,20 @@ export const wsRoleMapping = { }, ], }; + +export const invitation = { + email: 'foo@example.com', + code: '123fooqwe', +}; + +export const wsSingleUserRoleMapping = { + invitation, + elasticsearchUser: elasticsearchUsers[0], + roleMapping: wsRoleMapping, +}; + +export const asSingleUserRoleMapping = { + invitation, + elasticsearchUser: elasticsearchUsers[0], + roleMapping: asRoleMapping, +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts index 9f40844e52470a..45cab32b67e088 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts @@ -50,10 +50,26 @@ export const ROLE_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.rol defaultMessage: 'Role', }); +export const USERNAME_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.usernameLabel', { + defaultMessage: 'Username', +}); + +export const EMAIL_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.emailLabel', { + defaultMessage: 'Email', +}); + export const ALL_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.allLabel', { defaultMessage: 'All', }); +export const GROUPS_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.groupsLabel', { + defaultMessage: 'Groups', +}); + +export const ENGINES_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.enginesLabel', { + defaultMessage: 'Engines', +}); + export const AUTH_PROVIDER_LABEL = i18n.translate( 'xpack.enterpriseSearch.roleMapping.authProviderLabel', { @@ -82,10 +98,10 @@ export const ATTRIBUTE_VALUE_ERROR = i18n.translate( } ); -export const DELETE_ROLE_MAPPING_TITLE = i18n.translate( - 'xpack.enterpriseSearch.roleMapping.deleteRoleMappingTitle', +export const REMOVE_ROLE_MAPPING_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.removeRoleMappingTitle', { - defaultMessage: 'Remove this role mapping', + defaultMessage: 'Remove role mapping', } ); @@ -96,10 +112,17 @@ export const DELETE_ROLE_MAPPING_DESCRIPTION = i18n.translate( } ); -export const DELETE_ROLE_MAPPING_BUTTON = i18n.translate( - 'xpack.enterpriseSearch.roleMapping.deleteRoleMappingButton', +export const REMOVE_ROLE_MAPPING_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.removeRoleMappingButton', + { + defaultMessage: 'Remove mapping', + } +); + +export const REMOVE_USER_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.removeUserButton', { - defaultMessage: 'Delete mapping', + defaultMessage: 'Remove user', } ); @@ -205,3 +228,181 @@ export const ROLE_MAPPINGS_NO_RESULTS_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.roleMapping.noResults.message', { defaultMessage: 'Create a new role mapping' } ); + +export const ROLES_DISABLED_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.rolesDisabledTitle', + { defaultMessage: 'Role-based access is disabled' } +); + +export const ROLES_DISABLED_DESCRIPTION = (productName: ProductName) => + i18n.translate('xpack.enterpriseSearch.roleMapping.rolesDisabledDescription', { + defaultMessage: + 'All users set for this deployment currently have full access to {productName}. To restrict access and manage permissions, you must enable role-based access for Enterprise Search.', + values: { productName }, + }); + +export const ROLES_DISABLED_NOTE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.rolesDisabledNote', + { + defaultMessage: + 'Note: enabling role-based access restricts access for both App Search and Workplace Search. Once enabled, review access management for both products, if applicable.', + } +); + +export const ENABLE_ROLES_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.enableRolesButton', + { defaultMessage: 'Enable role-based access' } +); + +export const ENABLE_ROLES_LINK = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.enableRolesLink', + { defaultMessage: 'Learn more about role-based access' } +); + +export const INVITATION_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.invitationDescription', + { + defaultMessage: + 'This URL can be shared with the user, allowing them to accept the Enterprise Search invitation and set a new password', + } +); + +export const NEW_INVITATION_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.newInvitationLabel', + { defaultMessage: 'Invitation URL' } +); + +export const EXISTING_INVITATION_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.existingInvitationLabel', + { defaultMessage: 'The user has not yet accepted the invitation.' } +); + +export const INVITATION_LINK = i18n.translate('xpack.enterpriseSearch.roleMapping.invitationLink', { + defaultMessage: 'Enterprise Search Invitation Link', +}); + +export const NO_USERS_TITLE = i18n.translate('xpack.enterpriseSearch.roleMapping.noUsersTitle', { + defaultMessage: 'No user added', +}); + +export const NO_USERS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.noUsersDescription', + { + defaultMessage: + 'Users can be added individually, for flexibility. Role mappings provide a broader interface for adding large number of users using user attributes.', + } +); + +export const ENABLE_USERS_LINK = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.enableUsersLink', + { defaultMessage: 'Learn more about user management' } +); + +export const NEW_USER_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.newUserLabel', { + defaultMessage: 'Create new user', +}); + +export const EXISTING_USER_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.existingUserLabel', + { defaultMessage: 'Add existing user' } +); + +export const USERNAME_NO_USERS_TEXT = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.usernameNoUsersText', + { defaultMessage: 'No existing user eligible for addition.' } +); + +export const REQUIRED_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.requiredLabel', { + defaultMessage: 'Required', +}); + +export const USERS_HEADING_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.usersHeadingTitle', + { defaultMessage: 'Users' } +); + +export const USERS_HEADING_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.usersHeadingDescription', + { + defaultMessage: + 'User management provides granular access for individual or special permission needs. Users from federated sources such as SAML are managed by role mappings, and excluded from this list.', + } +); + +export const USERS_HEADING_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.usersHeadingLabel', + { defaultMessage: 'Add a new user' } +); + +export const UPDATE_USER_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.updateUserLabel', + { + defaultMessage: 'Update user', + } +); + +export const ADD_USER_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.addUserLabel', { + defaultMessage: 'Add user', +}); + +export const USER_ADDED_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.userAddedLabel', + { + defaultMessage: 'User added', + } +); + +export const USER_UPDATED_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.userUpdatedLabel', + { + defaultMessage: 'User updated', + } +); + +export const NEW_USER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.newUserDescription', + { + defaultMessage: 'Provide granular access and permissions', + } +); + +export const UPDATE_USER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.updateUserDescription', + { + defaultMessage: 'Manage granular access and permissions', + } +); + +export const INVITATION_PENDING_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.invitationPendingLabel', + { + defaultMessage: 'Invitation pending', + } +); + +export const ROLE_MODAL_TEXT = i18n.translate('xpack.enterpriseSearch.roleMapping.roleModalText', { + defaultMessage: + 'Removing a role mapping revokes access to any user corresponding to the mapping attributes, but may not take effect immediately for SAML-governed roles. Users with an active SAML session will retain access until it expires.', +}); + +export const USER_MODAL_TITLE = (username: string) => + i18n.translate('xpack.enterpriseSearch.roleMapping.userModalTitle', { + defaultMessage: 'Remove {username}', + values: { username }, + }); + +export const USER_MODAL_TEXT = i18n.translate('xpack.enterpriseSearch.roleMapping.userModalText', { + defaultMessage: + 'Removing a user immediately revokes access to the experience, unless this user’s attributes also corresponds to a role mapping for native and SAML-governed authentication, in which case associated role mappings should also be reviewed and adjusted, as needed.', +}); + +export const FILTER_USERS_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.filterUsersLabel', + { + defaultMessage: 'Filter users', + } +); + +export const NO_USERS_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.noUsersLabel', { + defaultMessage: 'No matching users found', +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts index b0d10e9692714f..8096b86939ff33 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts @@ -6,9 +6,17 @@ */ export { AttributeSelector } from './attribute_selector'; +export { RolesEmptyPrompt } from './roles_empty_prompt'; export { RoleMappingsTable } from './role_mappings_table'; export { RoleOptionLabel } from './role_option_label'; export { RoleSelector } from './role_selector'; export { RoleMappingFlyout } from './role_mapping_flyout'; export { RoleMappingsHeading } from './role_mappings_heading'; +export { UserAddedInfo } from './user_added_info'; +export { UserFlyout } from './user_flyout'; +export { UsersHeading } from './users_heading'; +export { UserInvitationCallout } from './user_invitation_callout'; +export { UserSelector } from './user_selector'; +export { UsersTable } from './users_table'; export { UsersAndRolesRowActions } from './users_and_roles_row_actions'; +export { UsersEmptyPrompt } from './users_empty_prompt'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.test.tsx index f0bf86fb306c65..5a2958d60dc2ce 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.test.tsx @@ -15,7 +15,13 @@ import { RoleMappingsHeading } from './role_mappings_heading'; describe('RoleMappingsHeading', () => { it('renders ', () => { - const wrapper = shallow(); + const wrapper = shallow( + + ); expect(wrapper.find(EuiTitle)).toHaveLength(1); expect(wrapper.find(EuiText)).toHaveLength(1); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.tsx index eee8b180d32819..1984cc6c60a349 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.tsx @@ -28,13 +28,11 @@ import { interface Props { productName: ProductName; + docsLink: string; onClick(): void; } -// TODO: Replace EuiLink href with acutal docs link when available -const ROLE_MAPPINGS_DOCS_HREF = '#TODO'; - -export const RoleMappingsHeading: React.FC = ({ productName, onClick }) => ( +export const RoleMappingsHeading: React.FC = ({ productName, docsLink, onClick }) => (
@@ -45,7 +43,7 @@ export const RoleMappingsHeading: React.FC = ({ productName, onClick }) =

{ROLE_MAPPINGS_HEADING_DESCRIPTION(productName)}{' '} - + {ROLE_MAPPINGS_HEADING_DOCS_LINK}

diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx index 156b52a4016c32..81a7c06020165c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx @@ -13,7 +13,9 @@ import { mount } from 'enzyme'; import { EuiInMemoryTable, EuiTableHeaderCell } from '@elastic/eui'; -import { ALL_LABEL, ANY_AUTH_PROVIDER_OPTION_LABEL } from './constants'; +import { engines } from '../../app_search/__mocks__/engines.mock'; + +import { ANY_AUTH_PROVIDER_OPTION_LABEL } from './constants'; import { RoleMappingsTable } from './role_mappings_table'; import { UsersAndRolesRowActions } from './users_and_roles_row_actions'; @@ -78,28 +80,30 @@ describe('RoleMappingsTable', () => { expect(handleDeleteMapping).toHaveBeenCalled(); }); - it('shows default message when "accessAllEngines" is true', () => { + it('handles access items display for all items', () => { const wrapper = mount( ); - expect(wrapper.find('[data-test-subj="AccessItemsList"]').prop('children')).toEqual(ALL_LABEL); + expect(wrapper.find('[data-test-subj="AllItems"]')).toHaveLength(1); }); - it('handles display when no items present', () => { - const noItemsRoleMapping = { ...asRoleMapping, engines: [] }; - noItemsRoleMapping.accessAllEngines = false; - + it('handles access items display more than 2 items', () => { + const extraEngine = { + ...engines[0], + id: '3', + }; + + const roleMapping = { + ...asRoleMapping, + engines: [...engines, extraEngine], + accessAllEngines: false, + }; const wrapper = mount( - + ); - - expect(wrapper.find('[data-test-subj="AccessItemsList"]').children().children().text()).toEqual( - '—' + expect(wrapper.find('[data-test-subj="AccessItems"]').prop('children')).toEqual( + `${engines[0].name}, ${engines[1].name} + 1` ); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx index 7696cf03ed4b15..eb9621c7a242c2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import React, { Fragment } from 'react'; +import React from 'react'; -import { EuiIconTip, EuiTextColor, EuiInMemoryTable, EuiBasicTableColumn } from '@elastic/eui'; +import { EuiIconTip, EuiInMemoryTable, EuiBasicTableColumn } from '@elastic/eui'; import { ASRoleMapping } from '../../app_search/types'; import { WSRoleMapping } from '../../workplace_search/types'; @@ -46,8 +46,6 @@ interface Props { handleDeleteMapping(roleMappingId: string): void; } -const noItemsPlaceholder = ; - const getAuthProviderDisplayValue = (authProvider: string) => authProvider === ANY_AUTH_PROVIDER ? ANY_AUTH_PROVIDER_OPTION_LABEL : authProvider; @@ -90,24 +88,18 @@ export const RoleMappingsTable: React.FC = ({ const accessItemsCol: EuiBasicTableColumn = { field: 'accessItems', name: accessHeader, - render: (_, { accessAllEngines, accessItems }: SharedRoleMapping) => ( - - {accessAllEngines ? ( - ALL_LABEL - ) : ( - <> - {accessItems.length === 0 - ? noItemsPlaceholder - : accessItems.map(({ name }) => ( - - {name} -
-
- ))} - - )} -
- ), + render: (_, { accessAllEngines, accessItems }: SharedRoleMapping) => { + // Design calls for showing the first 2 items followed by a +x after those 2. + // ['foo', 'bar', 'baz'] would display as: "foo, bar + 1" + const numItems = accessItems.length; + if (accessAllEngines || numItems === 0) + return {ALL_LABEL}; + const additionalItems = numItems > 2 ? ` + ${numItems - 2}` : ''; + const names = accessItems.map((item) => item.name); + return ( + {names.slice(0, 2).join(', ') + additionalItems} + ); + }, }; const authProviderCol: EuiBasicTableColumn = { @@ -143,6 +135,7 @@ export const RoleMappingsTable: React.FC = ({ const pagination = { hidePerPageOptions: true, + pageSize: 10, }; const search = { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/roles_empty_prompt.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/roles_empty_prompt.test.tsx new file mode 100644 index 00000000000000..8331a45849e3a5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/roles_empty_prompt.test.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButton, EuiLink, EuiEmptyPrompt } from '@elastic/eui'; + +import { RolesEmptyPrompt } from './roles_empty_prompt'; + +describe('RolesEmptyPrompt', () => { + const onEnable = jest.fn(); + + const props = { + productName: 'App Search', + docsLink: 'http://elastic.co', + onEnable, + }; + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(wrapper.find(EuiEmptyPrompt).dive().find(EuiLink).prop('href')).toEqual(props.docsLink); + }); + + it('calls onEnable on change', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + prompt.find(EuiButton).simulate('click'); + + expect(onEnable).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/roles_empty_prompt.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/roles_empty_prompt.tsx new file mode 100644 index 00000000000000..11d50573c45f64 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/roles_empty_prompt.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 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 { EuiEmptyPrompt, EuiButton, EuiLink, EuiSpacer } from '@elastic/eui'; + +import { ProductName } from '../types'; + +import { + ROLES_DISABLED_TITLE, + ROLES_DISABLED_DESCRIPTION, + ROLES_DISABLED_NOTE, + ENABLE_ROLES_BUTTON, + ENABLE_ROLES_LINK, +} from './constants'; + +interface Props { + productName: ProductName; + docsLink: string; + onEnable(): void; +} + +export const RolesEmptyPrompt: React.FC = ({ onEnable, docsLink, productName }) => ( + {ROLES_DISABLED_TITLE}} + body={ + <> +

{ROLES_DISABLED_DESCRIPTION(productName)}

+

{ROLES_DISABLED_NOTE}

+ + } + actions={[ + + {ENABLE_ROLES_BUTTON} + , + , + + {ENABLE_ROLES_LINK} + , + ]} + /> +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.test.tsx new file mode 100644 index 00000000000000..30bdaa0010b584 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiText } from '@elastic/eui'; + +import { UserAddedInfo } from './'; + +describe('UserAddedInfo', () => { + const props = { + username: 'user1', + email: 'test@test.com', + roleType: 'user', + }; + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiText)).toHaveLength(6); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.tsx new file mode 100644 index 00000000000000..a12eae66262a06 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 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 { EuiSpacer, EuiText } from '@elastic/eui'; + +import { USERNAME_LABEL, EMAIL_LABEL } from '../constants'; + +import { ROLE_LABEL } from './constants'; + +interface Props { + username: string; + email: string; + roleType: string; +} + +export const UserAddedInfo: React.FC = ({ username, email, roleType }) => ( + <> + + {USERNAME_LABEL} + + {username} + + + {EMAIL_LABEL} + + {email} + + + {ROLE_LABEL} + + {roleType} + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.test.tsx new file mode 100644 index 00000000000000..43333fe048f234 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiFlyout, EuiText, EuiIcon } from '@elastic/eui'; + +import { + USERS_HEADING_LABEL, + UPDATE_USER_LABEL, + USER_UPDATED_LABEL, + NEW_USER_DESCRIPTION, + UPDATE_USER_DESCRIPTION, +} from './constants'; + +import { UserFlyout } from './'; + +describe('UserFlyout', () => { + const closeUserFlyout = jest.fn(); + const handleSaveUser = jest.fn(); + + const props = { + children:
, + isNew: true, + isComplete: false, + disabled: false, + closeUserFlyout, + handleSaveUser, + }; + + it('renders for new user', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiFlyout)).toHaveLength(1); + expect(wrapper.find('h2').prop('children')).toEqual(USERS_HEADING_LABEL); + expect(wrapper.find(EuiText).prop('children')).toEqual(

{NEW_USER_DESCRIPTION}

); + }); + + it('renders for existing user', () => { + const wrapper = shallow(); + + expect(wrapper.find('h2').prop('children')).toEqual(UPDATE_USER_LABEL); + expect(wrapper.find(EuiText).prop('children')).toEqual(

{UPDATE_USER_DESCRIPTION}

); + }); + + it('renders icon and message for completed user', () => { + const wrapper = shallow(); + const icon = ( + + ); + const children = ( + + {USER_UPDATED_LABEL} {icon} + + ); + + expect(wrapper.find('h2').prop('children')).toEqual(children); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.tsx new file mode 100644 index 00000000000000..e13a56a716929f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiIcon, + EuiText, + EuiTitle, + EuiSpacer, +} from '@elastic/eui'; + +interface Props { + children: React.ReactNode; + isNew: boolean; + isComplete: boolean; + disabled: boolean; + closeUserFlyout(): void; + handleSaveUser(): void; +} + +import { CANCEL_BUTTON_LABEL, CLOSE_BUTTON_LABEL } from '../constants'; + +import { + USERS_HEADING_LABEL, + UPDATE_USER_LABEL, + ADD_USER_LABEL, + USER_ADDED_LABEL, + USER_UPDATED_LABEL, + NEW_USER_DESCRIPTION, + UPDATE_USER_DESCRIPTION, +} from './constants'; + +export const UserFlyout: React.FC = ({ + children, + isNew, + isComplete, + disabled, + closeUserFlyout, + handleSaveUser, +}) => { + const savedIcon = ( + + ); + const IS_EDITING_HEADING = isNew ? USERS_HEADING_LABEL : UPDATE_USER_LABEL; + const IS_EDITING_DESCRIPTION = isNew ? NEW_USER_DESCRIPTION : UPDATE_USER_DESCRIPTION; + const USER_SAVED_HEADING = isNew ? USER_ADDED_LABEL : USER_UPDATED_LABEL; + const IS_COMPLETE_HEADING = ( + + {USER_SAVED_HEADING} {savedIcon} + + ); + + const editingFooterActions = ( + + + {CANCEL_BUTTON_LABEL} + + + + {isNew ? ADD_USER_LABEL : UPDATE_USER_LABEL} + + + + ); + + const completedFooterAction = ( + + + + {CLOSE_BUTTON_LABEL} + + + + ); + + return ( + + + +

{isComplete ? IS_COMPLETE_HEADING : IS_EDITING_HEADING}

+
+ {!isComplete && ( + +

{IS_EDITING_DESCRIPTION}

+
+ )} +
+ + {children} + + + {isComplete ? completedFooterAction : editingFooterActions} +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.test.tsx new file mode 100644 index 00000000000000..d5272a26715b67 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiText, EuiButtonIcon, EuiCopy } from '@elastic/eui'; + +import { EXISTING_INVITATION_LABEL } from './constants'; + +import { UserInvitationCallout } from './'; + +describe('UserInvitationCallout', () => { + const props = { + isNew: true, + invitationCode: 'test@test.com', + urlPrefix: 'http://foo', + }; + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiText)).toHaveLength(2); + }); + + it('renders the copy button', () => { + const copyMock = jest.fn(); + const wrapper = shallow(); + + const copyEl = shallow(
{wrapper.find(EuiCopy).props().children(copyMock)}
); + expect(copyEl.find(EuiButtonIcon).props().onClick).toEqual(copyMock); + }); + + it('renders existing invitation label', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiText).first().prop('children')).toEqual( + {EXISTING_INVITATION_LABEL} + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.tsx new file mode 100644 index 00000000000000..8310077ad6f2e3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiCopy, EuiButtonIcon, EuiSpacer, EuiText, EuiLink } from '@elastic/eui'; + +import { + INVITATION_DESCRIPTION, + NEW_INVITATION_LABEL, + EXISTING_INVITATION_LABEL, + INVITATION_LINK, +} from './constants'; + +interface Props { + isNew: boolean; + invitationCode: string; + urlPrefix: string; +} + +export const UserInvitationCallout: React.FC = ({ isNew, invitationCode, urlPrefix }) => { + const link = urlPrefix + invitationCode; + const label = isNew ? NEW_INVITATION_LABEL : EXISTING_INVITATION_LABEL; + + return ( + <> + {!isNew && } + + {label} + + + {INVITATION_DESCRIPTION} + + + {INVITATION_LINK} + {' '} + + {(copy) => } + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.test.tsx new file mode 100644 index 00000000000000..08ddc7ba5427fa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.test.tsx @@ -0,0 +1,112 @@ +/* + * Copyright 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 { elasticsearchUsers } from './__mocks__/elasticsearch_users'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiFormRow } from '@elastic/eui'; + +import { Role as ASRole } from '../../app_search/types'; + +import { REQUIRED_LABEL, USERNAME_NO_USERS_TEXT } from './constants'; + +import { UserSelector } from './'; + +const simulatedEvent = { + target: { value: 'foo' }, +}; + +describe('UserSelector', () => { + const setUserExisting = jest.fn(); + const setElasticsearchUsernameValue = jest.fn(); + const setElasticsearchEmailValue = jest.fn(); + const handleRoleChange = jest.fn(); + const handleUsernameSelectChange = jest.fn(); + + const roleType = ('user' as unknown) as ASRole; + + const props = { + isNewUser: true, + userFormUserIsExisting: true, + elasticsearchUsers, + elasticsearchUser: elasticsearchUsers[0], + roleTypes: [roleType], + roleType, + setUserExisting, + setElasticsearchUsernameValue, + setElasticsearchEmailValue, + handleRoleChange, + handleUsernameSelectChange, + }; + + it('renders Role select and calls method', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="RoleSelect"]').simulate('change', simulatedEvent); + + expect(handleRoleChange).toHaveBeenCalled(); + }); + + it('renders when updating user', () => { + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="UsernameInput"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="EmailInput"]')).toHaveLength(1); + }); + + it('renders Username select and calls method', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="UsernameSelect"]').simulate('change', simulatedEvent); + + expect(handleUsernameSelectChange).toHaveBeenCalled(); + }); + + it('renders Existing user radio and calls method', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="ExistingUserRadio"]').simulate('change'); + + expect(setUserExisting).toHaveBeenCalledWith(true); + }); + + it('renders Email input and calls method', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="EmailInput"]').simulate('change', simulatedEvent); + + expect(setElasticsearchEmailValue).toHaveBeenCalled(); + }); + + it('renders Username input and calls method', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="UsernameInput"]').simulate('change', simulatedEvent); + + expect(setElasticsearchUsernameValue).toHaveBeenCalled(); + }); + + it('renders New user radio and calls method', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="NewUserRadio"]').simulate('change'); + + expect(setUserExisting).toHaveBeenCalledWith(false); + }); + + it('renders helpText when values are empty', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(EuiFormRow).at(0).prop('helpText')).toEqual(USERNAME_NO_USERS_TEXT); + expect(wrapper.find(EuiFormRow).at(1).prop('helpText')).toEqual(REQUIRED_LABEL); + expect(wrapper.find(EuiFormRow).at(2).prop('helpText')).toEqual(REQUIRED_LABEL); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.tsx new file mode 100644 index 00000000000000..70348bf29894aa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.tsx @@ -0,0 +1,159 @@ +/* + * Copyright 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 { + EuiFieldText, + EuiRadio, + EuiFormRow, + EuiSelect, + EuiSelectOption, + EuiSpacer, +} from '@elastic/eui'; + +import { Role as ASRole } from '../../app_search/types'; +import { ElasticsearchUser } from '../../shared/types'; +import { Role as WSRole } from '../../workplace_search/types'; + +import { USERNAME_LABEL, EMAIL_LABEL } from '../constants'; + +import { + NEW_USER_LABEL, + EXISTING_USER_LABEL, + USERNAME_NO_USERS_TEXT, + REQUIRED_LABEL, + ROLE_LABEL, +} from './constants'; + +type SharedRole = WSRole | ASRole; + +interface Props { + isNewUser: boolean; + userFormUserIsExisting: boolean; + elasticsearchUsers: ElasticsearchUser[]; + elasticsearchUser: ElasticsearchUser; + roleTypes: SharedRole[]; + roleType: SharedRole; + setUserExisting(userFormUserIsExisting: boolean): void; + setElasticsearchUsernameValue(username: string): void; + setElasticsearchEmailValue(email: string): void; + handleRoleChange(roleType: SharedRole): void; + handleUsernameSelectChange(username: string): void; +} + +export const UserSelector: React.FC = ({ + isNewUser, + userFormUserIsExisting, + elasticsearchUsers, + elasticsearchUser, + roleTypes, + roleType, + setUserExisting, + setElasticsearchUsernameValue, + setElasticsearchEmailValue, + handleRoleChange, + handleUsernameSelectChange, +}) => { + const roleOptions = roleTypes.map((role) => ({ id: role, text: role })); + const usernameOptions = elasticsearchUsers.map(({ username }) => ({ + id: username, + text: username, + })); + const hasElasticsearchUsers = elasticsearchUsers.length > 0; + const showNewUserExistingUserControls = userFormUserIsExisting && hasElasticsearchUsers; + + const roleSelect = ( + + handleRoleChange(e.target.value as SharedRole)} + /> + + ); + + const emailInput = ( + + setElasticsearchEmailValue(e.target.value)} + /> + + ); + + const usernameAndEmailControls = ( + <> + + setElasticsearchUsernameValue(e.target.value)} + /> + + {elasticsearchUser.email !== null && emailInput} + {roleSelect} + + ); + + const existingUserControls = ( + <> + + + handleUsernameSelectChange(e.target.value)} + /> + + {roleSelect} + + ); + + const newUserControls = ( + <> + + {usernameAndEmailControls} + + ); + + const createUserControls = ( + <> + + setUserExisting(true)} + disabled={!hasElasticsearchUsers} + /> + + + {showNewUserExistingUserControls && existingUserControls} + + setUserExisting(false)} + /> + {!showNewUserExistingUserControls && newUserControls} + + ); + + return isNewUser ? createUserControls : usernameAndEmailControls; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_and_roles_row_actions.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_and_roles_row_actions.test.tsx index dbb47b50d40669..5f1fefc688c77c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_and_roles_row_actions.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_and_roles_row_actions.test.tsx @@ -9,15 +9,23 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiButtonIcon } from '@elastic/eui'; +import { EuiButtonIcon, EuiConfirmModal } from '@elastic/eui'; + +import { + REMOVE_ROLE_MAPPING_TITLE, + REMOVE_ROLE_MAPPING_BUTTON, + ROLE_MODAL_TEXT, +} from './constants'; import { UsersAndRolesRowActions } from './users_and_roles_row_actions'; describe('UsersAndRolesRowActions', () => { const onManageClick = jest.fn(); const onDeleteClick = jest.fn(); + const username = 'foo'; const props = { + username, onManageClick, onDeleteClick, }; @@ -40,7 +48,19 @@ describe('UsersAndRolesRowActions', () => { const wrapper = shallow(); const button = wrapper.find(EuiButtonIcon).last(); button.simulate('click'); + wrapper.find(EuiConfirmModal).prop('onConfirm')!({} as any); expect(onDeleteClick).toHaveBeenCalled(); }); + + it('renders role mapping confirm modal text', () => { + const wrapper = shallow(); + const button = wrapper.find(EuiButtonIcon).last(); + button.simulate('click'); + const modal = wrapper.find(EuiConfirmModal); + + expect(modal.prop('title')).toEqual(REMOVE_ROLE_MAPPING_TITLE); + expect(modal.prop('children')).toEqual(

{ROLE_MODAL_TEXT}

); + expect(modal.prop('confirmButtonText')).toEqual(REMOVE_ROLE_MAPPING_BUTTON); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_and_roles_row_actions.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_and_roles_row_actions.tsx index 3d956c0aabd688..a3b0d24769bf6f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_and_roles_row_actions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_and_roles_row_actions.tsx @@ -5,20 +5,65 @@ * 2.0. */ -import React from 'react'; +import React, { useState } from 'react'; -import { EuiButtonIcon } from '@elastic/eui'; +import { EuiButtonIcon, EuiConfirmModal } from '@elastic/eui'; -import { MANAGE_BUTTON_LABEL, DELETE_BUTTON_LABEL } from '../constants'; +import { CANCEL_BUTTON_LABEL, MANAGE_BUTTON_LABEL, DELETE_BUTTON_LABEL } from '../constants'; + +import { + REMOVE_ROLE_MAPPING_TITLE, + REMOVE_ROLE_MAPPING_BUTTON, + REMOVE_USER_BUTTON, + ROLE_MODAL_TEXT, + USER_MODAL_TITLE, + USER_MODAL_TEXT, +} from './constants'; interface Props { + username?: string; onManageClick(): void; onDeleteClick(): void; } -export const UsersAndRolesRowActions: React.FC = ({ onManageClick, onDeleteClick }) => ( - <> - {' '} - - -); +export const UsersAndRolesRowActions: React.FC = ({ + onManageClick, + onDeleteClick, + username, +}) => { + const [deleteModalVisible, setVisible] = useState(false); + const showDeleteModal = () => setVisible(true); + const closeDeleteModal = () => setVisible(false); + const title = username ? USER_MODAL_TITLE(username) : REMOVE_ROLE_MAPPING_TITLE; + const text = username ? USER_MODAL_TEXT : ROLE_MODAL_TEXT; + const confirmButton = username ? REMOVE_USER_BUTTON : REMOVE_ROLE_MAPPING_BUTTON; + + const deleteModal = ( + { + onDeleteClick(); + closeDeleteModal(); + }} + cancelButtonText={CANCEL_BUTTON_LABEL} + confirmButtonText={confirmButton} + buttonColor="danger" + defaultFocusedButton="confirm" + > +

{text}

+
+ ); + + return ( + <> + {deleteModalVisible && deleteModal} + {' '} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_empty_prompt.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_empty_prompt.test.tsx new file mode 100644 index 00000000000000..9110c09827c490 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_empty_prompt.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt } from '@elastic/eui'; + +import { UsersEmptyPrompt } from './'; + +describe('UsersEmptyPrompt', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_empty_prompt.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_empty_prompt.tsx new file mode 100644 index 00000000000000..42bf690c388c48 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_empty_prompt.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 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 { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiEmptyPrompt, + EuiLink, + EuiSpacer, +} from '@elastic/eui'; + +import { docLinks } from '../doc_links'; + +import { NO_USERS_TITLE, NO_USERS_DESCRIPTION, ENABLE_USERS_LINK } from './constants'; + +const USERS_DOCS_URL = `${docLinks.enterpriseSearchBase}/users-access.html`; + +export const UsersEmptyPrompt: React.FC = () => ( + + + + + {NO_USERS_TITLE}} + body={

{NO_USERS_DESCRIPTION}

} + actions={ + + {ENABLE_USERS_LINK} + + } + /> +
+
+
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_heading.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_heading.test.tsx new file mode 100644 index 00000000000000..9bae93079e89fb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_heading.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButton, EuiText, EuiTitle } from '@elastic/eui'; + +import { UsersHeading } from './'; + +describe('UsersHeading', () => { + const onClick = jest.fn(); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiText)).toHaveLength(1); + expect(wrapper.find(EuiTitle)).toHaveLength(1); + }); + + it('handles button click', () => { + const wrapper = shallow(); + wrapper.find(EuiButton).simulate('click'); + + expect(onClick).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_heading.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_heading.tsx new file mode 100644 index 00000000000000..8d097e21e9c3fe --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_heading.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 React from 'react'; + +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; + +import { USERS_HEADING_TITLE, USERS_HEADING_DESCRIPTION, USERS_HEADING_LABEL } from './constants'; + +interface Props { + onClick(): void; +} + +export const UsersHeading: React.FC = ({ onClick }) => ( + <> + + + +

{USERS_HEADING_TITLE}

+
+ +

{USERS_HEADING_DESCRIPTION}

+
+
+ + + {USERS_HEADING_LABEL} + + +
+ + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.test.tsx new file mode 100644 index 00000000000000..dc1a2713ced128 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 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 { asSingleUserRoleMapping, wsSingleUserRoleMapping, asRoleMapping } from './__mocks__/roles'; + +import React from 'react'; + +import { shallow, mount } from 'enzyme'; + +import { EuiInMemoryTable, EuiTextColor } from '@elastic/eui'; + +import { engines } from '../../app_search/__mocks__/engines.mock'; + +import { UsersAndRolesRowActions } from './users_and_roles_row_actions'; + +import { UsersTable } from './'; + +describe('UsersTable', () => { + const initializeSingleUserRoleMapping = jest.fn(); + const handleDeleteMapping = jest.fn(); + const props = { + accessItemKey: 'groups' as 'groups' | 'engines', + singleUserRoleMappings: [wsSingleUserRoleMapping], + initializeSingleUserRoleMapping, + handleDeleteMapping, + }; + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiInMemoryTable)).toHaveLength(1); + }); + + it('handles manage click', () => { + const wrapper = mount(); + wrapper.find(UsersAndRolesRowActions).prop('onManageClick')(); + + expect(initializeSingleUserRoleMapping).toHaveBeenCalled(); + }); + + it('handles delete click', () => { + const wrapper = mount(); + wrapper.find(UsersAndRolesRowActions).prop('onDeleteClick')(); + + expect(handleDeleteMapping).toHaveBeenCalled(); + }); + + it('handles display when no email present', () => { + const userWithNoEmail = { + ...wsSingleUserRoleMapping, + elasticsearchUser: { + email: null, + username: 'foo', + }, + }; + const wrapper = mount(); + + expect(wrapper.find(EuiTextColor)).toHaveLength(1); + }); + + it('handles access items display for all items', () => { + const userWithAllItems = { + ...asSingleUserRoleMapping, + roleMapping: { + ...asRoleMapping, + engines: [], + }, + }; + const wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="AllItems"]')).toHaveLength(1); + }); + + it('handles access items display more than 2 items', () => { + const extraEngine = { + ...engines[0], + id: '3', + }; + const userWithAllItems = { + ...asSingleUserRoleMapping, + roleMapping: { + ...asRoleMapping, + engines: [...engines, extraEngine], + }, + }; + const wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="AccessItems"]').prop('children')).toEqual( + `${engines[0].name}, ${engines[1].name} + 1` + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx new file mode 100644 index 00000000000000..86dc2c2626229f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiBadge, EuiBasicTableColumn, EuiInMemoryTable, EuiTextColor } from '@elastic/eui'; + +import { ASRoleMapping } from '../../app_search/types'; +import { SingleUserRoleMapping } from '../../shared/types'; +import { WSRoleMapping } from '../../workplace_search/types'; + +import { + INVITATION_PENDING_LABEL, + ALL_LABEL, + FILTER_USERS_LABEL, + NO_USERS_LABEL, + ROLE_LABEL, + USERNAME_LABEL, + EMAIL_LABEL, + GROUPS_LABEL, + ENGINES_LABEL, +} from './constants'; + +import { UsersAndRolesRowActions } from './'; + +interface AccessItem { + name: string; +} + +interface SharedUser extends SingleUserRoleMapping { + accessItems: AccessItem[]; + username: string; + email: string | null; + roleType: string; + id: string; +} + +interface SharedRoleMapping extends ASRoleMapping, WSRoleMapping { + accessItems: AccessItem[]; +} + +interface Props { + accessItemKey: 'groups' | 'engines'; + singleUserRoleMappings: Array>; + initializeSingleUserRoleMapping(roleId: string): string; + handleDeleteMapping(roleId: string): string; +} + +const noItemsPlaceholder = ; +const invitationBadge = {INVITATION_PENDING_LABEL}; + +export const UsersTable: React.FC = ({ + accessItemKey, + singleUserRoleMappings, + initializeSingleUserRoleMapping, + handleDeleteMapping, +}) => { + // 'accessItems' is needed because App Search has `engines` and Workplace Search has `groups`. + const users = ((singleUserRoleMappings as SharedUser[]).map((user) => ({ + username: user.elasticsearchUser.username, + email: user.elasticsearchUser.email, + roleType: user.roleMapping.roleType, + id: user.roleMapping.id, + accessItems: (user.roleMapping as SharedRoleMapping)[accessItemKey], + invitation: user.invitation, + })) as unknown) as Array>; + + const columns: Array> = [ + { + field: 'username', + name: USERNAME_LABEL, + render: (_, { username }: SharedUser) => username, + }, + { + field: 'email', + name: EMAIL_LABEL, + render: (_, { email, invitation }: SharedUser) => { + if (!email) return noItemsPlaceholder; + return ( +
+ {email} {invitation && invitationBadge} +
+ ); + }, + }, + { + field: 'roleType', + name: ROLE_LABEL, + render: (_, user: SharedUser) => user.roleType, + }, + { + field: 'accessItems', + name: accessItemKey === 'groups' ? GROUPS_LABEL : ENGINES_LABEL, + render: (_, { accessItems }: SharedUser) => { + // Design calls for showing the first 2 items followed by a +x after those 2. + // ['foo', 'bar', 'baz'] would display as: "foo, bar + 1" + const numItems = accessItems.length; + if (numItems === 0) return {ALL_LABEL}; + const additionalItems = numItems > 2 ? ` + ${numItems - 2}` : ''; + const names = accessItems.map((item) => item.name); + return ( + {names.slice(0, 2).join(', ') + additionalItems} + ); + }, + }, + { + field: 'id', + name: '', + render: (_, { id, username }: SharedUser) => ( + initializeSingleUserRoleMapping(id)} + onDeleteClick={() => handleDeleteMapping(id)} + /> + ), + }, + ]; + + const pagination = { + hidePerPageOptions: true, + pageSize: 10, + }; + + const search = { + box: { + incremental: true, + fullWidth: false, + placeholder: FILTER_USERS_LABEL, + 'data-test-subj': 'UsersTableSearchInput', + }, + }; + + return ( + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts index 67208c63ddf4cc..e6d2c67d1baf83 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts @@ -40,3 +40,19 @@ export interface RoleMapping { const productNames = [APP_SEARCH_PLUGIN.NAME, WORKPLACE_SEARCH_PLUGIN.NAME] as const; export type ProductName = typeof productNames[number]; + +export interface Invitation { + email: string; + code: string; +} + +export interface ElasticsearchUser { + email: string | null; + username: string; +} + +export interface SingleUserRoleMapping { + invitation: Invitation; + elasticsearchUser: ElasticsearchUser; + roleMapping: T; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.tsx index a4eb228eff92f0..050aaf1dadf891 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.tsx @@ -11,8 +11,8 @@ import { useValues } from 'kea'; import { EuiTable, EuiTableBody, EuiTablePagination } from '@elastic/eui'; import { Pager } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { USERNAME_LABEL, EMAIL_LABEL } from '../../../../shared/constants'; import { TableHeader } from '../../../../shared/table_header'; import { AppLogic } from '../../../app_logic'; import { UserRow } from '../../../components/shared/user_row'; @@ -20,27 +20,15 @@ import { User } from '../../../types'; import { GroupLogic } from '../group_logic'; const USERS_PER_PAGE = 10; -const USERNAME_TABLE_HEADER = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.groups.groupsUsersTable.usernameTableHeader', - { - defaultMessage: 'Username', - } -); -const EMAIL_TABLE_HEADER = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.groups.groupsUsersTable.emailTableHeader', - { - defaultMessage: 'Email', - } -); export const GroupUsersTable: React.FC = () => { const { isFederatedAuth } = useValues(AppLogic); const { group: { users }, } = useValues(GroupLogic); - const headerItems = [USERNAME_TABLE_HEADER]; + const headerItems = [USERNAME_LABEL]; if (!isFederatedAuth) { - headerItems.push(EMAIL_TABLE_HEADER); + headerItems.push(EMAIL_LABEL); } const [firstItem, setFirstItem] = useState(0); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/constants.ts index 92c8b7827b9b67..809b631c783918 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/constants.ts @@ -7,14 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const DELETE_ROLE_MAPPING_MESSAGE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.roleMapping.deleteRoleMappingButtonMessage', - { - defaultMessage: - 'Are you sure you want to permanently delete this mapping? This action is not reversible and some users might lose access.', - } -); - export const ROLE_MAPPING_DELETED_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.roleMappingDeletedMessage', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx index b153d012241939..01d32bec14ebd7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx @@ -10,9 +10,14 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; -import { RoleMappingsTable, RoleMappingsHeading } from '../../../shared/role_mapping'; +import { + RoleMappingsTable, + RoleMappingsHeading, + RolesEmptyPrompt, +} from '../../../shared/role_mapping'; import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; import { WorkplaceSearchPageTemplate } from '../../components/layout'; +import { SECURITY_DOCS_URL } from '../../routes'; import { ROLE_MAPPINGS_TABLE_HEADER } from './constants'; @@ -20,9 +25,12 @@ import { RoleMapping } from './role_mapping'; import { RoleMappingsLogic } from './role_mappings_logic'; export const RoleMappings: React.FC = () => { - const { initializeRoleMappings, initializeRoleMapping, handleDeleteMapping } = useActions( - RoleMappingsLogic - ); + const { + enableRoleBasedAccess, + initializeRoleMappings, + initializeRoleMapping, + handleDeleteMapping, + } = useActions(RoleMappingsLogic); const { roleMappings, @@ -35,10 +43,19 @@ export const RoleMappings: React.FC = () => { initializeRoleMappings(); }, []); + const rolesEmptyState = ( + + ); + const roleMappingsSection = (
initializeRoleMapping()} /> { pageChrome={[ROLE_MAPPINGS_TITLE]} pageHeader={{ pageTitle: ROLE_MAPPINGS_TITLE }} isLoading={dataLoading} + isEmptyState={roleMappings.length < 1} + emptyState={rolesEmptyState} > {roleMappingFlyoutOpen && } {roleMappingsSection} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts index 4ee530870284e0..a4bbddbd23b497 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts @@ -90,6 +90,13 @@ describe('RoleMappingsLogic', () => { expect(RoleMappingsLogic.values.selectedGroups).toEqual(new Set([defaultGroup.id])); }); + it('setRoleMappings', () => { + RoleMappingsLogic.actions.setRoleMappings({ roleMappings: [wsRoleMapping] }); + + expect(RoleMappingsLogic.values.roleMappings).toEqual([wsRoleMapping]); + expect(RoleMappingsLogic.values.dataLoading).toEqual(false); + }); + it('handleRoleChange', () => { RoleMappingsLogic.actions.handleRoleChange('user'); @@ -234,6 +241,30 @@ describe('RoleMappingsLogic', () => { }); describe('listeners', () => { + describe('enableRoleBasedAccess', () => { + it('calls API and sets values', async () => { + const setRoleMappingsSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMappings'); + http.post.mockReturnValue(Promise.resolve(mappingsServerProps)); + RoleMappingsLogic.actions.enableRoleBasedAccess(); + + expect(RoleMappingsLogic.values.dataLoading).toEqual(true); + + expect(http.post).toHaveBeenCalledWith( + '/api/workplace_search/org/role_mappings/enable_role_based_access' + ); + await nextTick(); + expect(setRoleMappingsSpy).toHaveBeenCalledWith(mappingsServerProps); + }); + + it('handles error', async () => { + http.post.mockReturnValue(Promise.reject('this is an error')); + RoleMappingsLogic.actions.enableRoleBasedAccess(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + describe('initializeRoleMappings', () => { it('calls API and sets values', async () => { const setRoleMappingsDataSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMappingsData'); @@ -351,18 +382,8 @@ describe('RoleMappingsLogic', () => { }); describe('handleDeleteMapping', () => { - let confirmSpy: any; const roleMappingId = 'r1'; - beforeEach(() => { - confirmSpy = jest.spyOn(window, 'confirm'); - confirmSpy.mockImplementation(jest.fn(() => true)); - }); - - afterEach(() => { - confirmSpy.mockRestore(); - }); - it('calls API and refreshes list', async () => { const initializeRoleMappingsSpy = jest.spyOn( RoleMappingsLogic.actions, @@ -388,15 +409,6 @@ describe('RoleMappingsLogic', () => { expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); - - it('will do nothing if not confirmed', async () => { - RoleMappingsLogic.actions.setRoleMapping(wsRoleMapping); - window.confirm = () => false; - RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); - - expect(http.delete).not.toHaveBeenCalled(); - await nextTick(); - }); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts index 361425b7a78a12..76b41b2f383ebd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts @@ -20,7 +20,6 @@ import { AttributeName } from '../../../shared/types'; import { RoleGroup, WSRoleMapping, Role } from '../../types'; import { - DELETE_ROLE_MAPPING_MESSAGE, ROLE_MAPPING_DELETED_MESSAGE, ROLE_MAPPING_CREATED_MESSAGE, ROLE_MAPPING_UPDATED_MESSAGE, @@ -57,10 +56,16 @@ interface RoleMappingsActions { initializeRoleMappings(): void; resetState(): void; setRoleMapping(roleMapping: WSRoleMapping): { roleMapping: WSRoleMapping }; + setRoleMappings({ + roleMappings, + }: { + roleMappings: WSRoleMapping[]; + }): { roleMappings: WSRoleMapping[] }; setRoleMappingsData(data: RoleMappingsServerDetails): RoleMappingsServerDetails; openRoleMappingFlyout(): void; closeRoleMappingFlyout(): void; setRoleMappingErrors(errors: string[]): { errors: string[] }; + enableRoleBasedAccess(): void; } interface RoleMappingsValues { @@ -88,6 +93,7 @@ export const RoleMappingsLogic = kea data, setRoleMapping: (roleMapping: WSRoleMapping) => ({ roleMapping }), + setRoleMappings: ({ roleMappings }: { roleMappings: WSRoleMapping[] }) => ({ roleMappings }), setRoleMappingErrors: (errors: string[]) => ({ errors }), handleAuthProviderChange: (value: string[]) => ({ value }), handleRoleChange: (roleType: Role) => ({ roleType }), @@ -98,6 +104,7 @@ export const RoleMappingsLogic = kea ({ value }), handleAllGroupsSelectionChange: (selected: boolean) => ({ selected }), + enableRoleBasedAccess: true, resetState: true, initializeRoleMappings: true, initializeRoleMapping: (roleMappingId?: string) => ({ roleMappingId }), @@ -111,13 +118,16 @@ export const RoleMappingsLogic = kea false, + setRoleMappings: () => false, resetState: () => true, + enableRoleBasedAccess: () => true, }, ], roleMappings: [ [], { setRoleMappingsData: (_, { roleMappings }) => roleMappings, + setRoleMappings: (_, { roleMappings }) => roleMappings, resetState: () => [], }, ], @@ -260,6 +270,17 @@ export const RoleMappingsLogic = kea ({ + enableRoleBasedAccess: async () => { + const { http } = HttpLogic.values; + const route = '/api/workplace_search/org/role_mappings/enable_role_based_access'; + + try { + const response = await http.post(route); + actions.setRoleMappings(response); + } catch (e) { + flashAPIErrors(e); + } + }, initializeRoleMappings: async () => { const { http } = HttpLogic.values; const route = '/api/workplace_search/org/role_mappings'; @@ -279,14 +300,12 @@ export const RoleMappingsLogic = kea { diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts index 718597c12e9c5b..7d9f08627516be 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts @@ -7,7 +7,11 @@ import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; -import { registerRoleMappingsRoute, registerRoleMappingRoute } from './role_mappings'; +import { + registerEnableRoleMappingsRoute, + registerRoleMappingsRoute, + registerRoleMappingRoute, +} from './role_mappings'; const roleMappingBaseSchema = { rules: { username: 'user' }, @@ -18,6 +22,29 @@ const roleMappingBaseSchema = { }; describe('role mappings routes', () => { + describe('POST /api/app_search/role_mappings/enable_role_based_access', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/role_mappings/enable_role_based_access', + }); + + registerEnableRoleMappingsRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/role_mappings/enable_role_based_access', + }); + }); + }); + describe('GET /api/app_search/role_mappings', () => { let mockRouter: MockRouter; @@ -36,7 +63,7 @@ describe('role mappings routes', () => { it('creates a request handler', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/role_mappings', + path: '/as/role_mappings', }); }); }); @@ -59,7 +86,7 @@ describe('role mappings routes', () => { it('creates a request handler', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/role_mappings', + path: '/as/role_mappings', }); }); @@ -94,7 +121,7 @@ describe('role mappings routes', () => { it('creates a request handler', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/role_mappings/:id', + path: '/as/role_mappings/:id', }); }); @@ -129,7 +156,7 @@ describe('role mappings routes', () => { it('creates a request handler', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/role_mappings/:id', + path: '/as/role_mappings/:id', }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts index 75724a3344d6de..da620be2ea9505 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts @@ -17,6 +17,21 @@ const roleMappingBaseSchema = { authProvider: schema.arrayOf(schema.string()), }; +export function registerEnableRoleMappingsRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.post( + { + path: '/api/app_search/role_mappings/enable_role_based_access', + validate: false, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/role_mappings/enable_role_based_access', + }) + ); +} + export function registerRoleMappingsRoute({ router, enterpriseSearchRequestHandler, @@ -27,7 +42,7 @@ export function registerRoleMappingsRoute({ validate: false, }, enterpriseSearchRequestHandler.createRequest({ - path: '/role_mappings', + path: '/as/role_mappings', }) ); @@ -39,7 +54,7 @@ export function registerRoleMappingsRoute({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/role_mappings', + path: '/as/role_mappings', }) ); } @@ -59,7 +74,7 @@ export function registerRoleMappingRoute({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/role_mappings/:id', + path: '/as/role_mappings/:id', }) ); @@ -73,12 +88,13 @@ export function registerRoleMappingRoute({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/role_mappings/:id', + path: '/as/role_mappings/:id', }) ); } export const registerRoleMappingsRoutes = (dependencies: RouteDependencies) => { + registerEnableRoleMappingsRoute(dependencies); registerRoleMappingsRoute(dependencies); registerRoleMappingRoute(dependencies); }; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts index a945866da5ef21..aa0e9983166c02 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts @@ -7,9 +7,36 @@ import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; -import { registerOrgRoleMappingsRoute, registerOrgRoleMappingRoute } from './role_mappings'; +import { + registerOrgEnableRoleMappingsRoute, + registerOrgRoleMappingsRoute, + registerOrgRoleMappingRoute, +} from './role_mappings'; describe('role mappings routes', () => { + describe('POST /api/workplace_search/org/role_mappings/enable_role_based_access', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/workplace_search/org/role_mappings/enable_role_based_access', + }); + + registerOrgEnableRoleMappingsRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/role_mappings/enable_role_based_access', + }); + }); + }); + describe('GET /api/workplace_search/org/role_mappings', () => { let mockRouter: MockRouter; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts index a0fcec63cbb272..cea7bcb311ce8a 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts @@ -17,6 +17,21 @@ const roleMappingBaseSchema = { authProvider: schema.arrayOf(schema.string()), }; +export function registerOrgEnableRoleMappingsRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.post( + { + path: '/api/workplace_search/org/role_mappings/enable_role_based_access', + validate: false, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/role_mappings/enable_role_based_access', + }) + ); +} + export function registerOrgRoleMappingsRoute({ router, enterpriseSearchRequestHandler, @@ -79,6 +94,7 @@ export function registerOrgRoleMappingRoute({ } export const registerRoleMappingsRoutes = (dependencies: RouteDependencies) => { + registerOrgEnableRoleMappingsRoute(dependencies); registerOrgRoleMappingsRoute(dependencies); registerOrgRoleMappingRoute(dependencies); }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e246cd06810535..17c31b8cd115ec 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7521,7 +7521,6 @@ "xpack.enterpriseSearch.appSearch.credentials.title": "資格情報", "xpack.enterpriseSearch.appSearch.credentials.updateWarning": "既存の API キーはユーザー間で共有できます。このキーのアクセス権を変更すると、このキーにアクセスできるすべてのユーザーに影響します。", "xpack.enterpriseSearch.appSearch.credentials.updateWarningTitle": "十分ご注意ください!", - "xpack.enterpriseSearch.appSearch.deleteRoleMappingMessage": "このマッピングを完全に削除しますか?このアクションは元に戻せません。一部のユーザーがアクセスを失う可能性があります。", "xpack.enterpriseSearch.appSearch.DEV_ROLE_TYPE_DESCRIPTION": "開発者はエンジンのすべての要素を管理できます。", "xpack.enterpriseSearch.appSearch.documentCreation.api.description": "{documentsApiLink}を使用すると、新しいドキュメントをエンジンに追加できるほか、ドキュメントの更新、IDによるドキュメントの取得、ドキュメントの削除が可能です。基本操作を説明するさまざまな{clientLibrariesLink}があります。", "xpack.enterpriseSearch.appSearch.documentCreation.api.example": "実行中のAPIを表示するには、コマンドラインまたはクライアントライブラリを使用して、次の要求の例で実験することができます。", @@ -7906,6 +7905,7 @@ "xpack.enterpriseSearch.appSearch.tokens.search.description": "エンドポイントのみの検索では、公開検索キーが使用されます。", "xpack.enterpriseSearch.appSearch.tokens.search.name": "公開検索キー", "xpack.enterpriseSearch.appSearch.tokens.update": "正常に API キーを更新しました。", + "xpack.enterpriseSearch.emailLabel": "メール", "xpack.enterpriseSearch.enterpriseSearch.setupGuide.description": "場所を問わず、何でも検索。組織を支える多忙なチームのために、パワフルでモダンな検索エクスペリエンスを簡単に導入できます。Webサイトやアプリ、ワークプレイスに事前調整済みの検索をすばやく追加しましょう。何でもシンプルに検索できます。", "xpack.enterpriseSearch.enterpriseSearch.setupGuide.notConfigured": "エンタープライズサーチはまだKibanaインスタンスで構成されていません。", "xpack.enterpriseSearch.enterpriseSearch.setupGuide.videoAlt": "エンタープライズ サーチの基本操作", @@ -7948,15 +7948,14 @@ "xpack.enterpriseSearch.roleMapping.attributeSelectorTitle": "属性マッピング", "xpack.enterpriseSearch.roleMapping.attributeValueLabel": "属性値", "xpack.enterpriseSearch.roleMapping.authProviderLabel": "認証プロバイダー", - "xpack.enterpriseSearch.roleMapping.deleteRoleMappingButton": "マッピングを削除", "xpack.enterpriseSearch.roleMapping.deleteRoleMappingDescription": "マッピングの削除は永久的であり、元に戻すことはできません", - "xpack.enterpriseSearch.roleMapping.deleteRoleMappingTitle": "このロールマッピングを削除", "xpack.enterpriseSearch.roleMapping.externalAttributeLabel": "外部属性", "xpack.enterpriseSearch.roleMapping.filterRoleMappingsPlaceholder": "ロールをフィルタリング...", "xpack.enterpriseSearch.roleMapping.individualAuthProviderLabel": "個別の認証プロバイダーを選択", "xpack.enterpriseSearch.roleMapping.manageRoleMappingTitle": "ロールマッピングを管理", "xpack.enterpriseSearch.roleMapping.noResults.message": "の結果が見つかりません。", "xpack.enterpriseSearch.roleMapping.newRoleMappingTitle": "ロールマッピングを追加", + "xpack.enterpriseSearch.roleMapping.removeRoleMappingTitle": "このロールマッピングを削除", "xpack.enterpriseSearch.roleMapping.roleLabel": "ロール", "xpack.enterpriseSearch.roleMapping.roleMappingsTitle": "ユーザーとロール", "xpack.enterpriseSearch.roleMapping.saveRoleMappingButtonLabel": "ロールマッピングの保存", @@ -7993,6 +7992,7 @@ "xpack.enterpriseSearch.troubleshooting.differentEsClusters.title": "{productName}とKibanaは別のElasticsearchクラスターにあります", "xpack.enterpriseSearch.troubleshooting.standardAuth.description": "このプラグインは、{standardAuthLink}の{productName}を完全にはサポートしていません。{productName}で作成されたユーザーはKibanaアクセス権が必要です。Kibanaで作成されたユーザーは、ナビゲーションメニューに{productName}が表示されません。", "xpack.enterpriseSearch.troubleshooting.standardAuth.title": "標準認証の{productName}はサポートされていません", + "xpack.enterpriseSearch.usernameLabel": "ユーザー名", "xpack.enterpriseSearch.workplaceSearch.accountNav.account.link": "マイアカウント", "xpack.enterpriseSearch.workplaceSearch.accountNav.logout.link": "ログアウト", "xpack.enterpriseSearch.workplaceSearch.accountNav.orgDashboard.link": "組織ダッシュボードに移動", @@ -8163,8 +8163,6 @@ "xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.groupTableHeader": "グループ", "xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.sourcesTableHeader": "コンテンツソース", "xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.usersTableHeader": "ユーザー", - "xpack.enterpriseSearch.workplaceSearch.groups.groupsUsersTable.emailTableHeader": "メール", - "xpack.enterpriseSearch.workplaceSearch.groups.groupsUsersTable.usernameTableHeader": "ユーザー名", "xpack.enterpriseSearch.workplaceSearch.groups.groupUpdatedText": "前回更新日時{updatedAt}。", "xpack.enterpriseSearch.workplaceSearch.groups.groupUsersUpdated": "このグループのユーザーが正常に更新されました。", "xpack.enterpriseSearch.workplaceSearch.groups.heading": "グループを管理", @@ -8264,7 +8262,6 @@ "xpack.enterpriseSearch.workplaceSearch.reset.button": "リセット", "xpack.enterpriseSearch.workplaceSearch.roleMapping.adminRoleTypeDescription": "管理者は、コンテンツソース、グループ、ユーザー管理機能など、すべての組織レベルの設定に無制限にアクセスできます。", "xpack.enterpriseSearch.workplaceSearch.roleMapping.defaultGroupName": "デフォルト", - "xpack.enterpriseSearch.workplaceSearch.roleMapping.deleteRoleMappingButtonMessage": "このマッピングを完全に削除しますか?このアクションは元に戻せません。一部のユーザーがアクセスを失う可能性があります。", "xpack.enterpriseSearch.workplaceSearch.roleMapping.groupAssignmentInvalidError": "1つ以上の割り当てられたグループが必要です。", "xpack.enterpriseSearch.workplaceSearch.roleMapping.roleMappingsTableHeader": "グループアクセス", "xpack.enterpriseSearch.workplaceSearch.roleMapping.userRoleTypeDescription": "ユーザーの機能アクセスは検索インターフェースと個人設定管理に制限されます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 6a96769e2da1eb..055ccbdde6ae87 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7580,7 +7580,6 @@ "xpack.enterpriseSearch.appSearch.credentials.title": "凭据", "xpack.enterpriseSearch.appSearch.credentials.updateWarning": "现有 API 密钥可在用户之间共享。更改此密钥的权限将影响有权访问此密钥的所有用户。", "xpack.enterpriseSearch.appSearch.credentials.updateWarningTitle": "谨慎操作!", - "xpack.enterpriseSearch.appSearch.deleteRoleMappingMessage": "确定要永久删除此映射?此操作不可逆转,且某些用户可能会失去访问权限。", "xpack.enterpriseSearch.appSearch.DEV_ROLE_TYPE_DESCRIPTION": "开发人员可以管理引擎的所有方面。", "xpack.enterpriseSearch.appSearch.documentCreation.api.description": "{documentsApiLink} 可用于将新文档添加到您的引擎、更新文档、按 ID 检索文档以及删除文档。有各种{clientLibrariesLink}可帮助您入门。", "xpack.enterpriseSearch.appSearch.documentCreation.api.example": "要了解如何使用 API,可以在下面通过命令行或客户端库试用示例请求。", @@ -7974,6 +7973,7 @@ "xpack.enterpriseSearch.appSearch.tokens.search.description": "公有搜索密钥仅用于搜索终端。", "xpack.enterpriseSearch.appSearch.tokens.search.name": "公有搜索密钥", "xpack.enterpriseSearch.appSearch.tokens.update": "成功更新 API 密钥。", + "xpack.enterpriseSearch.emailLabel": "电子邮件", "xpack.enterpriseSearch.enterpriseSearch.setupGuide.description": "随时随地进行全面搜索。为工作繁忙的团队轻松实现强大的现代搜索体验。将预先调整的搜索功能快速添加到您的网站、应用或工作区。全面搜索就是这么简单。", "xpack.enterpriseSearch.enterpriseSearch.setupGuide.notConfigured": "企业搜索尚未在您的 Kibana 实例中配置。", "xpack.enterpriseSearch.enterpriseSearch.setupGuide.videoAlt": "企业搜索入门", @@ -8016,9 +8016,7 @@ "xpack.enterpriseSearch.roleMapping.attributeSelectorTitle": "属性映射", "xpack.enterpriseSearch.roleMapping.attributeValueLabel": "属性值", "xpack.enterpriseSearch.roleMapping.authProviderLabel": "身份验证提供程序", - "xpack.enterpriseSearch.roleMapping.deleteRoleMappingButton": "删除映射", "xpack.enterpriseSearch.roleMapping.deleteRoleMappingDescription": "请注意,删除映射是永久性的,无法撤消", - "xpack.enterpriseSearch.roleMapping.deleteRoleMappingTitle": "移除此角色映射", "xpack.enterpriseSearch.roleMapping.externalAttributeLabel": "外部属性", "xpack.enterpriseSearch.roleMapping.filterRoleMappingsPlaceholder": "筛选角色......", "xpack.enterpriseSearch.roleMapping.individualAuthProviderLabel": "选择单个身份验证提供程序", @@ -8027,6 +8025,7 @@ "xpack.enterpriseSearch.roleMapping.newRoleMappingTitle": "添加角色映射", "xpack.enterpriseSearch.roleMapping.roleLabel": "角色", "xpack.enterpriseSearch.roleMapping.roleMappingsTitle": "用户和角色", + "xpack.enterpriseSearch.roleMapping.removeRoleMappingTitle": "移除此角色映射", "xpack.enterpriseSearch.roleMapping.saveRoleMappingButtonLabel": "保存角色映射", "xpack.enterpriseSearch.roleMapping.updateRoleMappingButtonLabel": "更新角色映射", "xpack.enterpriseSearch.schema.addFieldModal.fieldNameNote.correct": "字段名称只能包含小写字母、数字和下划线", @@ -8061,6 +8060,7 @@ "xpack.enterpriseSearch.troubleshooting.differentEsClusters.title": "{productName} 和 Kibana 在不同的 Elasticsearch 集群中", "xpack.enterpriseSearch.troubleshooting.standardAuth.description": "此插件不完全支持使用 {standardAuthLink} 的 {productName}。{productName} 中创建的用户必须具有 Kibana 访问权限。Kibana 中创建的用户在导航菜单中将看不到 {productName}。", "xpack.enterpriseSearch.troubleshooting.standardAuth.title": "不支持使用标准身份验证的 {productName}", + "xpack.enterpriseSearch.usernameLabel": "用户名", "xpack.enterpriseSearch.workplaceSearch.accountNav.account.link": "我的帐户", "xpack.enterpriseSearch.workplaceSearch.accountNav.logout.link": "注销", "xpack.enterpriseSearch.workplaceSearch.accountNav.orgDashboard.link": "前往组织仪表板", @@ -8231,8 +8231,6 @@ "xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.groupTableHeader": "组", "xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.sourcesTableHeader": "内容源", "xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.usersTableHeader": "用户", - "xpack.enterpriseSearch.workplaceSearch.groups.groupsUsersTable.emailTableHeader": "电子邮件", - "xpack.enterpriseSearch.workplaceSearch.groups.groupsUsersTable.usernameTableHeader": "用户名", "xpack.enterpriseSearch.workplaceSearch.groups.groupUpdatedText": "上次更新于 {updatedAt}。", "xpack.enterpriseSearch.workplaceSearch.groups.groupUsersUpdated": "已成功更新此组的用户", "xpack.enterpriseSearch.workplaceSearch.groups.heading": "管理组", @@ -8332,7 +8330,6 @@ "xpack.enterpriseSearch.workplaceSearch.reset.button": "重置", "xpack.enterpriseSearch.workplaceSearch.roleMapping.adminRoleTypeDescription": "管理员对所有组织范围设置 (包括内容源、组和用户管理功能) 具有完全权限。", "xpack.enterpriseSearch.workplaceSearch.roleMapping.defaultGroupName": "默认", - "xpack.enterpriseSearch.workplaceSearch.roleMapping.deleteRoleMappingButtonMessage": "确定要永久删除此映射?此操作不可逆转,且某些用户可能会失去访问权限。", "xpack.enterpriseSearch.workplaceSearch.roleMapping.groupAssignmentInvalidError": "至少需要一个分配的组。", "xpack.enterpriseSearch.workplaceSearch.roleMapping.roleMappingsTableHeader": "组访问权限", "xpack.enterpriseSearch.workplaceSearch.roleMapping.userRoleTypeDescription": "用户的功能访问权限仅限于搜索界面和个人设置管理。", From 136d3617032526dcb396896da408791c1362cb39 Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Wed, 23 Jun 2021 15:10:34 -0500 Subject: [PATCH 51/61] Upgrade EUI to v34.3.0 (#101334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * eui to v34.1.0 * styled-components types * src snapshot updates * x-pack snapshot updates * eui to v34.2.0 * styled-components todo * src snapshot updates * x-pack snapshot updates * jest test updates * collapsible_nav * Hard-code global nav width for bottom bar’s (for now) * Update to eui v34.3.0 * flyout unmock * src flyout snapshots * remove duplicate euioverlaymask * xpack flyout snapshots * remove unused import * sidenavprops * attr updates * trial: flyout ownfocus * remove unused * graph selector * jest * jest * flyout ownFocus * saved objects flyout * console welcome flyout * timeline flyout * clean up * visible * colorpicker data-test-subj * selectors * selector * ts * selector * snapshot * Fix `use_security_solution_navigation` TS error * cypress Co-authored-by: cchaos Co-authored-by: Chandler Prall --- package.json | 2 +- .../collapsible_nav.test.tsx.snap | 3451 ++++++++--------- .../header/__snapshots__/header.test.tsx.snap | 989 +++-- .../chrome/ui/header/collapsible_nav.test.tsx | 4 - .../public/chrome/ui/header/header.test.tsx | 2 +- src/core/public/chrome/ui/header/header.tsx | 2 +- .../flyout_service.test.tsx.snap | 4 +- src/core/public/styles/_base.scss | 2 +- .../application/components/welcome_panel.tsx | 2 +- .../dashboard_empty_screen.test.tsx.snap | 8 +- .../__snapshots__/data_view.test.tsx.snap | 30 +- .../discover_grid_flyout.test.tsx | 4 +- .../__snapshots__/source_viewer.test.tsx.snap | 22 +- .../url/__snapshots__/url.test.tsx.snap | 8 +- .../header/__snapshots__/header.test.tsx.snap | 2 +- .../warning_call_out.test.tsx.snap | 98 +- .../inspector_panel.test.tsx.snap | 1 + .../__snapshots__/solution_nav.test.tsx.snap | 18 + .../solution_nav/solution_nav.tsx | 2 +- .../public/components/labs/labs_flyout.tsx | 51 +- .../__snapshots__/intro.test.tsx.snap | 26 +- .../not_found_errors.test.tsx.snap | 160 +- .../__snapshots__/flyout.test.tsx.snap | 3 + .../objects_table/components/flyout.tsx | 2 +- .../components/color_picker.test.tsx | 4 +- .../visualization_noresults.test.js.snap | 2 +- test/accessibility/apps/management.ts | 1 + .../apps/management/_import_objects.ts | 8 +- test/functional/page_objects/settings_page.ts | 4 + .../page_objects/visual_builder_page.ts | 2 +- .../Waterfall/ResponsiveFlyout.tsx | 15 +- .../asset_manager.stories.storyshot | 17 +- .../custom_element_modal.stories.storyshot | 32 +- .../datasource_component.stories.storyshot | 10 +- .../keyboard_shortcuts_doc.stories.storyshot | 2145 +++++----- .../saved_elements_modal.stories.storyshot | 12 +- .../__snapshots__/pdf_panel.stories.storyshot | 4 +- .../__snapshots__/settings.test.tsx.snap | 10 +- .../autoplay_settings.stories.storyshot | 12 +- .../toolbar_settings.stories.storyshot | 12 +- .../filebeat_config_flyout.tsx | 2 +- .../private_sources_sidebar.tsx | 1 - .../components/create_agent_policy.tsx | 11 +- .../extend_index_management.test.tsx.snap | 142 +- .../__snapshots__/policy_table.test.tsx.snap | 15 +- .../components/table_basic.test.tsx | 22 +- .../upload_license.test.tsx.snap | 96 +- .../action_edit/edit_action_flyout.tsx | 327 +- .../__snapshots__/checker_errors.test.js.snap | 54 +- .../__snapshots__/no_data.test.js.snap | 8 +- .../__snapshots__/page_loading.test.js.snap | 4 +- .../app/cases/create/flyout.test.tsx | 2 +- .../components/app/cases/create/flyout.tsx | 10 +- .../shared/page_template/page_template.tsx | 10 +- ...screen_capture_panel_content.test.tsx.snap | 18 +- .../report_info_button.test.tsx.snap | 356 +- .../privilege_summary/privilege_summary.tsx | 61 +- .../privilege_space_form.tsx | 116 +- .../roles_grid_page.test.tsx.snap | 34 +- .../__snapshots__/prompt_page.test.tsx.snap | 4 +- .../unauthenticated_page.test.tsx.snap | 2 +- .../reset_session_page.test.tsx.snap | 2 +- .../timelines/data_providers.spec.ts | 4 +- .../integration/timelines/pagination.spec.ts | 6 +- .../cypress/screens/timeline.ts | 8 +- .../cases/components/create/flyout.test.tsx | 2 +- .../public/cases/components/create/flyout.tsx | 10 +- .../exceptions/add_exception_comments.tsx | 2 +- .../index.test.tsx | 4 +- .../endpoint_hosts/view/details/index.tsx | 1 + .../__snapshots__/index.test.tsx.snap | 11 +- .../timelines/components/flyout/index.tsx | 11 +- .../components/flyout/pane/index.tsx | 13 +- .../__snapshots__/index.test.tsx.snap | 827 ++-- .../timelines/components/side_panel/index.tsx | 10 +- .../edit_transform_flyout.tsx | 127 +- .../sections/alert_form/alert_add.tsx | 1 + .../__snapshots__/license_info.test.tsx.snap | 30 +- .../ml/__snapshots__/ml_flyout.test.tsx.snap | 205 +- .../__snapshots__/expanded_row.test.tsx.snap | 82 +- .../waterfall/waterfall_flyout.tsx | 10 +- .../test/functional/apps/lens/lens_tagging.ts | 2 +- .../functional/page_objects/graph_page.ts | 4 +- .../test/functional/page_objects/lens_page.ts | 4 +- .../page_objects/space_selector_page.ts | 4 +- .../page_objects/tag_management_page.ts | 5 +- .../functional/tests/dashboard_integration.ts | 2 +- .../functional/tests/maps_integration.ts | 2 +- .../functional/tests/visualize_integration.ts | 2 +- yarn.lock | 25 +- 90 files changed, 4774 insertions(+), 5120 deletions(-) diff --git a/package.json b/package.json index 26465133569cd9..f99eb86a43cecb 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.13", "@elastic/ems-client": "7.14.0", - "@elastic/eui": "33.0.0", + "@elastic/eui": "34.3.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", "@elastic/maki": "6.3.0", diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 3668829a6888cd..0b10209bc13e59 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -370,54 +370,62 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` isOpen={true} onClose={[Function]} > - - - - } - /> - - - + + +
+
+ +
-
-
-
- - - -
+ data-euiicon-type="home" + /> + + + Home + + + + + +
-
-
- - + +
+
+ + + + +

+ Recently viewed +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-recentlyViewed" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="recentlyViewed" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Recently viewed" + paddingSize="none" > - - - -

- Recently viewed -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" +
-
- -
-
+ +
+ +
+ + + +
+
+ - -
+
+
-
- - - -
+ recent 2 + + + + + +
- -
+
+
-
-
- -
-
- + + + +
+
+ +
-
- + + + + + +

+ Analytics +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-kibana" - iconType="logoKibana" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="kibana" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Analytics" + paddingSize="none" > - - - - - - -

- Analytics -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" +
-
- -
-
+ +
+ +
+ + + +
+
+ - -
+
+
-
- - - -
+ dashboard + + + + + +
- -
+
+
-
-
- + + + + + + + + + +

+ Observability +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-observability" - iconType="logoObservability" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="observability" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Observability" + paddingSize="none" > - - - - - - -

- Observability -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" +
-
- -
-
+ +
+ +
+ + + +
+
+ - -
+
+
-
- - - -
+ logs + + + + + +
- -
+
+
-
-
- + + + + + + + + + +

+ Security +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-securitySolution" - iconType="logoSecurity" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="securitySolution" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Security" + paddingSize="none" > - - - - - - -

- Security -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" +
-
- -
-
+ +
+ +
+ + + +
+
+ - -
+
+
-
- - - -
+ siem + + + + + +
- -
+
+
-
-
- + + + + + + + + + +

+ Management +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-management" - iconType="managementApp" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="management" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Management" + paddingSize="none" > - - - - - - -

- Management -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" +
-
- -
-
+ +
+ +
+ + + +
+
+ - -
+
+
-
- - - -
+ monitoring + + + + + +
- -
+
+
-
-
- + + + +
-
- - - -
+ canvas + + + + + +
- - - +
+
+ + +
-
- -
    - - - - Dock navigation - - , - } - } - color="subdued" - data-test-subj="collapsible-nav-lock" - iconType="lockOpen" - label="Dock navigation" - onClick={[Function]} - size="xs" - > -
  • - -
  • -
    -
-
-
+ , + } + } + color="subdued" + data-test-subj="collapsible-nav-lock" + iconType="lockOpen" + label="Dock navigation" + onClick={[Function]} + size="xs" + > +
  • + +
  • + + +
    - - -
    - - - - - - - -
    - +
    + + +
    + + + `; @@ -2770,42 +2706,57 @@ exports[`CollapsibleNav renders the default nav 3`] = ` isOpen={false} onClose={[Function]} > - - -
    -
    + + + + + +

    + Recently viewed +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-recentlyViewed" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="recentlyViewed" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Recently viewed" + paddingSize="none" > - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" +
    -
    - -
    -
    + +
    + +
    + + + +
    +
    + - -
    +
    +
    -
    - -
    - -
    -

    - No recently viewed items -

    -
    -
    -
    -
    -
    +

    + No recently viewed items +

    +
    + +
    +
    -
    -
    +
    +
    - - - -
    -
    - + + + +
    +
    + +
    -
    - - + +
    -
    - -
      - - - - Undock navigation - - , - } - } - color="subdued" - data-test-subj="collapsible-nav-lock" - iconType="lock" - label="Undock navigation" - onClick={[Function]} - size="xs" - > -
    • - -
    • -
      -
    -
    -
    + , + } + } + color="subdued" + data-test-subj="collapsible-nav-lock" + iconType="lock" + label="Undock navigation" + onClick={[Function]} + size="xs" + > +
  • + +
  • + + +
    - - -
    - - - - - - - -
    - +
    + + +
    + + + `; diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 6ad1e2d3a1cc62..5aee9ca1b7c08d 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -4947,42 +4947,57 @@ exports[`Header renders 1`] = ` isOpen={false} onClose={[Function]} > - - -
    -
    + + +
    +
    + +
    -
    -
    -
    - - - -
    + data-euiicon-type="home" + /> + + + Home + + + + + +
    -
    -
    - - + +
    +
    + + + + +

    + Recently viewed +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-recentlyViewed" + id="mockId" initialIsOpen={true} - isCollapsible={true} - key="recentlyViewed" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Recently viewed" + paddingSize="none" > - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" +
    -
    - -
    -
    + +
    + +
    + + + +
    +
    + - -
    +
    +
    -
    - - - -
    + dashboard + + + + + +
    - -
    +
    +
    -
    -
    - -
    -
    - + + + +
    +
    + +
    -
    - +
    + +
      + +
    • + +
    • +
      +
    +
    +
    +
    + + +
    + + + Undock navigation + + , + } + } + color="subdued" + data-test-subj="collapsible-nav-lock" + iconType="lock" + label="Undock navigation" onClick={[Function]} - size="s" + size="xs" >
  • @@ -5445,163 +5540,11 @@ exports[`Header renders 1`] = `
    - - -
    -
    - -
      - - - - Undock navigation - - , - } - } - color="subdued" - data-test-subj="collapsible-nav-lock" - iconType="lock" - label="Undock navigation" - onClick={[Function]} - size="xs" - > -
    • - -
    • -
      -
    -
    -
    -
    -
    -
    -
    -
    - - - - - - -
    - + +
    + + + diff --git a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx index 7f338a859e7b42..460770744d53a3 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx @@ -16,10 +16,6 @@ import { httpServiceMock } from '../../../http/http_service.mock'; import { ChromeRecentlyAccessedHistoryItem } from '../../recently_accessed'; import { CollapsibleNav } from './collapsible_nav'; -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ - htmlIdGenerator: () => () => 'mockId', -})); - const { kibana, observability, security, management } = DEFAULT_APP_CATEGORIES; function mockLink({ title = 'discover', category }: Partial) { diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index fdbdde8556eebf..a3a0197b4017e0 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -99,7 +99,7 @@ describe('Header', () => { act(() => isLocked$.next(true)); component.update(); - expect(component.find('nav[aria-label="Primary"]').exists()).toBeTruthy(); + expect(component.find('[data-test-subj="collapsibleNav"]').exists()).toBeTruthy(); expect(component).toMatchSnapshot(); act(() => diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 67cdd24aae8487..246ca83ef5adeb 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -87,6 +87,7 @@ export function Header({ const isVisible = useObservable(observables.isVisible$, false); const isLocked = useObservable(observables.isLocked$, false); const [isNavOpen, setIsNavOpen] = useState(false); + const [navId] = useState(htmlIdGenerator()()); const breadcrumbsAppendExtension = useObservable(breadcrumbsAppendExtension$); if (!isVisible) { @@ -99,7 +100,6 @@ export function Header({ } const toggleCollapsibleNavRef = createRef void }>(); - const navId = htmlIdGenerator()(); const className = classnames('hide-for-sharing', 'headerGlobalNav'); const Breadcrumbs = ( diff --git a/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap b/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap index f5a1c51ccbe158..fbd09f30968542 100644 --- a/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap +++ b/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap @@ -26,7 +26,7 @@ Array [ ] `; -exports[`FlyoutService openFlyout() renders a flyout to the DOM 2`] = `"
    Flyout content
    "`; +exports[`FlyoutService openFlyout() renders a flyout to the DOM 2`] = `"
    Flyout content
    "`; exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 1`] = ` Array [ @@ -59,4 +59,4 @@ Array [ ] `; -exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 2`] = `"
    Flyout content 2
    "`; +exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 2`] = `"
    Flyout content 2
    "`; diff --git a/src/core/public/styles/_base.scss b/src/core/public/styles/_base.scss index 3386fa73f328aa..de138cdf402e6e 100644 --- a/src/core/public/styles/_base.scss +++ b/src/core/public/styles/_base.scss @@ -26,7 +26,7 @@ } .euiBody--collapsibleNavIsDocked .euiBottomBar { - margin-left: $euiCollapsibleNavWidth; + margin-left: 320px; // Hard-coded for now -- @cchaos } // Temporary fix for EuiPageHeader with a bottom border but no tabs or padding diff --git a/src/plugins/console/public/application/components/welcome_panel.tsx b/src/plugins/console/public/application/components/welcome_panel.tsx index eb746e313d228a..8514d41c04a51c 100644 --- a/src/plugins/console/public/application/components/welcome_panel.tsx +++ b/src/plugins/console/public/application/components/welcome_panel.tsx @@ -27,7 +27,7 @@ interface Props { export function WelcomePanel(props: Props) { return ( - +

    diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index 9f56740fdac221..afe339f3f43a23 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -603,7 +603,7 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` } > -
    -
    +

    @@ -950,7 +950,7 @@ exports[`DashboardEmptyScreen renders correctly with view mode 1`] = ` } > -
    -
    +
    diff --git a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap index a0a7e54d275322..0ab3f8a4e34668 100644 --- a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap +++ b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap @@ -176,27 +176,27 @@ exports[`Inspector Data View component should render empty state 1`] = `
    + +

    + + No data available + +

    +
    - -

    - - No data available - -

    -
    diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx index 60841799b1398b..50be2473a441e2 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx @@ -144,7 +144,9 @@ describe('Discover flyout', function () { expect(props.setExpandedDoc.mock.calls[0][0]._id).toBe('4'); }); - it('allows navigating with arrow keys through documents', () => { + // EuiFlyout is mocked in Jest environments. + // EUI team to reinstate `onKeyDown`: https://github.com/elastic/eui/issues/4883 + it.skip('allows navigating with arrow keys through documents', () => { const props = getProps(); const component = mountWithIntl(); findTestSubject(component, 'docTableDetailsFlyout').simulate('keydown', { key: 'ArrowRight' }); diff --git a/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap b/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap index f40dbbbae1f877..68786871825ac3 100644 --- a/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap +++ b/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap @@ -147,27 +147,27 @@ exports[`Source Viewer component renders error state 1`] = ` />
    + +

    + An Error Occurred +

    +
    - -

    - An Error Occurred -

    -
    diff --git a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/url/__snapshots__/url.test.tsx.snap b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/url/__snapshots__/url.test.tsx.snap index 40170c39942e52..79c1a11cfef84a 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/url/__snapshots__/url.test.tsx.snap +++ b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/url/__snapshots__/url.test.tsx.snap @@ -153,7 +153,7 @@ exports[`UrlFormatEditor should render normally 1`] = ` class="euiFormControlLayout__childrenWrapper" > diff --git a/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap b/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap index 5ad82053651469..67d2cf72c53756 100644 --- a/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap +++ b/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap @@ -329,6 +329,7 @@ exports[`InspectorPanel should render as expected 1`] = ` >
    & { +export type KibanaPageTemplateSolutionNavProps = Partial> & { /** * Name of the solution, i.e. "Observability" */ diff --git a/src/plugins/presentation_util/public/components/labs/labs_flyout.tsx b/src/plugins/presentation_util/public/components/labs/labs_flyout.tsx index 5b424c7e95f18b..1af85da9830851 100644 --- a/src/plugins/presentation_util/public/components/labs/labs_flyout.tsx +++ b/src/plugins/presentation_util/public/components/labs/labs_flyout.tsx @@ -20,7 +20,6 @@ import { EuiFlexItem, EuiFlexGroup, EuiIcon, - EuiOverlayMask, } from '@elastic/eui'; import { SolutionName, ProjectStatus, ProjectID, Project, EnvironmentName } from '../../../common'; @@ -124,30 +123,32 @@ export const LabsFlyout = (props: Props) => { ); return ( - onClose()} headerZindexLocation="below"> - - - -

    - - - - - {strings.getTitleLabel()} - -

    -
    - - -

    {strings.getDescriptionMessage()}

    -
    -
    - - - - {footer} -
    -
    + + + +

    + + + + + {strings.getTitleLabel()} + +

    +
    + + +

    {strings.getDescriptionMessage()}

    +
    +
    + + + + {footer} +
    ); }; diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/intro.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/intro.test.tsx.snap index 5239a925435399..5a8cd06b8ecc07 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/intro.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/intro.test.tsx.snap @@ -47,20 +47,30 @@ exports[`Intro component renders correctly 1`] = `
    -
    - +
    - Modifying objects is for advanced users only. Object properties are not validated and invalid objects could cause errors, data loss, or worse. Unless someone with intimate knowledge of the code told you to be in here, you probably shouldn’t be. - -
    +
    + + Modifying objects is for advanced users only. Object properties are not validated and invalid objects could cause errors, data loss, or worse. Unless someone with intimate knowledge of the code told you to be in here, you probably shouldn’t be. + +
    +
    +
    diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap index bddfe000008d42..f977c17df41d31 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap @@ -49,29 +49,39 @@ exports[`NotFoundErrors component renders correctly for index-pattern type 1`] =
    -
    - - The index pattern associated with this object no longer exists. - -
    -
    - +
    - If you know what this error means, go ahead and fix it — otherwise click the delete button above. - -
    +
    + + The index pattern associated with this object no longer exists. + +
    +
    + + If you know what this error means, go ahead and fix it — otherwise click the delete button above. + +
    +
    +
    @@ -128,29 +138,39 @@ exports[`NotFoundErrors component renders correctly for index-pattern-field type
    -
    - - A field associated with this object no longer exists in the index pattern. - -
    -
    - +
    - If you know what this error means, go ahead and fix it — otherwise click the delete button above. - -
    +
    + + A field associated with this object no longer exists in the index pattern. + +
    +
    + + If you know what this error means, go ahead and fix it — otherwise click the delete button above. + +
    +
    +
    @@ -207,29 +227,39 @@ exports[`NotFoundErrors component renders correctly for search type 1`] = `
    -
    - - The saved search associated with this object no longer exists. - -
    -
    - +
    - If you know what this error means, go ahead and fix it — otherwise click the delete button above. - -
    +
    + + The saved search associated with this object no longer exists. + +
    +
    + + If you know what this error means, go ahead and fix it — otherwise click the delete button above. + +
    +
    +
    @@ -286,21 +316,31 @@ exports[`NotFoundErrors component renders correctly for unknown type 1`] = `
    -
    -
    - +
    - If you know what this error means, go ahead and fix it — otherwise click the delete button above. - -
    +
    +
    + + If you know what this error means, go ahead and fix it — otherwise click the delete button above. + +
    +
    +
    diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap index a68e8891b5ad19..bd97f2e6bffb13 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap @@ -2,6 +2,7 @@ exports[`Flyout conflicts should allow conflict resolution 1`] = ` @@ -277,6 +278,7 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` exports[`Flyout legacy conflicts should allow conflict resolution 1`] = ` @@ -548,6 +550,7 @@ Array [ exports[`Flyout should render import step 1`] = ` diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx index 62e0cd0504e8e2..f6c8d5fb694087 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx @@ -960,7 +960,7 @@ export class Flyout extends Component { } return ( - +

    diff --git a/src/plugins/vis_type_timeseries/public/application/components/color_picker.test.tsx b/src/plugins/vis_type_timeseries/public/application/components/color_picker.test.tsx index 8e975f99042562..50d3e8c38e389f 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/color_picker.test.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/color_picker.test.tsx @@ -36,7 +36,7 @@ describe('ColorPicker', () => { const props = { ...defaultProps, value: '#68BC00' }; component = mount(); component.find('.tvbColorPicker button').simulate('click'); - const input = findTestSubject(component, 'topColorPickerInput'); + const input = findTestSubject(component, 'euiColorPickerInput_top'); expect(input.props().value).toBe('#68BC00'); }); @@ -44,7 +44,7 @@ describe('ColorPicker', () => { const props = { ...defaultProps, value: 'rgba(85,66,177,1)' }; component = mount(); component.find('.tvbColorPicker button').simulate('click'); - const input = findTestSubject(component, 'topColorPickerInput'); + const input = findTestSubject(component, 'euiColorPickerInput_top'); expect(input.props().value).toBe('85,66,177,1'); }); diff --git a/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap b/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap index 25ec05c83a8c6a..56e2cb1b60f3c7 100644 --- a/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap +++ b/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap @@ -14,7 +14,7 @@ exports[`VisualizationNoResults should render according to snapshot 1`] = ` data-euiicon-type="visualizeApp" />
    { await PageObjects.settings.clickEditFieldFormat(); await a11y.testAppSnapshot(); + await PageObjects.settings.clickCloseEditFieldFormatFlyout(); }); it('Advanced settings', async () => { diff --git a/test/functional/apps/management/_import_objects.ts b/test/functional/apps/management/_import_objects.ts index 0278955c577a10..6ef0bfd5a09e8a 100644 --- a/test/functional/apps/management/_import_objects.ts +++ b/test/functional/apps/management/_import_objects.ts @@ -419,14 +419,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'index-pattern-test-1' ); - await testSubjects.click('pagination-button-next'); + const flyout = await testSubjects.find('importSavedObjectsFlyout'); + + await (await flyout.findByTestSubject('pagination-button-next')).click(); await PageObjects.savedObjects.setOverriddenIndexPatternValue( 'missing-index-pattern-7', 'index-pattern-test-2' ); - await testSubjects.click('pagination-button-previous'); + await (await flyout.findByTestSubject('pagination-button-previous')).click(); const selectedIdForMissingIndexPattern1 = await testSubjects.getAttribute( 'managementChangeIndexSelection-missing-index-pattern-1', @@ -435,7 +437,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(selectedIdForMissingIndexPattern1).to.eql('f1e4c910-a2e6-11e7-bb30-233be9be6a20'); - await testSubjects.click('pagination-button-next'); + await (await flyout.findByTestSubject('pagination-button-next')).click(); const selectedIdForMissingIndexPattern7 = await testSubjects.getAttribute( 'managementChangeIndexSelection-missing-index-pattern-7', diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index 88951bb04c956a..cb8f1981770174 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -739,6 +739,10 @@ export class SettingsPageObject extends FtrService { await this.testSubjects.click('editFieldFormat'); } + async clickCloseEditFieldFormatFlyout() { + await this.testSubjects.click('euiFlyoutCloseButton'); + } + async associateIndexPattern(oldIndexPatternId: string, newIndexPatternTitle: string) { await this.find.clickByCssSelector( `select[data-test-subj="managementChangeIndexSelection-${oldIndexPatternId}"] > diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index 6e263dd1cdbbf5..7f1ea64bcd9792 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -563,7 +563,7 @@ export class VisualBuilderPageObject extends FtrService { public async checkColorPickerPopUpIsPresent(): Promise { this.log.debug(`Check color picker popup is present`); - await this.testSubjects.existOrFail('colorPickerPopover', { timeout: 5000 }); + await this.testSubjects.existOrFail('euiColorPickerPopover', { timeout: 5000 }); } public async changePanelPreview(nth: number = 0): Promise { diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/ResponsiveFlyout.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/ResponsiveFlyout.tsx index 8549f09bba2482..09fbf07b8ecbd7 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/ResponsiveFlyout.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/ResponsiveFlyout.tsx @@ -5,10 +5,21 @@ * 2.0. */ +import { ReactNode } from 'react'; +import { StyledComponent } from 'styled-components'; import { EuiFlyout } from '@elastic/eui'; -import { euiStyled } from '../../../../../../../../../../src/plugins/kibana_react/common'; +import { + euiStyled, + EuiTheme, +} from '../../../../../../../../../../src/plugins/kibana_react/common'; -export const ResponsiveFlyout = euiStyled(EuiFlyout)` +// TODO: EUI team follow up on complex types and styled-components `styled` +// https://github.com/elastic/eui/issues/4855 +export const ResponsiveFlyout: StyledComponent< + typeof EuiFlyout, + EuiTheme, + { children?: ReactNode } +> = euiStyled(EuiFlyout)` width: 100%; @media (min-width: 800px) { diff --git a/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset_manager.stories.storyshot b/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset_manager.stories.storyshot index 34b6b333f3ef50..d567d3cf85f13b 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset_manager.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset_manager.stories.storyshot @@ -116,20 +116,13 @@ exports[`Storyshots components/Assets/AssetManager no assets 1`] = ` size="xxl" />
    - -

    - Import your assets to get started -

    -
    - + Import your assets to get started +

    diff --git a/x-pack/plugins/canvas/public/components/custom_element_modal/__stories__/__snapshots__/custom_element_modal.stories.storyshot b/x-pack/plugins/canvas/public/components/custom_element_modal/__stories__/__snapshots__/custom_element_modal.stories.storyshot index 18f86aca243027..dc66eef8090508 100644 --- a/x-pack/plugins/canvas/public/components/custom_element_modal/__stories__/__snapshots__/custom_element_modal.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/custom_element_modal/__stories__/__snapshots__/custom_element_modal.stories.storyshot @@ -80,7 +80,7 @@ exports[`Storyshots components/Elements/CustomElementModal with description 1`] className="euiFormControlLayout__childrenWrapper" >
    40 characters remaining
    @@ -119,7 +119,7 @@ exports[`Storyshots components/Elements/CustomElementModal with description 1`] className="euiFormRow__fieldWrapper" >