diff --git a/.buildkite/pipelines/bazel_cache.yml b/.buildkite/pipelines/bazel_cache.yml index daf56eb712a8d..9aa961bcddbd2 100644 --- a/.buildkite/pipelines/bazel_cache.yml +++ b/.buildkite/pipelines/bazel_cache.yml @@ -1,5 +1,7 @@ steps: - label: ':pipeline: Create pipeline with priority' + agents: + queue: kibana-default concurrency_group: bazel_macos concurrency: 1 concurrency_method: eager diff --git a/.buildkite/pipelines/es_snapshots/promote.yml b/.buildkite/pipelines/es_snapshots/promote.yml index 5a003321246a1..f2f7b423c94c2 100644 --- a/.buildkite/pipelines/es_snapshots/promote.yml +++ b/.buildkite/pipelines/es_snapshots/promote.yml @@ -10,3 +10,5 @@ steps: required: true - label: Promote Snapshot command: .buildkite/scripts/steps/es_snapshots/promote.sh + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/es_snapshots/verify.yml b/.buildkite/pipelines/es_snapshots/verify.yml index f98626ef25c01..58908d1578bb5 100755 --- a/.buildkite/pipelines/es_snapshots/verify.yml +++ b/.buildkite/pipelines/es_snapshots/verify.yml @@ -14,6 +14,8 @@ steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build timeout_in_minutes: 10 + agents: + queue: kibana-default - wait @@ -85,6 +87,8 @@ steps: - command: .buildkite/scripts/steps/es_snapshots/trigger_promote.sh label: Trigger promotion timeout_in_minutes: 10 + agents: + queue: kibana-default depends_on: - default-cigroup - default-cigroup-docker @@ -98,3 +102,5 @@ steps: - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build timeout_in_minutes: 10 + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/flaky_tests/pipeline.js b/.buildkite/pipelines/flaky_tests/pipeline.js index cb5c37bf58348..b7f93412edb37 100644 --- a/.buildkite/pipelines/flaky_tests/pipeline.js +++ b/.buildkite/pipelines/flaky_tests/pipeline.js @@ -51,6 +51,9 @@ const pipeline = { { command: '.buildkite/pipelines/flaky_tests/runner.sh', label: 'Create pipeline', + agents: { + queue: 'kibana-default', + }, }, ], }; diff --git a/.buildkite/pipelines/hourly.yml b/.buildkite/pipelines/hourly.yml index 6953c146050eb..78c57ff3bd128 100644 --- a/.buildkite/pipelines/hourly.yml +++ b/.buildkite/pipelines/hourly.yml @@ -2,6 +2,8 @@ steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build timeout_in_minutes: 10 + agents: + queue: kibana-default - wait @@ -174,3 +176,5 @@ steps: - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build timeout_in_minutes: 10 + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/on_merge.yml b/.buildkite/pipelines/on_merge.yml index e5f6dcc2d1d5f..c6acb48b3e212 100644 --- a/.buildkite/pipelines/on_merge.yml +++ b/.buildkite/pipelines/on_merge.yml @@ -5,6 +5,8 @@ steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build timeout_in_minutes: 10 + agents: + queue: kibana-default - wait @@ -34,3 +36,5 @@ steps: - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build timeout_in_minutes: 10 + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/performance/daily.yml b/.buildkite/pipelines/performance/daily.yml index 208456f9c67a5..564bfb5e501b3 100644 --- a/.buildkite/pipelines/performance/daily.yml +++ b/.buildkite/pipelines/performance/daily.yml @@ -1,25 +1,27 @@ steps: - - block: ":gear: Performance Tests Configuration" - prompt: "Fill out the details for performance test" + - block: ':gear: Performance Tests Configuration' + prompt: 'Fill out the details for performance test' fields: - - text: ":arrows_counterclockwise: Iterations" - key: "performance-test-iteration-count" - hint: "How many times you want to run tests? " + - text: ':arrows_counterclockwise: Iterations' + key: 'performance-test-iteration-count' + hint: 'How many times you want to run tests? ' required: true if: build.env('PERF_TEST_COUNT') == null - - label: ":male-mechanic::skin-tone-2: Pre-Build" + - label: ':male-mechanic::skin-tone-2: Pre-Build' command: .buildkite/scripts/lifecycle/pre_build.sh + agents: + queue: kibana-default - wait - - label: ":factory_worker: Build Kibana Distribution and Plugins" + - label: ':factory_worker: Build Kibana Distribution and Plugins' command: .buildkite/scripts/steps/build_kibana.sh agents: queue: c2-16 key: build - - label: ":muscle: Performance Tests with Playwright config" + - label: ':muscle: Performance Tests with Playwright config' command: .buildkite/scripts/steps/functional/performance_playwright.sh agents: queue: c2-16 @@ -28,6 +30,7 @@ steps: - wait: ~ continue_on_failure: true - - label: ":male_superhero::skin-tone-2: Post-Build" + - label: ':male_superhero::skin-tone-2: Post-Build' command: .buildkite/scripts/lifecycle/post_build.sh - + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/pull_request.yml b/.buildkite/pipelines/pull_request.yml deleted file mode 100644 index 41c13bb403e1a..0000000000000 --- a/.buildkite/pipelines/pull_request.yml +++ /dev/null @@ -1,17 +0,0 @@ -env: - GITHUB_COMMIT_STATUS_ENABLED: 'true' - GITHUB_COMMIT_STATUS_CONTEXT: 'buildkite/kibana-pull-request' -steps: - - command: .buildkite/scripts/lifecycle/pre_build.sh - label: Pre-Build - - - wait - - - command: echo 'Hello World' - label: Test - - - wait: ~ - continue_on_failure: true - - - command: .buildkite/scripts/lifecycle/post_build.sh - label: Post-Build diff --git a/.buildkite/pipelines/pull_request/base.yml b/.buildkite/pipelines/pull_request/base.yml index d832717906bb1..3117ba98078d9 100644 --- a/.buildkite/pipelines/pull_request/base.yml +++ b/.buildkite/pipelines/pull_request/base.yml @@ -2,6 +2,8 @@ steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build timeout_in_minutes: 10 + agents: + queue: kibana-default - wait diff --git a/.buildkite/pipelines/pull_request/post_build.yml b/.buildkite/pipelines/pull_request/post_build.yml index 4f252bf8abc11..63f7169334584 100644 --- a/.buildkite/pipelines/pull_request/post_build.yml +++ b/.buildkite/pipelines/pull_request/post_build.yml @@ -4,3 +4,5 @@ steps: - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/purge_cloud_deployments.yml b/.buildkite/pipelines/purge_cloud_deployments.yml index 8287abf2ca5a2..9567f67a047f8 100644 --- a/.buildkite/pipelines/purge_cloud_deployments.yml +++ b/.buildkite/pipelines/purge_cloud_deployments.yml @@ -2,3 +2,5 @@ steps: - command: .buildkite/scripts/steps/cloud/purge.sh label: Purge old cloud deployments timeout_in_minutes: 10 + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/update_demo_env.yml b/.buildkite/pipelines/update_demo_env.yml index e2dfdd782fd41..12c4f296f5dfd 100644 --- a/.buildkite/pipelines/update_demo_env.yml +++ b/.buildkite/pipelines/update_demo_env.yml @@ -2,6 +2,8 @@ steps: - command: .buildkite/scripts/steps/demo_env/es_and_init.sh label: Initialize Environment and Deploy ES timeout_in_minutes: 10 + agents: + queue: kibana-default - command: .buildkite/scripts/steps/demo_env/kibana.sh label: Build and Deploy Kibana diff --git a/docs/setup/configuring-reporting.asciidoc b/docs/setup/configuring-reporting.asciidoc index 0b2fe48670777..bd800d6032309 100644 --- a/docs/setup/configuring-reporting.asciidoc +++ b/docs/setup/configuring-reporting.asciidoc @@ -30,6 +30,7 @@ If you are using Ubuntu/Debian systems, install the following packages: * `fonts-liberation` * `libfontconfig1` +* `libnss3` If the system is missing dependencies, *Reporting* fails in a non-deterministic way. {kib} runs a self-test at server startup, and if it encounters errors, logs them in the Console. The error message does not include diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index a86772d3ef27f..933e257ca235e 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -6,7 +6,7 @@ "description": "Developer documentation for building custom Kibana plugins and extending Kibana functionality.", "items": [ { - "category": "Getting started", + "label": "Getting started", "items": [ { "id": "kibDevDocsWelcome" }, { "id": "kibDevTutorialSetupDevEnv" }, @@ -16,7 +16,7 @@ ] }, { - "category": "Key concepts", + "label": "Key concepts", "items": [ { "id": "kibPlatformIntro" }, { "id": "kibDevAnatomyOfAPlugin" }, @@ -32,7 +32,7 @@ ] }, { - "category": "Tutorials", + "label": "Tutorials", "items": [ { "id": "kibDevTutorialTestingPlugins" }, { "id": "kibDevTutorialSavedObject" }, @@ -53,7 +53,7 @@ ] }, { - "category": "Contributing", + "label": "Contributing", "items": [ { "id": "kibRepoStructure" }, { "id": "kibDevPrinciples" }, @@ -65,7 +65,7 @@ ] }, { - "category": "Contributors Newsletters", + "label": "Contributors Newsletters", "items": [ { "id": "kibFebruary2022ContributorNewsletter" }, { "id": "kibJanuary2022ContributorNewsletter" }, @@ -82,7 +82,7 @@ ] }, { - "category": "API documentation", + "label": "API documentation", "items": [ { "id": "kibDevDocsApiWelcome" }, { "id": "kibDevDocsPluginDirectory" }, diff --git a/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js index 6e25e4c073ab0..417fc8e10aeca 100644 --- a/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js @@ -37,5 +37,10 @@ export default function () { captureLogOutput: false, sendToCiStats: false, }, + servers: { + elasticsearch: { + port: 1234, + }, + }, }; } diff --git a/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js index 4c87b53b5753b..067528c4ae120 100644 --- a/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js @@ -13,4 +13,9 @@ export default () => ({ mochaReporter: { sendToCiStats: false, }, + servers: { + elasticsearch: { + port: 1234, + }, + }, }); diff --git a/packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js b/packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js index 0d986a1602e12..47ae51ca62f13 100644 --- a/packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js @@ -61,7 +61,7 @@ describe('failure hooks', function () { expect(tests).toHaveLength(0); } catch (error) { - console.error('full log output', linesCopy.join('\n')); + error.message += `\n\nfull log output:${linesCopy.join('\n')}`; throw error; } }); diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js index 123bc8b9bc201..afcad01c4ab92 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js @@ -9,5 +9,10 @@ export default function () { return { testFiles: ['config.1'], + servers: { + elasticsearch: { + port: 1234, + }, + }, }; } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js index 2dd4c96186fcd..692a3de786723 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js @@ -11,5 +11,10 @@ export default async function ({ readConfigFile }) { return { testFiles: [...config1.get('testFiles'), 'config.2'], + servers: { + elasticsearch: { + port: 1234, + }, + }, }; } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/config.test.ts b/packages/kbn-test/src/functional_test_runner/lib/config/config.test.ts index 88c1fd99f0014..d551e7a884b41 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/config.test.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/config.test.ts @@ -15,6 +15,11 @@ describe('Config', () => { services: { foo: () => 42, }, + servers: { + elasticsearch: { + port: 1234, + }, + }, }, primary: true, path: process.cwd(), diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts index f65cb3c41f421..42a77b85ddc6c 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts @@ -17,19 +17,33 @@ const ID_PATTERN = /^[a-zA-Z0-9_]+$/; // it will search both --inspect and --inspect-brk const INSPECTING = !!process.execArgv.find((arg) => arg.includes('--inspect')); -const urlPartsSchema = () => +const maybeRequireKeys = (keys: string[], schemas: Record) => { + if (!keys.length) { + return schemas; + } + + const withRequires: Record = {}; + for (const [key, schema] of Object.entries(schemas)) { + withRequires[key] = keys.includes(key) ? schema.required() : schema; + } + return withRequires; +}; + +const urlPartsSchema = ({ requiredKeys }: { requiredKeys?: string[] } = {}) => Joi.object() - .keys({ - protocol: Joi.string().valid('http', 'https').default('http'), - hostname: Joi.string().hostname().default('localhost'), - port: Joi.number(), - auth: Joi.string().regex(/^[^:]+:.+$/, 'username and password separated by a colon'), - username: Joi.string(), - password: Joi.string(), - pathname: Joi.string().regex(/^\//, 'start with a /'), - hash: Joi.string().regex(/^\//, 'start with a /'), - certificateAuthorities: Joi.array().items(Joi.binary()).optional(), - }) + .keys( + maybeRequireKeys(requiredKeys ?? [], { + protocol: Joi.string().valid('http', 'https').default('http'), + hostname: Joi.string().hostname().default('localhost'), + port: Joi.number(), + auth: Joi.string().regex(/^[^:]+:.+$/, 'username and password separated by a colon'), + username: Joi.string(), + password: Joi.string(), + pathname: Joi.string().regex(/^\//, 'start with a /'), + hash: Joi.string().regex(/^\//, 'start with a /'), + certificateAuthorities: Joi.array().items(Joi.binary()).optional(), + }) + ) .default(); const appUrlPartsSchema = () => @@ -170,7 +184,9 @@ export const schema = Joi.object() servers: Joi.object() .keys({ kibana: urlPartsSchema(), - elasticsearch: urlPartsSchema(), + elasticsearch: urlPartsSchema({ + requiredKeys: ['port'], + }), }) .default(), diff --git a/src/plugins/data/common/datatable_utilities/datatable_utilities_service.test.ts b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.test.ts new file mode 100644 index 0000000000000..d626bc2226543 --- /dev/null +++ b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.test.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { createStubDataView } from 'src/plugins/data_views/common/mocks'; +import type { DataViewsContract } from 'src/plugins/data_views/common'; +import type { DatatableColumn } from 'src/plugins/expressions/common'; +import { FieldFormat } from 'src/plugins/field_formats/common'; +import { fieldFormatsMock } from 'src/plugins/field_formats/common/mocks'; +import type { AggsCommonStart } from '../search'; +import { DatatableUtilitiesService } from './datatable_utilities_service'; + +describe('DatatableUtilitiesService', () => { + let aggs: jest.Mocked; + let dataViews: jest.Mocked; + let datatableUtilitiesService: DatatableUtilitiesService; + + beforeEach(() => { + aggs = { + createAggConfigs: jest.fn(), + types: { get: jest.fn() }, + } as unknown as typeof aggs; + dataViews = { + get: jest.fn(), + } as unknown as typeof dataViews; + + datatableUtilitiesService = new DatatableUtilitiesService(aggs, dataViews, fieldFormatsMock); + }); + + describe('clearField', () => { + it('should delete the field reference', () => { + const column = { meta: { field: 'foo' } } as DatatableColumn; + + datatableUtilitiesService.clearField(column); + + expect(column).not.toHaveProperty('meta.field'); + }); + }); + + describe('clearFieldFormat', () => { + it('should remove field format', () => { + const column = { meta: { params: { id: 'number' } } } as DatatableColumn; + datatableUtilitiesService.clearFieldFormat(column); + + expect(column).not.toHaveProperty('meta.params'); + }); + }); + + describe('getDataView', () => { + it('should return a data view instance', async () => { + const column = { meta: { index: 'index' } } as DatatableColumn; + const dataView = {} as ReturnType; + dataViews.get.mockReturnValue(dataView); + + await expect(datatableUtilitiesService.getDataView(column)).resolves.toBe(dataView); + expect(dataViews.get).toHaveBeenCalledWith('index'); + }); + + it('should return undefined when there is no index metadata', async () => { + const column = { meta: {} } as DatatableColumn; + + await expect(datatableUtilitiesService.getDataView(column)).resolves.toBeUndefined(); + expect(dataViews.get).not.toHaveBeenCalled(); + }); + }); + + describe('getField', () => { + it('should return a data view field instance', async () => { + const column = { meta: { field: 'field', index: 'index' } } as DatatableColumn; + const dataView = createStubDataView({ spec: {} }); + const field = {}; + spyOn(datatableUtilitiesService, 'getDataView').and.returnValue(dataView); + spyOn(dataView, 'getFieldByName').and.returnValue(field); + + await expect(datatableUtilitiesService.getField(column)).resolves.toBe(field); + expect(dataView.getFieldByName).toHaveBeenCalledWith('field'); + }); + + it('should return undefined when there is no field metadata', async () => { + const column = { meta: {} } as DatatableColumn; + + await expect(datatableUtilitiesService.getField(column)).resolves.toBeUndefined(); + }); + }); + + describe('getFieldFormat', () => { + it('should deserialize field format', () => { + const column = { meta: { params: { id: 'number' } } } as DatatableColumn; + const fieldFormat = datatableUtilitiesService.getFieldFormat(column); + + expect(fieldFormat).toBeInstanceOf(FieldFormat); + }); + }); + + describe('getInterval', () => { + it('should return a histogram interval', () => { + const column = { + meta: { sourceParams: { params: { interval: '1d' } } }, + } as unknown as DatatableColumn; + + expect(datatableUtilitiesService.getInterval(column)).toBe('1d'); + }); + }); + + describe('setFieldFormat', () => { + it('should set new field format', () => { + const column = { meta: {} } as DatatableColumn; + const fieldFormat = fieldFormatsMock.deserialize({ id: 'number' }); + datatableUtilitiesService.setFieldFormat(column, fieldFormat); + + expect(column.meta.params).toEqual( + expect.objectContaining({ + id: expect.anything(), + params: undefined, + }) + ); + }); + }); +}); diff --git a/src/plugins/data/common/datatable_utilities/datatable_utilities_service.ts b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.ts new file mode 100644 index 0000000000000..cf4e65f31cce3 --- /dev/null +++ b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { DataView, DataViewsContract, DataViewField } from 'src/plugins/data_views/common'; +import type { DatatableColumn } from 'src/plugins/expressions/common'; +import type { FieldFormatsStartCommon, FieldFormat } from 'src/plugins/field_formats/common'; +import type { AggsCommonStart, AggConfig, CreateAggConfigParams, IAggType } from '../search'; + +export class DatatableUtilitiesService { + constructor( + private aggs: AggsCommonStart, + private dataViews: DataViewsContract, + private fieldFormats: FieldFormatsStartCommon + ) { + this.getAggConfig = this.getAggConfig.bind(this); + this.getDataView = this.getDataView.bind(this); + this.getField = this.getField.bind(this); + this.isFilterable = this.isFilterable.bind(this); + } + + clearField(column: DatatableColumn): void { + delete column.meta.field; + } + + clearFieldFormat(column: DatatableColumn): void { + delete column.meta.params; + } + + async getAggConfig(column: DatatableColumn): Promise { + const dataView = await this.getDataView(column); + + if (!dataView) { + return; + } + + const { aggs } = await this.aggs.createAggConfigs( + dataView, + column.meta.sourceParams && [column.meta.sourceParams as CreateAggConfigParams] + ); + + return aggs[0]; + } + + async getDataView(column: DatatableColumn): Promise { + if (!column.meta.index) { + return; + } + + return this.dataViews.get(column.meta.index); + } + + async getField(column: DatatableColumn): Promise { + if (!column.meta.field) { + return; + } + + const dataView = await this.getDataView(column); + if (!dataView) { + return; + } + + return dataView.getFieldByName(column.meta.field); + } + + getFieldFormat(column: DatatableColumn): FieldFormat | undefined { + return this.fieldFormats.deserialize(column.meta.params); + } + + getInterval(column: DatatableColumn): string | undefined { + const params = column.meta.sourceParams?.params as { interval: string } | undefined; + + return params?.interval; + } + + isFilterable(column: DatatableColumn): boolean { + if (column.meta.source !== 'esaggs') { + return false; + } + + const aggType = this.aggs.types.get(column.meta.sourceParams?.type as string) as IAggType; + + return Boolean(aggType.createFilter); + } + + setFieldFormat(column: DatatableColumn, fieldFormat: FieldFormat): void { + column.meta.params = fieldFormat.toJSON(); + } +} diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.4.js b/src/plugins/data/common/datatable_utilities/index.ts similarity index 78% rename from packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.4.js rename to src/plugins/data/common/datatable_utilities/index.ts index 6dc8aa803613d..34df78137510a 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.4.js +++ b/src/plugins/data/common/datatable_utilities/index.ts @@ -6,10 +6,4 @@ * Side Public License, v 1. */ -export default function () { - return { - screenshots: { - directory: 'bar', - }, - }; -} +export * from './datatable_utilities_service'; diff --git a/src/plugins/data/common/datatable_utilities/mock.ts b/src/plugins/data/common/datatable_utilities/mock.ts new file mode 100644 index 0000000000000..4266e501f2ca2 --- /dev/null +++ b/src/plugins/data/common/datatable_utilities/mock.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { DatatableUtilitiesService } from './datatable_utilities_service'; + +export function createDatatableUtilitiesMock(): jest.Mocked { + return { + clearField: jest.fn(), + clearFieldFormat: jest.fn(), + getAggConfig: jest.fn(), + getDataView: jest.fn(), + getField: jest.fn(), + getFieldFormat: jest.fn(), + isFilterable: jest.fn(), + setFieldFormat: jest.fn(), + } as unknown as jest.Mocked; +} diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index 7bb4b78850dcd..a793050eb6556 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -10,6 +10,7 @@ /* eslint-disable @kbn/eslint/no_export_all */ export * from './constants'; +export * from './datatable_utilities'; export * from './es_query'; export * from './kbn_field_types'; export * from './query'; diff --git a/src/plugins/data/common/mocks.ts b/src/plugins/data/common/mocks.ts index c656d9d21346e..cf7d6bef6a4e8 100644 --- a/src/plugins/data/common/mocks.ts +++ b/src/plugins/data/common/mocks.ts @@ -7,3 +7,4 @@ */ export * from '../../data_views/common/fields/fields.mocks'; +export * from './datatable_utilities/mock'; diff --git a/src/plugins/data/common/search/aggs/aggs_service.test.ts b/src/plugins/data/common/search/aggs/aggs_service.test.ts index 998b8bf286b52..b7237c7b80134 100644 --- a/src/plugins/data/common/search/aggs/aggs_service.test.ts +++ b/src/plugins/data/common/search/aggs/aggs_service.test.ts @@ -206,11 +206,10 @@ describe('Aggs service', () => { describe('start()', () => { test('exposes proper contract', () => { const start = service.start(startDeps); - expect(Object.keys(start).length).toBe(4); + expect(Object.keys(start).length).toBe(3); expect(start).toHaveProperty('calculateAutoTimeExpression'); expect(start).toHaveProperty('createAggConfigs'); expect(start).toHaveProperty('types'); - expect(start).toHaveProperty('datatableUtilities'); }); test('types registry returns uninitialized type providers', () => { diff --git a/src/plugins/data/common/search/aggs/aggs_service.ts b/src/plugins/data/common/search/aggs/aggs_service.ts index 58f65bb0cab44..6fe7eef5b87b4 100644 --- a/src/plugins/data/common/search/aggs/aggs_service.ts +++ b/src/plugins/data/common/search/aggs/aggs_service.ts @@ -17,7 +17,6 @@ import { getCalculateAutoTimeExpression, } from './'; import { AggsCommonSetup, AggsCommonStart } from './types'; -import { getDatatableColumnUtilities } from './utils/datatable_column_meta'; /** @internal */ export const aggsRequiredUiSettings = [ @@ -67,11 +66,7 @@ export class AggsCommonService { }; } - public start({ - getConfig, - getIndexPattern, - isDefaultTimezone, - }: AggsCommonStartDependencies): AggsCommonStart { + public start({ getConfig }: AggsCommonStartDependencies): AggsCommonStart { const aggTypesStart = this.aggTypesRegistry.start(); const calculateAutoTimeExpression = getCalculateAutoTimeExpression(getConfig); @@ -86,11 +81,6 @@ export class AggsCommonService { return { calculateAutoTimeExpression, - datatableUtilities: getDatatableColumnUtilities({ - getIndexPattern, - createAggConfigs, - aggTypesStart, - }), createAggConfigs, types: aggTypesStart, }; diff --git a/src/plugins/data/common/search/aggs/types.ts b/src/plugins/data/common/search/aggs/types.ts index 34d773b0ba518..cf9a6123b14c8 100644 --- a/src/plugins/data/common/search/aggs/types.ts +++ b/src/plugins/data/common/search/aggs/types.ts @@ -7,7 +7,6 @@ */ import { Assign } from '@kbn/utility-types'; -import { DatatableColumn } from 'src/plugins/expressions'; import { IndexPattern } from '../..'; import { aggAvg, @@ -88,7 +87,6 @@ import { CreateAggConfigParams, getCalculateAutoTimeExpression, METRIC_TYPES, - AggConfig, aggFilteredMetric, aggSinglePercentile, } from './'; @@ -111,11 +109,6 @@ export interface AggsCommonSetup { export interface AggsCommonStart { calculateAutoTimeExpression: ReturnType; - datatableUtilities: { - getIndexPattern: (column: DatatableColumn) => Promise; - getAggConfig: (column: DatatableColumn) => Promise; - isFilterable: (column: DatatableColumn) => boolean; - }; createAggConfigs: ( indexPattern: IndexPattern, configStates?: CreateAggConfigParams[] diff --git a/src/plugins/data/common/search/aggs/utils/datatable_column_meta.ts b/src/plugins/data/common/search/aggs/utils/datatable_column_meta.ts deleted file mode 100644 index 0e3ff69fac1d1..0000000000000 --- a/src/plugins/data/common/search/aggs/utils/datatable_column_meta.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { DatatableColumn } from 'src/plugins/expressions/common'; -import { IndexPattern } from '../../..'; -import { AggConfigs, CreateAggConfigParams } from '../agg_configs'; -import { AggTypesRegistryStart } from '../agg_types_registry'; -import { IAggType } from '../agg_type'; - -export interface MetaByColumnDeps { - getIndexPattern: (id: string) => Promise; - createAggConfigs: ( - indexPattern: IndexPattern, - configStates?: CreateAggConfigParams[] - ) => InstanceType; - aggTypesStart: AggTypesRegistryStart; -} - -export const getDatatableColumnUtilities = (deps: MetaByColumnDeps) => { - const { getIndexPattern, createAggConfigs, aggTypesStart } = deps; - - const getIndexPatternFromDatatableColumn = async (column: DatatableColumn) => { - if (!column.meta.index) return; - - return await getIndexPattern(column.meta.index); - }; - - const getAggConfigFromDatatableColumn = async (column: DatatableColumn) => { - const indexPattern = await getIndexPatternFromDatatableColumn(column); - - if (!indexPattern) return; - - const aggConfigs = await createAggConfigs(indexPattern, [column.meta.sourceParams as any]); - return aggConfigs.aggs[0]; - }; - - const isFilterableAggDatatableColumn = (column: DatatableColumn) => { - if (column.meta.source !== 'esaggs') { - return false; - } - const aggType = (aggTypesStart.get(column.meta.sourceParams?.type as string) as any)( - {} - ) as IAggType; - return Boolean(aggType.createFilter); - }; - - return { - getIndexPattern: getIndexPatternFromDatatableColumn, - getAggConfig: getAggConfigFromDatatableColumn, - isFilterable: isFilterableAggDatatableColumn, - }; -}; diff --git a/src/plugins/data/public/mocks.ts b/src/plugins/data/public/mocks.ts index 53600a1f44469..630a29a8a7854 100644 --- a/src/plugins/data/public/mocks.ts +++ b/src/plugins/data/public/mocks.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { createDatatableUtilitiesMock } from '../common/mocks'; import { DataPlugin, DataViewsContract } from '.'; import { fieldFormatsServiceMock } from '../../field_formats/public/mocks'; import { searchServiceMock } from './search/mocks'; @@ -58,6 +59,7 @@ const createStartContract = (): Start => { createFiltersFromRangeSelectAction: jest.fn(), }, autocomplete: autocompleteStartMock, + datatableUtilities: createDatatableUtilitiesMock(), search: searchServiceMock.createStartContract(), fieldFormats: fieldFormatsServiceMock.createStartContract(), query: queryStartMock, diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 7d19c1eb3ac19..50795b4416247 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -42,7 +42,7 @@ import { APPLY_FILTER_TRIGGER, applyFilterTrigger } from './triggers'; import { UsageCollectionSetup } from '../../usage_collection/public'; import { getTableViewDescription } from './utils/table_inspector_view'; import { NowProvider, NowProviderInternalContract } from './now_provider'; -import { getAggsFormats } from '../common'; +import { getAggsFormats, DatatableUtilitiesService } from '../common'; export class DataPublicPlugin implements @@ -108,7 +108,7 @@ export class DataPublicPlugin uiActions: startServices().plugins.uiActions, uiSettings: startServices().core.uiSettings, fieldFormats: startServices().self.fieldFormats, - isFilterable: startServices().self.search.aggs.datatableUtilities.isFilterable, + isFilterable: startServices().self.datatableUtilities.isFilterable, })) ); @@ -166,12 +166,14 @@ export class DataPublicPlugin uiActions.getAction(ACTION_GLOBAL_APPLY_FILTER) ); + const datatableUtilities = new DatatableUtilitiesService(search.aggs, dataViews, fieldFormats); const dataServices = { actions: { createFiltersFromValueClickAction, createFiltersFromRangeSelectAction, }, autocomplete: this.autocomplete.start(), + datatableUtilities, fieldFormats, indexPatterns: dataViews, dataViews, diff --git a/src/plugins/data/public/search/aggs/aggs_service.test.ts b/src/plugins/data/public/search/aggs/aggs_service.test.ts index b0e6e0327e654..101c2c909c7e1 100644 --- a/src/plugins/data/public/search/aggs/aggs_service.test.ts +++ b/src/plugins/data/public/search/aggs/aggs_service.test.ts @@ -79,11 +79,10 @@ describe('AggsService - public', () => { describe('start()', () => { test('exposes proper contract', () => { const start = service.start(startDeps); - expect(Object.keys(start).length).toBe(4); + expect(Object.keys(start).length).toBe(3); expect(start).toHaveProperty('calculateAutoTimeExpression'); expect(start).toHaveProperty('createAggConfigs'); expect(start).toHaveProperty('types'); - expect(start).toHaveProperty('datatableUtilities'); }); test('types registry returns initialized agg types', () => { diff --git a/src/plugins/data/public/search/aggs/aggs_service.ts b/src/plugins/data/public/search/aggs/aggs_service.ts index 4907c3bcbad26..99930a95831ea 100644 --- a/src/plugins/data/public/search/aggs/aggs_service.ts +++ b/src/plugins/data/public/search/aggs/aggs_service.ts @@ -91,13 +91,11 @@ export class AggsService { public start({ fieldFormats, uiSettings, indexPatterns }: AggsStartDependencies): AggsStart { const isDefaultTimezone = () => uiSettings.isDefault('dateFormat:tz'); - const { calculateAutoTimeExpression, datatableUtilities, types } = this.aggsCommonService.start( - { - getConfig: this.getConfig!, - getIndexPattern: indexPatterns.get, - isDefaultTimezone, - } - ); + const { calculateAutoTimeExpression, types } = this.aggsCommonService.start({ + getConfig: this.getConfig!, + getIndexPattern: indexPatterns.get, + isDefaultTimezone, + }); const aggTypesDependencies: AggTypesDependencies = { calculateBounds: this.calculateBounds, @@ -137,7 +135,6 @@ export class AggsService { return { calculateAutoTimeExpression, - datatableUtilities, createAggConfigs: (indexPattern, configStates = []) => { return new AggConfigs(indexPattern, configStates, { typesRegistry }); }, diff --git a/src/plugins/data/public/search/aggs/mocks.ts b/src/plugins/data/public/search/aggs/mocks.ts index fb50058f08348..c45d024384ba6 100644 --- a/src/plugins/data/public/search/aggs/mocks.ts +++ b/src/plugins/data/public/search/aggs/mocks.ts @@ -56,11 +56,6 @@ export const searchAggsSetupMock = (): AggsSetup => ({ export const searchAggsStartMock = (): AggsStart => ({ calculateAutoTimeExpression: getCalculateAutoTimeExpression(getConfig), - datatableUtilities: { - isFilterable: jest.fn(), - getAggConfig: jest.fn(), - getIndexPattern: jest.fn(), - }, createAggConfigs: jest.fn().mockImplementation((indexPattern, configStates = [], schemas) => { return new AggConfigs(indexPattern, configStates, { typesRegistry: mockAggTypesRegistry(), diff --git a/src/plugins/data/public/types.ts b/src/plugins/data/public/types.ts index e2e7c6b222b90..bfc35b8f39c51 100644 --- a/src/plugins/data/public/types.ts +++ b/src/plugins/data/public/types.ts @@ -14,6 +14,7 @@ import { ExpressionsSetup } from 'src/plugins/expressions/public'; import { DataViewsPublicPluginStart } from 'src/plugins/data_views/public'; import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; import { FieldFormatsSetup, FieldFormatsStart } from 'src/plugins/field_formats/public'; +import { DatatableUtilitiesService } from '../common'; import { AutocompleteSetup, AutocompleteStart } from './autocomplete'; import { createFiltersFromRangeSelectAction, createFiltersFromValueClickAction } from './actions'; import type { ISearchSetup, ISearchStart } from './search'; @@ -83,6 +84,12 @@ export interface DataPublicPluginStart { * {@link DataViewsContract} */ dataViews: DataViewsContract; + + /** + * Datatable type utility functions. + */ + datatableUtilities: DatatableUtilitiesService; + /** * index patterns service * {@link DataViewsContract} diff --git a/src/plugins/data/server/datatable_utilities/datatable_utilities_service.ts b/src/plugins/data/server/datatable_utilities/datatable_utilities_service.ts new file mode 100644 index 0000000000000..3909003cd4d2c --- /dev/null +++ b/src/plugins/data/server/datatable_utilities/datatable_utilities_service.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 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 { + ElasticsearchClient, + SavedObjectsClientContract, + UiSettingsServiceStart, +} from 'src/core/server'; +import type { FieldFormatsStart } from 'src/plugins/field_formats/server'; +import type { IndexPatternsServiceStart } from 'src/plugins/data_views/server'; +import { DatatableUtilitiesService as DatatableUtilitiesServiceCommon } from '../../common'; +import type { AggsStart } from '../search'; + +export class DatatableUtilitiesService { + constructor( + private aggs: AggsStart, + private dataViews: IndexPatternsServiceStart, + private fieldFormats: FieldFormatsStart, + private uiSettings: UiSettingsServiceStart + ) { + this.asScopedToClient = this.asScopedToClient.bind(this); + } + + async asScopedToClient( + savedObjectsClient: SavedObjectsClientContract, + elasticsearchClient: ElasticsearchClient + ): Promise { + const aggs = await this.aggs.asScopedToClient(savedObjectsClient, elasticsearchClient); + const dataViews = await this.dataViews.dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient + ); + const uiSettings = this.uiSettings.asScopedToClient(savedObjectsClient); + const fieldFormats = await this.fieldFormats.fieldFormatServiceFactory(uiSettings); + + return new DatatableUtilitiesServiceCommon(aggs, dataViews, fieldFormats); + } +} diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.3.js b/src/plugins/data/server/datatable_utilities/index.ts similarity index 60% rename from packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.3.js rename to src/plugins/data/server/datatable_utilities/index.ts index b68a5115553f5..34df78137510a 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.3.js +++ b/src/plugins/data/server/datatable_utilities/index.ts @@ -6,12 +6,4 @@ * Side Public License, v 1. */ -export default async function ({ readConfigFile }) { - const config4 = await readConfigFile(require.resolve('./config.4')); - return { - testFiles: ['baz'], - screenshots: { - ...config4.get('screenshots'), - }, - }; -} +export * from './datatable_utilities_service'; diff --git a/src/plugins/data/server/datatable_utilities/mock.ts b/src/plugins/data/server/datatable_utilities/mock.ts new file mode 100644 index 0000000000000..9ec069fda7ab0 --- /dev/null +++ b/src/plugins/data/server/datatable_utilities/mock.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createDatatableUtilitiesMock as createDatatableUtilitiesCommonMock } from '../../common/mocks'; +import type { DatatableUtilitiesService } from './datatable_utilities_service'; + +export function createDatatableUtilitiesMock(): jest.Mocked { + return { + asScopedToClient: jest.fn(createDatatableUtilitiesCommonMock), + } as unknown as jest.Mocked; +} diff --git a/src/plugins/data/server/mocks.ts b/src/plugins/data/server/mocks.ts index 6fd670d869c20..355e809888bd4 100644 --- a/src/plugins/data/server/mocks.ts +++ b/src/plugins/data/server/mocks.ts @@ -16,6 +16,7 @@ import { createFieldFormatsStartMock, } from '../../field_formats/server/mocks'; import { createIndexPatternsStartMock } from './data_views/mocks'; +import { createDatatableUtilitiesMock } from './datatable_utilities/mock'; import { DataRequestHandlerContext } from './search'; import { AutocompleteSetup } from './autocomplete'; @@ -42,6 +43,7 @@ function createStartContract() { */ fieldFormats: createFieldFormatsStartMock(), indexPatterns: createIndexPatternsStartMock(), + datatableUtilities: createDatatableUtilitiesMock(), }; } diff --git a/src/plugins/data/server/plugin.ts b/src/plugins/data/server/plugin.ts index ab8e28755cd77..9d5b3792da566 100644 --- a/src/plugins/data/server/plugin.ts +++ b/src/plugins/data/server/plugin.ts @@ -11,6 +11,7 @@ import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { PluginStart as DataViewsServerPluginStart } from 'src/plugins/data_views/server'; import { ConfigSchema } from '../config'; +import { DatatableUtilitiesService } from './datatable_utilities'; import type { ISearchSetup, ISearchStart, SearchEnhancements } from './search'; import { SearchService } from './search/search_service'; import { QueryService } from './query/query_service'; @@ -48,6 +49,11 @@ export interface DataPluginStart { */ fieldFormats: FieldFormatsStart; indexPatterns: DataViewsServerPluginStart; + + /** + * Datatable type utility functions. + */ + datatableUtilities: DatatableUtilitiesService; } export interface DataPluginSetupDependencies { @@ -115,10 +121,19 @@ export class DataServerPlugin } public start(core: CoreStart, { fieldFormats, dataViews }: DataPluginStartDependencies) { + const search = this.searchService.start(core, { fieldFormats, indexPatterns: dataViews }); + const datatableUtilities = new DatatableUtilitiesService( + search.aggs, + dataViews, + fieldFormats, + core.uiSettings + ); + return { + datatableUtilities, + search, fieldFormats, indexPatterns: dataViews, - search: this.searchService.start(core, { fieldFormats, indexPatterns: dataViews }), }; } diff --git a/src/plugins/data/server/search/aggs/aggs_service.ts b/src/plugins/data/server/search/aggs/aggs_service.ts index e65c6d4134970..808c0e9cc8499 100644 --- a/src/plugins/data/server/search/aggs/aggs_service.ts +++ b/src/plugins/data/server/search/aggs/aggs_service.ts @@ -72,17 +72,13 @@ export class AggsService { }; const isDefaultTimezone = () => getConfig('dateFormat:tz') === 'Browser'; - const { calculateAutoTimeExpression, datatableUtilities, types } = - this.aggsCommonService.start({ - getConfig, - getIndexPattern: ( - await indexPatterns.indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient - ) - ).get, - isDefaultTimezone, - }); + const { calculateAutoTimeExpression, types } = this.aggsCommonService.start({ + getConfig, + getIndexPattern: ( + await indexPatterns.indexPatternsServiceFactory(savedObjectsClient, elasticsearchClient) + ).get, + isDefaultTimezone, + }); const aggTypesDependencies: AggTypesDependencies = { calculateBounds: this.calculateBounds, @@ -118,7 +114,6 @@ export class AggsService { return { calculateAutoTimeExpression, - datatableUtilities, createAggConfigs: (indexPattern, configStates = []) => { return new AggConfigs(indexPattern, configStates, { typesRegistry }); }, diff --git a/src/plugins/data/server/search/aggs/mocks.ts b/src/plugins/data/server/search/aggs/mocks.ts index 3644a3c13c48d..301bc3e5e1240 100644 --- a/src/plugins/data/server/search/aggs/mocks.ts +++ b/src/plugins/data/server/search/aggs/mocks.ts @@ -58,11 +58,6 @@ export const searchAggsSetupMock = (): AggsSetup => ({ const commonStartMock = (): AggsCommonStart => ({ calculateAutoTimeExpression: getCalculateAutoTimeExpression(getConfig), - datatableUtilities: { - getIndexPattern: jest.fn(), - getAggConfig: jest.fn(), - isFilterable: jest.fn(), - }, createAggConfigs: jest.fn().mockImplementation((indexPattern, configStates = [], schemas) => { return new AggConfigs(indexPattern, configStates, { typesRegistry: mockAggTypesRegistry(), diff --git a/src/plugins/input_control_vis/public/components/editor/control_editor.tsx b/src/plugins/input_control_vis/public/components/editor/control_editor.tsx index 76727fcaa645f..cb0ea78d613b3 100644 --- a/src/plugins/input_control_vis/public/components/editor/control_editor.tsx +++ b/src/plugins/input_control_vis/public/components/editor/control_editor.tsx @@ -22,7 +22,7 @@ import { import { RangeControlEditor } from './range_control_editor'; import { ListControlEditor } from './list_control_editor'; import { getTitle, ControlParams, CONTROL_TYPES, ControlParamsOptions } from '../../editor_utils'; -import { IndexPattern } from '../../../../data/public'; +import { DataView } from '../../../../data_views/public'; import { InputControlVisDependencies } from '../../plugin'; import './control_editor.scss'; @@ -35,7 +35,7 @@ interface ControlEditorUiProps { handleRemoveControl: (controlIndex: number) => void; handleIndexPatternChange: (controlIndex: number, indexPatternId: string) => void; handleFieldNameChange: (controlIndex: number, fieldName: string) => void; - getIndexPattern: (indexPatternId: string) => Promise; + getIndexPattern: (indexPatternId: string) => Promise; handleOptionsChange: ( controlIndex: number, optionName: T, diff --git a/src/plugins/input_control_vis/public/components/editor/controls_tab.tsx b/src/plugins/input_control_vis/public/components/editor/controls_tab.tsx index 41a6b34259a72..0b000aa61f34e 100644 --- a/src/plugins/input_control_vis/public/components/editor/controls_tab.tsx +++ b/src/plugins/input_control_vis/public/components/editor/controls_tab.tsx @@ -20,7 +20,7 @@ import { } from '@elastic/eui'; import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; -import { IndexPattern } from 'src/plugins/data/public'; +import { DataView } from '../../../../data_views/public'; import { ControlEditor } from './control_editor'; import { addControl, @@ -49,7 +49,7 @@ class ControlsTab extends PureComponent { type: CONTROL_TYPES.LIST, }; - getIndexPattern = async (indexPatternId: string): Promise => { + getIndexPattern = async (indexPatternId: string): Promise => { const [, startDeps] = await this.props.deps.core.getStartServices(); return await startDeps.data.indexPatterns.get(indexPatternId); }; diff --git a/src/plugins/input_control_vis/public/components/editor/field_select.tsx b/src/plugins/input_control_vis/public/components/editor/field_select.tsx index 1ecbf2772ebfd..7cc818b71d795 100644 --- a/src/plugins/input_control_vis/public/components/editor/field_select.tsx +++ b/src/plugins/input_control_vis/public/components/editor/field_select.tsx @@ -12,7 +12,7 @@ import React, { Component } from 'react'; import { injectI18n, FormattedMessage, InjectedIntlProps } from '@kbn/i18n-react'; import { EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { IndexPattern, IndexPatternField } from '../../../../data/public'; +import { DataView, DataViewField } from '../../../../data_views/public'; interface FieldSelectUiState { isLoading: boolean; @@ -21,11 +21,11 @@ interface FieldSelectUiState { } export type FieldSelectUiProps = InjectedIntlProps & { - getIndexPattern: (indexPatternId: string) => Promise; + getIndexPattern: (indexPatternId: string) => Promise; indexPatternId: string; onChange: (value: any) => void; fieldName?: string; - filterField?: (field: IndexPatternField) => boolean; + filterField?: (field: DataViewField) => boolean; controlIndex: number; }; @@ -74,7 +74,7 @@ class FieldSelectUi extends Component { return; } - let indexPattern: IndexPattern; + let indexPattern: DataView; try { indexPattern = await this.props.getIndexPattern(indexPatternId); } catch (err) { @@ -96,7 +96,7 @@ class FieldSelectUi extends Component { const fields: Array> = []; indexPattern.fields .filter(this.props.filterField ?? (() => true)) - .forEach((field: IndexPatternField) => { + .forEach((field: DataViewField) => { const fieldsList = fieldsByTypeMap.get(field.type) ?? []; fieldsList.push(field.name); fieldsByTypeMap.set(field.type, fieldsList); diff --git a/src/plugins/input_control_vis/public/components/editor/list_control_editor.tsx b/src/plugins/input_control_vis/public/components/editor/list_control_editor.tsx index 2bf1bacbbcd5b..720b1325142ec 100644 --- a/src/plugins/input_control_vis/public/components/editor/list_control_editor.tsx +++ b/src/plugins/input_control_vis/public/components/editor/list_control_editor.tsx @@ -14,7 +14,8 @@ import { EuiFormRow, EuiFieldNumber, EuiSwitch, EuiSelect } from '@elastic/eui'; import { IndexPatternSelectFormRow } from './index_pattern_select_form_row'; import { FieldSelect } from './field_select'; import { ControlParams, ControlParamsOptions } from '../../editor_utils'; -import { IndexPattern, IndexPatternField, IndexPatternSelectProps } from '../../../../data/public'; +import { IndexPatternSelectProps } from '../../../../data/public'; +import { DataView, DataViewField } from '../../../../data_views/public'; import { InputControlVisDependencies } from '../../plugin'; interface ListControlEditorState { @@ -25,7 +26,7 @@ interface ListControlEditorState { } interface ListControlEditorProps { - getIndexPattern: (indexPatternId: string) => Promise; + getIndexPattern: (indexPatternId: string) => Promise; controlIndex: number; controlParams: ControlParams; handleFieldNameChange: (fieldName: string) => void; @@ -40,7 +41,7 @@ interface ListControlEditorProps { deps: InputControlVisDependencies; } -function filterField(field: IndexPatternField) { +function filterField(field: DataViewField) { return ( Boolean(field.aggregatable) && ['number', 'boolean', 'date', 'ip', 'string'].includes(field.type) @@ -104,7 +105,7 @@ export class ListControlEditor extends PureComponent< return; } - let indexPattern: IndexPattern; + let indexPattern: DataView; try { indexPattern = await this.props.getIndexPattern(this.props.controlParams.indexPattern); } catch (err) { @@ -116,7 +117,7 @@ export class ListControlEditor extends PureComponent< return; } - const field = (indexPattern.fields as IndexPatternField[]).find( + const field = (indexPattern.fields as DataViewField[]).find( ({ name }) => name === this.props.controlParams.fieldName ); if (!field) { diff --git a/src/plugins/input_control_vis/public/components/editor/range_control_editor.tsx b/src/plugins/input_control_vis/public/components/editor/range_control_editor.tsx index cdf8663caea56..913eb49c96cfe 100644 --- a/src/plugins/input_control_vis/public/components/editor/range_control_editor.tsx +++ b/src/plugins/input_control_vis/public/components/editor/range_control_editor.tsx @@ -14,13 +14,14 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { IndexPatternSelectFormRow } from './index_pattern_select_form_row'; import { FieldSelect } from './field_select'; import { ControlParams, ControlParamsOptions } from '../../editor_utils'; -import { IndexPattern, IndexPatternField, IndexPatternSelectProps } from '../../../../data/public'; +import { IndexPatternSelectProps } from '../../../../data/public'; +import { DataView, DataViewField } from '../../../../data_views/public'; import { InputControlVisDependencies } from '../../plugin'; interface RangeControlEditorProps { controlIndex: number; controlParams: ControlParams; - getIndexPattern: (indexPatternId: string) => Promise; + getIndexPattern: (indexPatternId: string) => Promise; handleFieldNameChange: (fieldName: string) => void; handleIndexPatternChange: (indexPatternId: string) => void; handleOptionsChange: ( @@ -35,7 +36,7 @@ interface RangeControlEditorState { IndexPatternSelect: ComponentType | null; } -function filterField(field: IndexPatternField) { +function filterField(field: DataViewField) { return field.type === 'number'; } diff --git a/src/plugins/input_control_vis/public/control/control.ts b/src/plugins/input_control_vis/public/control/control.ts index 2df4a417da43c..26a88be6cd907 100644 --- a/src/plugins/input_control_vis/public/control/control.ts +++ b/src/plugins/input_control_vis/public/control/control.ts @@ -9,7 +9,7 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { Filter } from 'src/plugins/data/public'; +import { Filter } from '@kbn/es-query'; import { ControlParams, ControlParamsOptions, CONTROL_TYPES } from '../editor_utils'; import { RangeFilterManager } from './filter_manager/range_filter_manager'; import { PhraseFilterManager } from './filter_manager/phrase_filter_manager'; diff --git a/src/plugins/input_control_vis/public/control/create_search_source.ts b/src/plugins/input_control_vis/public/control/create_search_source.ts index 87dec8b1d9a24..c9db1de9f7f22 100644 --- a/src/plugins/input_control_vis/public/control/create_search_source.ts +++ b/src/plugins/input_control_vis/public/control/create_search_source.ts @@ -9,15 +9,16 @@ import { Filter } from '@kbn/es-query'; import { SerializedSearchSourceFields, - IndexPattern, TimefilterContract, DataPublicPluginStart, } from 'src/plugins/data/public'; +import { DataView } from '../../../data_views/public'; + export async function createSearchSource( { create }: DataPublicPluginStart['search']['searchSource'], initialState: SerializedSearchSourceFields | null, - indexPattern: IndexPattern, + indexPattern: DataView, aggs: any, useTimeFilter: boolean, filters: Filter[] = [], diff --git a/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.test.ts b/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.test.ts index a96326a626a27..7759ba3b34607 100644 --- a/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.test.ts +++ b/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.test.ts @@ -10,11 +10,8 @@ import expect from '@kbn/expect'; import { FilterManager } from './filter_manager'; import { coreMock } from '../../../../../core/public/mocks'; -import { - Filter, - FilterManager as QueryFilterManager, - IndexPatternsContract, -} from '../../../../data/public'; +import { FilterManager as QueryFilterManager, DataViewsContract } from '../../../../data/public'; +import { Filter } from '@kbn/es-query'; const setupMock = coreMock.createSetup(); @@ -44,7 +41,7 @@ describe('FilterManager', function () { controlId, 'field1', '1', - {} as IndexPatternsContract, + {} as DataViewsContract, queryFilterMock ); }); diff --git a/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.ts b/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.ts index f35eb364ecaf6..420cb8fe844d7 100644 --- a/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.ts +++ b/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.ts @@ -6,23 +6,20 @@ * Side Public License, v 1. */ +import { Filter } from '@kbn/es-query'; import _ from 'lodash'; -import { - FilterManager as QueryFilterManager, - IndexPattern, - Filter, - IndexPatternsContract, -} from '../../../../data/public'; +import { FilterManager as QueryFilterManager, DataViewsContract } from '../../../../data/public'; +import { DataView } from '../../../../data_views/public'; export abstract class FilterManager { - protected indexPattern: IndexPattern | undefined; + protected indexPattern: DataView | undefined; constructor( public controlId: string, public fieldName: string, private indexPatternId: string, - private indexPatternsService: IndexPatternsContract, + private indexPatternsService: DataViewsContract, protected queryFilter: QueryFilterManager ) {} @@ -48,7 +45,7 @@ export abstract class FilterManager { } } - getIndexPattern(): IndexPattern | undefined { + getIndexPattern(): DataView | undefined { return this.indexPattern; } diff --git a/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.test.ts b/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.test.ts index 14a616e8a0dbe..45e67ad742a64 100644 --- a/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.test.ts +++ b/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.test.ts @@ -9,11 +9,8 @@ import { Filter } from '@kbn/es-query'; import expect from '@kbn/expect'; -import { - IndexPattern, - FilterManager as QueryFilterManager, - IndexPatternsContract, -} from '../../../../data/public'; +import { FilterManager as QueryFilterManager, DataViewsContract } from '../../../../data/public'; +import { DataView } from '../../../../data_views/public'; import { PhraseFilterManager } from './phrase_filter_manager'; describe('PhraseFilterManager', function () { @@ -27,7 +24,7 @@ describe('PhraseFilterManager', function () { convert: (value: any) => value, }, }; - const indexPatternMock: IndexPattern = { + const indexPatternMock: DataView = { id: indexPatternId, fields: { getByName: (name: string) => { @@ -35,10 +32,10 @@ describe('PhraseFilterManager', function () { return fields[name]; }, }, - } as IndexPattern; + } as DataView; const indexPatternsServiceMock = { get: jest.fn().mockReturnValue(Promise.resolve(indexPatternMock)), - } as unknown as jest.Mocked; + } as unknown as jest.Mocked; const queryFilterMock: QueryFilterManager = {} as QueryFilterManager; let filterManager: PhraseFilterManager; beforeEach(async () => { @@ -89,7 +86,7 @@ describe('PhraseFilterManager', function () { id: string, fieldName: string, indexPatternId: string, - indexPatternsService: IndexPatternsContract, + indexPatternsService: DataViewsContract, queryFilter: QueryFilterManager ) { super(id, fieldName, indexPatternId, indexPatternsService, queryFilter); @@ -105,7 +102,7 @@ describe('PhraseFilterManager', function () { } } - const indexPatternsServiceMock = {} as IndexPatternsContract; + const indexPatternsServiceMock = {} as DataViewsContract; const queryFilterMock: QueryFilterManager = {} as QueryFilterManager; let filterManager: MockFindFiltersPhraseFilterManager; beforeEach(() => { diff --git a/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.ts b/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.ts index 98ba8b4fbcda8..0653d25f16d44 100644 --- a/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.ts +++ b/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.ts @@ -18,17 +18,14 @@ import { PhraseFilter, } from '@kbn/es-query'; import { FilterManager } from './filter_manager'; -import { - IndexPatternsContract, - FilterManager as QueryFilterManager, -} from '../../../../data/public'; +import { DataViewsContract, FilterManager as QueryFilterManager } from '../../../../data/public'; export class PhraseFilterManager extends FilterManager { constructor( controlId: string, fieldName: string, indexPatternId: string, - indexPatternsService: IndexPatternsContract, + indexPatternsService: DataViewsContract, queryFilter: QueryFilterManager ) { super(controlId, fieldName, indexPatternId, indexPatternsService, queryFilter); diff --git a/src/plugins/input_control_vis/public/control/filter_manager/range_filter_manager.test.ts b/src/plugins/input_control_vis/public/control/filter_manager/range_filter_manager.test.ts index bdcd1a34573d6..a329773720bc9 100644 --- a/src/plugins/input_control_vis/public/control/filter_manager/range_filter_manager.test.ts +++ b/src/plugins/input_control_vis/public/control/filter_manager/range_filter_manager.test.ts @@ -9,11 +9,8 @@ import expect from '@kbn/expect'; import { RangeFilterManager } from './range_filter_manager'; -import { - IndexPattern, - FilterManager as QueryFilterManager, - IndexPatternsContract, -} from '../../../../data/public'; +import { FilterManager as QueryFilterManager, DataViewsContract } from '../../../../data/public'; +import { DataView } from '../../../../data_views/public'; import { RangeFilter, RangeFilterMeta } from '@kbn/es-query'; describe('RangeFilterManager', function () { @@ -24,7 +21,7 @@ describe('RangeFilterManager', function () { const fieldMock = { name: 'field1', }; - const indexPatternMock: IndexPattern = { + const indexPatternMock: DataView = { id: indexPatternId, fields: { getByName: (name: any) => { @@ -34,10 +31,10 @@ describe('RangeFilterManager', function () { return fields[name]; }, }, - } as IndexPattern; + } as DataView; const indexPatternsServiceMock = { get: jest.fn().mockReturnValue(Promise.resolve(indexPatternMock)), - } as unknown as jest.Mocked; + } as unknown as jest.Mocked; const queryFilterMock: QueryFilterManager = {} as QueryFilterManager; let filterManager: RangeFilterManager; beforeEach(async () => { @@ -70,7 +67,7 @@ describe('RangeFilterManager', function () { id: string, fieldName: string, indexPatternId: string, - indexPatternsService: IndexPatternsContract, + indexPatternsService: DataViewsContract, queryFilter: QueryFilterManager ) { super(id, fieldName, indexPatternId, indexPatternsService, queryFilter); @@ -86,7 +83,7 @@ describe('RangeFilterManager', function () { } } - const indexPatternsServiceMock = {} as IndexPatternsContract; + const indexPatternsServiceMock = {} as DataViewsContract; const queryFilterMock: QueryFilterManager = {} as QueryFilterManager; let filterManager: MockFindFiltersRangeFilterManager; beforeEach(() => { diff --git a/src/plugins/input_control_vis/public/control/list_control_factory.ts b/src/plugins/input_control_vis/public/control/list_control_factory.ts index 39c5f259c2735..f6bd0bc0cd28a 100644 --- a/src/plugins/input_control_vis/public/control/list_control_factory.ts +++ b/src/plugins/input_control_vis/public/control/list_control_factory.ts @@ -9,11 +9,11 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { - IndexPatternField, TimefilterContract, SerializedSearchSourceFields, DataPublicPluginStart, } from 'src/plugins/data/public'; +import { DataViewField } from '../../../data_views/public'; import { Control, noValuesDisableMsg, noIndexPatternMsg } from './control'; import { PhraseFilterManager } from './filter_manager/phrase_filter_manager'; import { createSearchSource } from './create_search_source'; @@ -26,7 +26,7 @@ function getEscapedQuery(query = '') { } interface TermsAggArgs { - field?: IndexPatternField; + field?: DataViewField; size: number | null; direction: string; query?: string; diff --git a/src/plugins/input_control_vis/public/control/range_control_factory.ts b/src/plugins/input_control_vis/public/control/range_control_factory.ts index 906762266a7b3..6cd477d28b4f6 100644 --- a/src/plugins/input_control_vis/public/control/range_control_factory.ts +++ b/src/plugins/input_control_vis/public/control/range_control_factory.ts @@ -9,18 +9,15 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { - IndexPatternField, - TimefilterContract, - DataPublicPluginStart, -} from 'src/plugins/data/public'; +import { TimefilterContract, DataPublicPluginStart } from 'src/plugins/data/public'; +import { DataViewField } from '../../../data_views/public'; import { Control, noValuesDisableMsg, noIndexPatternMsg } from './control'; import { RangeFilterManager } from './filter_manager/range_filter_manager'; import { createSearchSource } from './create_search_source'; import { ControlParams } from '../editor_utils'; import { InputControlVisDependencies } from '../plugin'; -const minMaxAgg = (field?: IndexPatternField) => { +const minMaxAgg = (field?: DataViewField) => { const aggBody: any = {}; if (field) { if (field.scripted) { diff --git a/src/plugins/input_control_vis/public/test_utils/get_index_pattern_mock.ts b/src/plugins/input_control_vis/public/test_utils/get_index_pattern_mock.ts index 122800198f092..40f01b05d18b2 100644 --- a/src/plugins/input_control_vis/public/test_utils/get_index_pattern_mock.ts +++ b/src/plugins/input_control_vis/public/test_utils/get_index_pattern_mock.ts @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { IndexPattern } from 'src/plugins/data/public'; +import { DataView } from 'src/plugins/data_views/public'; /** * Returns forced **Partial** IndexPattern for use in tests */ -export const getIndexPatternMock = (): Promise => { +export const getIndexPatternMock = (): Promise => { return Promise.resolve({ id: 'mockIndexPattern', title: 'mockIndexPattern', @@ -20,5 +20,5 @@ export const getIndexPatternMock = (): Promise => { { name: 'textField', type: 'string', aggregatable: false }, { name: 'numberField', type: 'number', aggregatable: true }, ], - } as IndexPattern); + } as DataView); }; diff --git a/src/plugins/input_control_vis/public/vis_controller.tsx b/src/plugins/input_control_vis/public/vis_controller.tsx index bb09a90bb9dd6..51c88962f3cb2 100644 --- a/src/plugins/input_control_vis/public/vis_controller.tsx +++ b/src/plugins/input_control_vis/public/vis_controller.tsx @@ -13,8 +13,9 @@ import { Subscription } from 'rxjs'; import { I18nStart } from 'kibana/public'; import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { Filter } from '@kbn/es-query'; import { VisualizationContainer } from '../../visualizations/public'; -import { FilterManager, Filter } from '../../data/public'; +import { FilterManager } from '../../data/public'; import { InputControlVis } from './components/vis/input_control_vis'; import { getControlFactory } from './control/control_factory'; diff --git a/src/plugins/input_control_vis/tsconfig.json b/src/plugins/input_control_vis/tsconfig.json index 5e53199bb1e6e..43b1539e87da2 100644 --- a/src/plugins/input_control_vis/tsconfig.json +++ b/src/plugins/input_control_vis/tsconfig.json @@ -13,6 +13,7 @@ "references": [ { "path": "../kibana_react/tsconfig.json" }, { "path": "../data/tsconfig.json"}, + { "path": "../data_views/tsconfig.json"}, { "path": "../expressions/tsconfig.json" }, { "path": "../visualizations/tsconfig.json" }, { "path": "../vis_default_editor/tsconfig.json" }, diff --git a/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx b/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx index 4c71581fcb0bf..59710cbcff616 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx @@ -239,6 +239,7 @@ export class VisEditor extends Component ({ async fn( input, args, - { getSearchSessionId, isSyncColorsEnabled, getExecutionContext, inspectorAdapters } + { + getSearchSessionId, + isSyncColorsEnabled, + getExecutionContext, + inspectorAdapters, + abortSignal: expressionAbortSignal, + } ) { const visParams: TimeseriesVisParams = JSON.parse(args.params); const uiState = JSON.parse(args.uiState); @@ -70,6 +76,7 @@ export const createMetricsFn = (): TimeseriesExpressionFunctionDefinition => ({ searchSessionId: getSearchSessionId(), executionContext: getExecutionContext(), inspectorAdapters, + expressionAbortSignal, }); return { diff --git a/src/plugins/vis_types/timeseries/public/request_handler.ts b/src/plugins/vis_types/timeseries/public/request_handler.ts index bb15f32886cdc..dcb1b0691602d 100644 --- a/src/plugins/vis_types/timeseries/public/request_handler.ts +++ b/src/plugins/vis_types/timeseries/public/request_handler.ts @@ -22,6 +22,7 @@ interface MetricsRequestHandlerParams { searchSessionId?: string; executionContext?: KibanaExecutionContext; inspectorAdapters?: Adapters; + expressionAbortSignal: AbortSignal; } export const metricsRequestHandler = async ({ @@ -31,63 +32,72 @@ export const metricsRequestHandler = async ({ searchSessionId, executionContext, inspectorAdapters, + expressionAbortSignal, }: MetricsRequestHandlerParams): Promise => { - const config = getUISettings(); - const data = getDataStart(); - const theme = getCoreStart().theme; + if (!expressionAbortSignal.aborted) { + const config = getUISettings(); + const data = getDataStart(); + const theme = getCoreStart().theme; + const abortController = new AbortController(); + const expressionAbortHandler = function () { + abortController.abort(); + }; - const timezone = getTimezone(config); - const uiStateObj = uiState[visParams.type] ?? {}; - const dataSearch = data.search; - const parsedTimeRange = data.query.timefilter.timefilter.calculateBounds(input?.timeRange!); + expressionAbortSignal.addEventListener('abort', expressionAbortHandler); - if (visParams && visParams.id && !visParams.isModelInvalid) { - const untrackSearch = - dataSearch.session.isCurrentSession(searchSessionId) && - dataSearch.session.trackSearch({ - abort: () => { - // TODO: support search cancellations - }, - }); + const timezone = getTimezone(config); + const uiStateObj = uiState[visParams.type] ?? {}; + const dataSearch = data.search; + const parsedTimeRange = data.query.timefilter.timefilter.calculateBounds(input?.timeRange!); - try { - const searchSessionOptions = dataSearch.session.getSearchOptions(searchSessionId); + if (visParams && visParams.id && !visParams.isModelInvalid && !expressionAbortSignal.aborted) { + const untrackSearch = + dataSearch.session.isCurrentSession(searchSessionId) && + dataSearch.session.trackSearch({ + abort: () => abortController.abort(), + }); - const visData: TimeseriesVisData = await getCoreStart().http.post(ROUTES.VIS_DATA, { - body: JSON.stringify({ - timerange: { - timezone, - ...parsedTimeRange, - }, - query: input?.query, - filters: input?.filters, - panels: [visParams], - state: uiStateObj, - ...(searchSessionOptions && { - searchSession: searchSessionOptions, + try { + const searchSessionOptions = dataSearch.session.getSearchOptions(searchSessionId); + + const visData: TimeseriesVisData = await getCoreStart().http.post(ROUTES.VIS_DATA, { + body: JSON.stringify({ + timerange: { + timezone, + ...parsedTimeRange, + }, + query: input?.query, + filters: input?.filters, + panels: [visParams], + state: uiStateObj, + ...(searchSessionOptions && { + searchSession: searchSessionOptions, + }), }), - }), - context: executionContext, - }); + context: executionContext, + signal: abortController.signal, + }); - inspectorAdapters?.requests?.reset(); + inspectorAdapters?.requests?.reset(); - Object.entries(visData.trackedEsSearches || {}).forEach(([key, query]) => { - inspectorAdapters?.requests - ?.start(query.label ?? key, { searchSessionId }) - .json(query.body) - .ok({ time: query.time }); + Object.entries(visData.trackedEsSearches || {}).forEach(([key, query]) => { + inspectorAdapters?.requests + ?.start(query.label ?? key, { searchSessionId }) + .json(query.body) + .ok({ time: query.time }); - if (query.response && config.get(UI_SETTINGS.ALLOW_CHECKING_FOR_FAILED_SHARDS)) { - handleResponse({ body: query.body }, { rawResponse: query.response }, theme); - } - }); + if (query.response && config.get(UI_SETTINGS.ALLOW_CHECKING_FOR_FAILED_SHARDS)) { + handleResponse({ body: query.body }, { rawResponse: query.response }, theme); + } + }); - return visData; - } finally { - if (untrackSearch && dataSearch.session.isCurrentSession(searchSessionId)) { - // untrack if this search still belongs to current session - untrackSearch(); + return visData; + } finally { + if (untrackSearch && dataSearch.session.isCurrentSession(searchSessionId)) { + // untrack if this search still belongs to current session + untrackSearch(); + } + expressionAbortSignal.removeEventListener('abort', expressionAbortHandler); } } } diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts index 1a52132612f71..f52d1bd9b7427 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts @@ -7,8 +7,8 @@ */ import { IndexPatternsService } from '../../../../../../data/common'; - import { from } from 'rxjs'; + import { AbstractSearchStrategy, EsSearchRequest } from './abstract_search_strategy'; import type { FieldSpec } from '../../../../../../data/common'; import type { CachedIndexPatternFetcher } from '../lib/cached_index_pattern_fetcher'; @@ -76,6 +76,9 @@ describe('AbstractSearchStrategy', () => { isStored: true, }, }, + events: { + aborted$: from([]), + }, } as unknown as VisTypeTimeseriesVisDataRequest, searches ); @@ -90,6 +93,7 @@ describe('AbstractSearchStrategy', () => { indexType: undefined, }, { + abortSignal: new AbortController().signal, sessionId: '1', isRestore: false, isStored: true, diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index 1d3650ccedbd3..58c67f84a9373 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -5,9 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - import { tap } from 'rxjs/operators'; import { omit } from 'lodash'; +import type { Observable } from 'rxjs'; import { IndexPatternsService } from '../../../../../../data/server'; import { toSanitizedFieldType } from '../../../../common/fields_utils'; @@ -27,6 +27,12 @@ export interface EsSearchRequest { }; } +function getRequestAbortedSignal(aborted$: Observable): AbortSignal { + const controller = new AbortController(); + aborted$.subscribe(() => controller.abort()); + return controller.signal; +} + export abstract class AbstractSearchStrategy { async search( requestContext: VisTypeTimeseriesRequestHandlerContext, @@ -37,6 +43,10 @@ export abstract class AbstractSearchStrategy { ) { const requests: any[] = []; + // User may abort the request without waiting for the results + // we need to handle this scenario by aborting underlying server requests + const abortSignal = getRequestAbortedSignal(req.events.aborted$); + esRequests.forEach(({ body, index, trackingEsSearchMeta }) => { const startTime = Date.now(); requests.push( @@ -49,7 +59,7 @@ export abstract class AbstractSearchStrategy { index, }, }, - req.body.searchSession + { ...req.body.searchSession, abortSignal } ) .pipe( tap((data) => { diff --git a/src/plugins/visualizations/kibana.json b/src/plugins/visualizations/kibana.json index c7fd9c977bc2e..79b04f132077b 100644 --- a/src/plugins/visualizations/kibana.json +++ b/src/plugins/visualizations/kibana.json @@ -12,9 +12,10 @@ "embeddable", "inspector", "savedObjects", + "screenshotMode", "presentationUtil" ], - "optionalPlugins": [ "home", "share", "usageCollection", "spaces", "savedObjectsTaggingOss"], + "optionalPlugins": ["home", "share", "usageCollection", "spaces", "savedObjectsTaggingOss"], "requiredBundles": ["kibanaUtils", "discover", "kibanaReact", "home"], "extraPublicDirs": ["common/constants", "common/utils", "common/expression_functions"], "owner": { diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index 0fc142aeead63..69a7c61e68893 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -23,6 +23,7 @@ import { urlForwardingPluginMock } from '../../../plugins/url_forwarding/public/ import { navigationPluginMock } from '../../../plugins/navigation/public/mocks'; import { presentationUtilPluginMock } from '../../../plugins/presentation_util/public/mocks'; import { savedObjectTaggingOssPluginMock } from '../../saved_objects_tagging_oss/public/mocks'; +import { screenshotModePluginMock } from '../../screenshot_mode/public/mocks'; const createSetupContract = (): VisualizationsSetup => ({ createBaseVisualization: jest.fn(), @@ -68,6 +69,7 @@ const createInstance = async () => { navigation: navigationPluginMock.createStartContract(), presentationUtil: presentationUtilPluginMock.createStartContract(coreMock.createStart()), urlForwarding: urlForwardingPluginMock.createStartContract(), + screenshotMode: screenshotModePluginMock.createStartContract(), }); return { diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index c8c4d57543a02..92bcf1dfe6a96 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -86,6 +86,7 @@ import type { SharePluginSetup, SharePluginStart } from '../../share/public'; import type { UrlForwardingSetup, UrlForwardingStart } from '../../url_forwarding/public'; import type { PresentationUtilPluginStart } from '../../presentation_util/public'; import type { UsageCollectionStart } from '../../usage_collection/public'; +import type { ScreenshotModePluginStart } from '../../screenshot_mode/public'; import type { HomePublicPluginSetup } from '../../home/public'; import type { SpacesPluginStart } from '../../../../x-pack/plugins/spaces/public'; @@ -130,6 +131,7 @@ export interface VisualizationsStartDeps { share?: SharePluginStart; urlForwarding: UrlForwardingStart; usageCollection?: UsageCollectionStart; + screenshotMode: ScreenshotModePluginStart; } /** @@ -289,6 +291,11 @@ export class VisualizationsPlugin params.element.classList.add('visAppWrapper'); const { renderApp } = await import('./visualize_app'); + if (pluginsStart.screenshotMode.isScreenshotMode()) { + params.element.classList.add('visEditorScreenshotModeActive'); + // @ts-expect-error TS error, cannot find type declaration for scss + await import('./visualize_screenshot_mode.scss'); + } const unmount = renderApp(params, services); return () => { data.search.session.clear(); diff --git a/src/plugins/visualizations/public/visualize_screenshot_mode.scss b/src/plugins/visualizations/public/visualize_screenshot_mode.scss new file mode 100644 index 0000000000000..b0a8bb35835bd --- /dev/null +++ b/src/plugins/visualizations/public/visualize_screenshot_mode.scss @@ -0,0 +1,60 @@ +/* hide unusable controls */ +/* TODO: This is the legacy way of hiding chrome elements. Rather use chrome.setIsVisible */ +kbn-top-nav, +filter-bar, +.kbnTopNavMenu__wrapper, +::-webkit-scrollbar, +.euiNavDrawer { + display: none !important; +} + +/* hide unusable controls +* !important is required to override resizable panel inline display */ +.visEditorScreenshotModeActive .visEditor__content .visEditor--default > :not(.visEditor__visualization__wrapper) { + display: none !important; +} + +/** THIS IS FOR TSVB UNTIL REFACTOR **/ +.visEditorScreenshotModeActive .tvbEditorVisualization { + position: static !important; +} +.visEditorScreenshotModeActive .visualize .tvbVisTimeSeries__legendToggle { + /* all non-content rows in interface */ + display: none; +} + +.visEditorScreenshotModeActive .tvbEditor--hideForReporting { + /* all non-content rows in interface */ + display: none; +} +/** END TSVB BAD BAD HACKS **/ + +/* remove left padding from visualizations so that map lines up with .leaflet-container and +* setting the position to be fixed and to take up the entire screen, because some zoom levels/viewports +* are triggering the media breakpoints that cause the .visEditor__canvas to take up more room than the viewport */ + +.visEditorScreenshotModeActive .visEditor .visEditor__canvas { + padding-left: 0; + position: fixed; + width: 100%; + height: 100%; + top: 0; + left: 0; +} + +/** + * Visualization tweaks + */ + +/* hide unusable controls */ +.visEditorScreenshotModeActive .visualize .visLegend__toggle, +.visEditorScreenshotModeActive .visualize .kbnAggTable__controls, +.visEditorScreenshotModeActive .visualize .leaflet-container .leaflet-top.leaflet-left, +.visEditorScreenshotModeActive .visualize paginate-controls /* page numbers */ { + display: none; +} + +/* Ensure the min-height of the small breakpoint isn't used */ +.visEditorScreenshotModeActive .vis-editor visualization { + min-height: 0 !important; +} diff --git a/x-pack/plugins/alerting/server/mocks.ts b/x-pack/plugins/alerting/server/mocks.ts index d58630e18cd4d..a94b30b59104c 100644 --- a/x-pack/plugins/alerting/server/mocks.ts +++ b/x-pack/plugins/alerting/server/mocks.ts @@ -11,6 +11,7 @@ import { Alert, AlertFactoryDoneUtils } from './alert'; import { elasticsearchServiceMock, savedObjectsClientMock, + uiSettingsServiceMock, } from '../../../../src/core/server/mocks'; import { AlertInstanceContext, AlertInstanceState } from './types'; @@ -105,6 +106,7 @@ const createAlertServicesMock = < done: jest.fn().mockReturnValue(alertFactoryMockDone), }, savedObjectsClient: savedObjectsClientMock.create(), + uiSettingsClient: uiSettingsServiceMock.createClient(), scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), shouldWriteAlerts: () => true, shouldStopExecution: () => true, diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 760aa6e0050a9..939068e23e2b4 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -384,6 +384,7 @@ export class AlertingPlugin { taskRunnerFactory.initialize({ logger, savedObjects: core.savedObjects, + uiSettings: core.uiSettings, elasticsearch: core.elasticsearch, getRulesClientWithRequest, spaceIdToNamespace, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 99feefb472df1..bdebc66911e94 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -30,6 +30,7 @@ import { executionContextServiceMock, savedObjectsServiceMock, elasticsearchServiceMock, + uiSettingsServiceMock, } from '../../../../../src/core/server/mocks'; import { PluginStartContract as ActionsPluginStart } from '../../../actions/server'; import { actionsMock, actionsClientMock } from '../../../actions/server/mocks'; @@ -95,6 +96,7 @@ describe('Task Runner', () => { const ruleTypeRegistry = ruleTypeRegistryMock.create(); const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); const elasticsearchService = elasticsearchServiceMock.createInternalStart(); + const uiSettingsService = uiSettingsServiceMock.createStartContract(); type TaskRunnerFactoryInitializerParamsType = jest.Mocked & { actionsPlugin: jest.Mocked; @@ -106,6 +108,7 @@ describe('Task Runner', () => { const taskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType = { savedObjects: savedObjectsService, + uiSettings: uiSettingsService, elasticsearch: elasticsearchService, actionsPlugin: actionsMock.createStart(), getRulesClientWithRequest: jest.fn().mockReturnValue(rulesClient), diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index dbc7749a0fbdf..c05bdc3cf7bd9 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -352,14 +352,17 @@ export class TaskRunner< }] namespace`, }; + const savedObjectsClient = this.context.savedObjects.getScopedClient(fakeRequest, { + includedHiddenTypes: ['alert', 'action'], + }); + updatedRuleTypeState = await this.context.executionContext.withContext(ctx, () => this.ruleType.executor({ alertId: ruleId, executionId: this.executionId, services: { - savedObjectsClient: this.context.savedObjects.getScopedClient(fakeRequest, { - includedHiddenTypes: ['alert', 'action'], - }), + savedObjectsClient, + uiSettingsClient: this.context.uiSettings.asScopedToClient(savedObjectsClient), scopedClusterClient: wrappedScopedClusterClient.client(), alertFactory: createAlertFactory< InstanceState, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts index d70b36ff48a8f..add8d7a24912d 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts @@ -25,6 +25,7 @@ import { executionContextServiceMock, savedObjectsServiceMock, elasticsearchServiceMock, + uiSettingsServiceMock, } from '../../../../../src/core/server/mocks'; import { PluginStartContract as ActionsPluginStart } from '../../../actions/server'; import { actionsMock, actionsClientMock } from '../../../actions/server/mocks'; @@ -94,6 +95,7 @@ describe('Task Runner Cancel', () => { const ruleTypeRegistry = ruleTypeRegistryMock.create(); const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); const elasticsearchService = elasticsearchServiceMock.createInternalStart(); + const uiSettingsService = uiSettingsServiceMock.createStartContract(); type TaskRunnerFactoryInitializerParamsType = jest.Mocked & { actionsPlugin: jest.Mocked; @@ -103,6 +105,7 @@ describe('Task Runner Cancel', () => { const taskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType = { savedObjects: savedObjectsService, + uiSettings: uiSettingsService, elasticsearch: elasticsearchService, actionsPlugin: actionsMock.createStart(), getRulesClientWithRequest: jest.fn().mockReturnValue(rulesClient), diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts index 6dea8df475503..d4e92015d4112 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -16,6 +16,7 @@ import { httpServiceMock, savedObjectsServiceMock, elasticsearchServiceMock, + uiSettingsServiceMock, } from '../../../../../src/core/server/mocks'; import { actionsMock } from '../../../actions/server/mocks'; import { rulesClientMock } from '../mocks'; @@ -28,6 +29,7 @@ const executionContext = executionContextServiceMock.createSetupContract(); const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); +const uiSettingsService = uiSettingsServiceMock.createStartContract(); const elasticsearchService = elasticsearchServiceMock.createInternalStart(); const ruleType: UntypedNormalizedRuleType = { id: 'test', @@ -77,6 +79,7 @@ describe('Task Runner Factory', () => { const taskRunnerFactoryInitializerParams: jest.Mocked = { savedObjects: savedObjectsService, + uiSettings: uiSettingsService, elasticsearch: elasticsearchService, getRulesClientWithRequest: jest.fn().mockReturnValue(rulesClient), actionsPlugin: actionsMock.createStart(), diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts index f60370dd7daf7..0b8ffe2f93d7b 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -15,6 +15,7 @@ import type { ExecutionContextStart, SavedObjectsServiceStart, ElasticsearchServiceStart, + UiSettingsServiceStart, } from '../../../../../src/core/server'; import { RunContext } from '../../../task_manager/server'; import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server'; @@ -35,6 +36,7 @@ import { NormalizedRuleType } from '../rule_type_registry'; export interface TaskRunnerContext { logger: Logger; savedObjects: SavedObjectsServiceStart; + uiSettings: UiSettingsServiceStart; elasticsearch: ElasticsearchServiceStart; getRulesClientWithRequest(request: KibanaRequest): PublicMethodsOf; actionsPlugin: ActionsPluginStartContract; diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 95c1a07e241b2..1642cc13d4dec 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -11,6 +11,7 @@ import type { RequestHandlerContext, SavedObjectReference, ElasticsearchClient, + IUiSettingsClient, } from 'src/core/server'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { AlertFactoryDoneUtils, PublicAlert } from './alert'; @@ -78,6 +79,7 @@ export interface AlertServices< ActionGroupIds extends string = never > { savedObjectsClient: SavedObjectsClientContract; + uiSettingsClient: IUiSettingsClient; scopedClusterClient: IScopedClusterClient; alertFactory: { create: (id: string) => PublicAlert; diff --git a/x-pack/plugins/apm/dev_docs/local_setup.md b/x-pack/plugins/apm/dev_docs/local_setup.md index 19864abd795ba..b3c9d0acbaebc 100644 --- a/x-pack/plugins/apm/dev_docs/local_setup.md +++ b/x-pack/plugins/apm/dev_docs/local_setup.md @@ -21,7 +21,7 @@ yarn es snapshot **Create APM mappings** ``` -node ./scripts/es_archiver load "x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_mappings_only_8.0.0" --es-url=http://elastic:changeme@localhost:9200 --kibana-url=http://elastic:changeme@localhost:5601 --config=./test/functional/config.js +node ./scripts/es_archiver load "x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_mappings_only_8.0.0" --es-url=http://system_indices_superuser:changeme@localhost:9200 --kibana-url=http://elastic:changeme@localhost:5601 --config=./test/functional/config.js ``` *Note: Elasticsearch must be available before running the above command* diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index 8523c4b5757d4..84c62e62c3351 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -5,11 +5,15 @@ * 2.0. */ -export const CSP_KUBEBEAT_INDEX_PATTERN = 'logs-k8s_cis*'; -export const CSP_FINDINGS_INDEX_NAME = 'findings'; export const STATS_ROUTE_PATH = '/api/csp/stats'; export const FINDINGS_ROUTE_PATH = '/api/csp/findings'; -export const AGENT_LOGS_INDEX_PATTERN = '.logs-k8s_cis.metadata*'; +export const BENCHMARKS_ROUTE_PATH = '/api/csp/benchmarks'; + +export const CSP_KUBEBEAT_INDEX_PATTERN = 'logs-cis_kubernetes_benchmark.findings*'; +export const AGENT_LOGS_INDEX_PATTERN = '.logs-cis_kubernetes_benchmark.metadata*'; + +export const CSP_FINDINGS_INDEX_NAME = 'findings'; +export const CIS_KUBERNETES_PACKAGE_NAME = 'cis_kubernetes_benchmark'; export const RULE_PASSED = `passed`; export const RULE_FAILED = `failed`; @@ -17,5 +21,9 @@ export const RULE_FAILED = `failed`; // A mapping of in-development features to their status. These features should be hidden from users but can be easily // activated via a simple code change in a single location. export const INTERNAL_FEATURE_FLAGS = { - benchmarks: false, + showBenchmarks: false, + showTrendLineMock: false, + showClusterMetaMock: false, + showManageRulesMock: false, + showRisksMock: false, } as const; diff --git a/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule.ts b/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule.ts new file mode 100644 index 0000000000000..d5c8e9fab1f2e --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema as rt, TypeOf } from '@kbn/config-schema'; + +export const cspRuleAssetSavedObjectType = 'csp_rule'; + +// TODO: needs to be shared with kubebeat +export const cspRuleSchema = rt.object({ + id: rt.string(), + name: rt.string(), + description: rt.string(), + rationale: rt.string(), + impact: rt.string(), + default_value: rt.string(), + remediation: rt.string(), + benchmark: rt.object({ name: rt.string(), version: rt.string() }), + tags: rt.arrayOf(rt.string()), + enabled: rt.boolean(), + muted: rt.boolean(), +}); + +export type CspRuleSchema = TypeOf; diff --git a/x-pack/plugins/cloud_security_posture/common/types.ts b/x-pack/plugins/cloud_security_posture/common/types.ts index aa6b1d5bc9854..435b9836c5754 100644 --- a/x-pack/plugins/cloud_security_posture/common/types.ts +++ b/x-pack/plugins/cloud_security_posture/common/types.ts @@ -9,25 +9,31 @@ export type Evaluation = 'passed' | 'failed' | 'NA'; /** number between 1-100 */ export type Score = number; -export interface FindingsResults { +export interface FindingsEvaluation { totalFindings: number; totalPassed: number; totalFailed: number; } -export interface Stats extends FindingsResults { +export interface Stats extends FindingsEvaluation { postureScore: Score; } -export interface ResourceTypeAgg extends FindingsResults { - resourceType: string; +export interface ResourceType extends FindingsEvaluation { + name: string; } -export interface BenchmarkStats extends Stats { - name: string; +export interface Cluster { + meta: { + clusterId: string; + benchmarkName: string; + }; + stats: Stats; + resourcesTypes: ResourceType[]; } -export interface CloudPostureStats extends Stats { - benchmarksStats: BenchmarkStats[]; - resourceTypesAggs: ResourceTypeAgg[]; +export interface CloudPostureStats { + stats: Stats; + resourcesTypes: ResourceType[]; + clusters: Cluster[]; } diff --git a/x-pack/plugins/cloud_security_posture/kibana.json b/x-pack/plugins/cloud_security_posture/kibana.json index 67143c15e2b7b..29f3813c211c7 100755 --- a/x-pack/plugins/cloud_security_posture/kibana.json +++ b/x-pack/plugins/cloud_security_posture/kibana.json @@ -10,6 +10,6 @@ "description": "The cloud security posture plugin", "server": true, "ui": true, - "requiredPlugins": ["navigation", "data"], + "requiredPlugins": ["navigation", "data", "fleet"], "requiredBundles": ["kibanaReact"] } diff --git a/x-pack/plugins/cloud_security_posture/public/application/constants.tsx b/x-pack/plugins/cloud_security_posture/public/application/constants.tsx index b592a30aeb2b4..128382d039f15 100644 --- a/x-pack/plugins/cloud_security_posture/public/application/constants.tsx +++ b/x-pack/plugins/cloud_security_posture/public/application/constants.tsx @@ -12,4 +12,5 @@ export const pageToComponentMapping: Record = findings: pages.Findings, dashboard: pages.ComplianceDashboard, benchmarks: pages.Benchmarks, + rules: pages.Rules, }; diff --git a/x-pack/plugins/cloud_security_posture/public/application/index.tsx b/x-pack/plugins/cloud_security_posture/public/application/index.tsx index 6530483bf2198..38fbef254ea44 100644 --- a/x-pack/plugins/cloud_security_posture/public/application/index.tsx +++ b/x-pack/plugins/cloud_security_posture/public/application/index.tsx @@ -7,8 +7,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; import { CspApp } from './app'; - import type { AppMountParameters, CoreStart } from '../../../../../src/core/public'; import type { CspClientPluginStartDeps } from '../types'; @@ -17,7 +17,12 @@ export const renderApp = ( deps: CspClientPluginStartDeps, params: AppMountParameters ) => { - ReactDOM.render(, params.element); + ReactDOM.render( + + + , + params.element + ); return () => ReactDOM.unmountComponentAtNode(params.element); }; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_kibana.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_kibana.ts new file mode 100644 index 0000000000000..b2d1d2a7b6bbe --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_kibana.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CoreStart } from '../../../../../../src/core/public'; +import type { CspClientPluginStartDeps } from '../../types'; +import { useKibana as useKibanaBase } from '../../../../../../src/plugins/kibana_react/public'; + +type CspKibanaContext = CoreStart & CspClientPluginStartDeps; + +export const useKibana = () => useKibanaBase(); diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts new file mode 100644 index 0000000000000..6cc1582d7ff7b --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useHistory } from 'react-router-dom'; +import { Query } from '@kbn/es-query'; +import { allNavigationItems } from '../navigation/constants'; +import { encodeQuery } from '../navigation/query_utils'; +import { CspFindingsRequest } from '../../pages/findings/use_findings'; + +const getFindingsQuery = (queryValue: Query['query']): Pick => { + const query = + typeof queryValue === 'string' + ? queryValue + : // TODO: use a tested query builder instead ASAP + Object.entries(queryValue) + .reduce((a, [key, value]) => { + a.push(`${key} : "${value}"`); + return a; + }, []) + .join(' and '); + + return { + query: { + language: 'kuery', + // NOTE: a query object is valid TS but throws on runtime + query, + }, + }!; +}; + +export const useNavigateFindings = () => { + const history = useHistory(); + + return (query?: Query['query']) => { + history.push({ + pathname: allNavigationItems.findings.path, + ...(query && { search: encodeQuery(getFindingsQuery(query)) }), + }); + }; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts b/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts index bde28fa1ce3b5..4e07a4c800f53 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts @@ -12,9 +12,10 @@ import type { CspPage, CspNavigationItem } from './types'; export const allNavigationItems: Record = { dashboard: { name: TEXT.DASHBOARD, path: '/dashboard' }, findings: { name: TEXT.FINDINGS, path: '/findings' }, + rules: { name: 'Rules', path: '/rules', disabled: true }, benchmarks: { name: TEXT.MY_BENCHMARKS, path: '/benchmarks', - disabled: !INTERNAL_FEATURE_FLAGS.benchmarks, + disabled: !INTERNAL_FEATURE_FLAGS.showBenchmarks, }, }; diff --git a/x-pack/plugins/cloud_security_posture/public/common/navigation/types.ts b/x-pack/plugins/cloud_security_posture/public/common/navigation/types.ts index 64db2e59b667f..87f62b88ba171 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/navigation/types.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/navigation/types.ts @@ -10,4 +10,4 @@ export interface CspNavigationItem { readonly disabled?: boolean; } -export type CspPage = 'dashboard' | 'findings' | 'benchmarks'; +export type CspPage = 'dashboard' | 'findings' | 'benchmarks' | 'rules'; diff --git a/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx b/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx index 5190f71a1721a..2b0882d0916e6 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx @@ -6,7 +6,6 @@ */ import React from 'react'; -import { css } from '@emotion/react'; import { EuiPanel, EuiText, @@ -64,7 +63,7 @@ export const ChartPanel: React.FC = ({ {title && ( - +

{title}

)} @@ -74,7 +73,3 @@ export const ChartPanel: React.FC = ({ ); }; - -const euiTitleStyle = css` - font-weight: 400; -`; diff --git a/x-pack/plugins/cloud_security_posture/public/components/csp_evaluation_badge.tsx b/x-pack/plugins/cloud_security_posture/public/components/csp_evaluation_badge.tsx index 8603bef59122e..93aa87c18a9b8 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/csp_evaluation_badge.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/csp_evaluation_badge.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiBadge, EuiBadgeProps } from '@elastic/eui'; +import { EuiBadge, type EuiBadgeProps } from '@elastic/eui'; import { CSP_EVALUATION_BADGE_FAILED, CSP_EVALUATION_BADGE_PASSED } from './translations'; interface Props { diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cases_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cases_table.tsx new file mode 100644 index 0000000000000..30107d6689752 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cases_table.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { EuiIcon } from '@elastic/eui'; +import * as TEXT from '../translations'; + +export const CasesTable = () => { + return ( + + + + + + {TEXT.COMING_SOON} + + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx index 01dfd837fca2f..a1f044241e5d3 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx @@ -7,18 +7,23 @@ import React from 'react'; import { + AreaSeries, + Axis, Chart, ElementClickListener, + niceTimeFormatByDay, Partition, PartitionElementEvent, PartitionLayout, Settings, + timeFormatter, } from '@elastic/charts'; import { EuiFlexGroup, EuiText, EuiHorizontalRule, EuiFlexItem } from '@elastic/eui'; import { statusColors } from '../../../common/constants'; import type { Stats } from '../../../../common/types'; import * as TEXT from '../translations'; import { CompactFormattedNumber } from '../../../components/compact_formatted_number'; +import { INTERNAL_FEATURE_FLAGS } from '../../../../common/constants'; interface CloudPostureScoreChartProps { data: Stats; @@ -37,7 +42,7 @@ const ScoreChart = ({ ]; return ( - +
Trend Placeholder
; +const mockData = [ + [0, 9], + [1000, 70], + [2000, 40], + [4000, 90], + [5000, 53], +]; + +const ComplianceTrendChart = () => ( + + + + + + +); export const CloudPostureScoreChart = ({ data, @@ -97,8 +124,8 @@ export const CloudPostureScoreChart = ({ }: CloudPostureScoreChartProps) => ( - - + + @@ -106,7 +133,7 @@ export const CloudPostureScoreChart = ({ - + diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/compliance_stats.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/compliance_stats.tsx deleted file mode 100644 index 1dff4aba203b9..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/compliance_stats.tsx +++ /dev/null @@ -1,158 +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 { - EuiStat, - EuiFlexItem, - EuiPanel, - EuiIcon, - EuiFlexGrid, - EuiText, - // EuiFlexGroup, -} from '@elastic/eui'; -// import { Chart, Settings, LineSeries } from '@elastic/charts'; -import type { IconType, EuiStatProps } from '@elastic/eui'; -import { useCloudPostureStatsApi } from '../../../common/api'; -import { statusColors } from '../../../common/constants'; -import { Score } from '../../../../common/types'; -import * as TEXT from '../translations'; -import { NO_DATA_TO_DISPLAY } from '../translations'; - -// type Trend = Array<[time: number, value: number]>; - -// TODO: this is the warning color hash listen in EUI's docs. need to find where to import it from. - -const getTitleColor = (value: Score): EuiStatProps['titleColor'] => { - if (value <= 65) return 'danger'; - if (value <= 95) return statusColors.warning; - if (value <= 100) return 'success'; - return 'default'; -}; - -const getScoreIcon = (value: Score): IconType => { - if (value <= 65) return 'alert'; - if (value <= 86) return 'alert'; - if (value <= 100) return 'check'; - return 'error'; -}; - -// TODO: make score trend check for length, cases for less than 2 or more than 5 should be handled -// const getScoreTrendPercentage = (scoreTrend: Trend) => { -// const beforeLast = scoreTrend[scoreTrend.length - 2][1]; -// const last = scoreTrend[scoreTrend.length - 1][1]; -// -// return Number((last - beforeLast).toFixed(1)); -// }; - -const placeholder = ( - - {NO_DATA_TO_DISPLAY} - -); - -export const ComplianceStats = () => { - const getStats = useCloudPostureStatsApi(); - // TODO: add error/loading state - if (!getStats.isSuccess) return null; - const { postureScore, benchmarksStats: benchmarks } = getStats.data; - - // TODO: in case we dont have a full length trend we will need to handle the sparkline chart alone. not rendering anything is just a temporary solution - if (!benchmarks || !postureScore) return null; - - // TODO: mock data, needs BE - // const scoreTrend = [ - // [0, 0], - // [1, 10], - // [2, 100], - // [3, 50], - // [4, postureScore], - // ] as Trend; - // - // const scoreChange = getScoreTrendPercentage(scoreTrend); - // const isPositiveChange = scoreChange > 0; - - const stats = [ - { - title: postureScore, - description: TEXT.POSTURE_SCORE, - titleColor: getTitleColor(postureScore), - iconType: getScoreIcon(postureScore), - }, - { - // TODO: remove placeholder for the commented out component, needs BE - title: placeholder, - description: TEXT.POSTURE_SCORE_TREND, - }, - // { - // title: ( - // - // - // {`${scoreChange}%`} - // - // ), - // description: 'Posture Score Trend', - // titleColor: isPositiveChange ? 'success' : 'danger', - // renderBody: ( - // <> - // - // - // - // - // - // ), - // }, - { - // TODO: this should count only ACTIVE benchmarks. needs BE - title: benchmarks.length, - description: TEXT.ACTIVE_FRAMEWORKS, - }, - { - // TODO: should be relatively simple to return from BE. needs BE - title: placeholder, - description: TEXT.TOTAL_RESOURCES, - }, - ]; - - return ( - - {stats.map((s) => ( - - - - { - // s.renderBody || - - } - - - - ))} - - ); -}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.test.ts b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.test.ts index 0c750e10f060a..6b2c00c507e6f 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.test.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.test.ts @@ -5,45 +5,45 @@ * 2.0. */ -import { getTop5Risks, RisksTableProps } from './risks_table'; +import { getTopRisks, RisksTableProps } from './risks_table'; const podsAgg = { - resourceType: 'pods', + name: 'pods', totalFindings: 2, totalPassed: 1, totalFailed: 1, }; const etcdAgg = { - resourceType: 'etcd', + name: 'etcd', totalFindings: 5, totalPassed: 0, totalFailed: 5, }; const clusterAgg = { - resourceType: 'cluster', + name: 'cluster', totalFindings: 2, totalPassed: 2, totalFailed: 0, }; const systemAgg = { - resourceType: 'system', + name: 'system', totalFindings: 10, totalPassed: 6, totalFailed: 4, }; const apiAgg = { - resourceType: 'api', + name: 'api', totalFindings: 19100, totalPassed: 2100, totalFailed: 17000, }; const serverAgg = { - resourceType: 'server', + name: 'server', totalFindings: 7, totalPassed: 4, totalFailed: 3, @@ -58,16 +58,16 @@ const mockData: RisksTableProps['data'] = [ serverAgg, ]; -describe('getTop5Risks', () => { +describe('getTopRisks', () => { it('returns sorted by failed findings', () => { - expect(getTop5Risks([systemAgg, etcdAgg, apiAgg])).toEqual([apiAgg, etcdAgg, systemAgg]); + expect(getTopRisks([systemAgg, etcdAgg, apiAgg], 3)).toEqual([apiAgg, etcdAgg, systemAgg]); }); it('return array filtered with failed findings only', () => { - expect(getTop5Risks([systemAgg, clusterAgg, apiAgg])).toEqual([apiAgg, systemAgg]); + expect(getTopRisks([systemAgg, clusterAgg, apiAgg], 3)).toEqual([apiAgg, systemAgg]); }); - it('return sorted and filtered array with no more then 5 elements', () => { - expect(getTop5Risks(mockData)).toEqual([apiAgg, etcdAgg, systemAgg, serverAgg, podsAgg]); + it('return sorted and filtered array with the correct number of elements', () => { + expect(getTopRisks(mockData, 5)).toEqual([apiAgg, etcdAgg, systemAgg, serverAgg, podsAgg]); }); }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx index fb43a8129ed77..1e355b3f3c82f 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { EuiBasicTable, EuiButtonEmpty, @@ -14,50 +14,44 @@ import { EuiLink, EuiText, } from '@elastic/eui'; -import type { Query } from '@kbn/es-query'; -import { useHistory } from 'react-router-dom'; -import { CloudPostureStats, ResourceTypeAgg } from '../../../../common/types'; -import { allNavigationItems } from '../../../common/navigation/constants'; -import { encodeQuery } from '../../../common/navigation/query_utils'; +import { CloudPostureStats, ResourceType } from '../../../../common/types'; import { CompactFormattedNumber } from '../../../components/compact_formatted_number'; import * as TEXT from '../translations'; -import { RULE_FAILED } from '../../../../common/constants'; +import { INTERNAL_FEATURE_FLAGS } from '../../../../common/constants'; -// TODO: remove this option after we get data from the beat -const useMockData: boolean = false; -const mock = [ +const mockData = [ { - resourceType: 'pods', + name: 'pods', totalFindings: 2, totalPassed: 1, totalFailed: 1, }, { - resourceType: 'etcd', + name: 'etcd', totalFindings: 5, totalPassed: 0, totalFailed: 5, }, { - resourceType: 'cluster', + name: 'cluster', totalFindings: 2, totalPassed: 2, totalFailed: 0, }, { - resourceType: 'system', + name: 'system', totalFindings: 10, totalPassed: 6, totalFailed: 4, }, { - resourceType: 'api', + name: 'api', totalFindings: 19100, totalPassed: 2100, totalFailed: 17000, }, { - resourceType: 'server', + name: 'server', totalFindings: 7, totalPassed: 4, totalFailed: 3, @@ -65,62 +59,41 @@ const mock = [ ]; export interface RisksTableProps { - data: CloudPostureStats['resourceTypesAggs']; + data: CloudPostureStats['resourcesTypes']; + maxItems: number; + onCellClick: (resourceTypeName: string) => void; + onViewAllClick: () => void; } -const maxRisks = 5; - -export const getTop5Risks = (resourceTypesAggs: CloudPostureStats['resourceTypesAggs']) => { - const filtered = resourceTypesAggs.filter((x) => x.totalFailed > 0); +export const getTopRisks = ( + resourcesTypes: CloudPostureStats['resourcesTypes'], + maxItems: number +) => { + const filtered = resourcesTypes.filter((x) => x.totalFailed > 0); const sorted = filtered.slice().sort((first, second) => second.totalFailed - first.totalFailed); - return sorted.slice(0, maxRisks); + return sorted.slice(0, maxItems); }; -const getFailedFindingsQuery = (): Query => ({ - language: 'kuery', - query: `result.evaluation : "${RULE_FAILED}" `, -}); - -const getResourceTypeFailedFindingsQuery = (resourceType: string): Query => ({ - language: 'kuery', - query: `resource.type : "${resourceType}" and result.evaluation : "${RULE_FAILED}" `, -}); - -export const RisksTable = ({ data: resourceTypesAggs }: RisksTableProps) => { - const { push } = useHistory(); - - const handleCellClick = useCallback( - (resourceType: ResourceTypeAgg['resourceType']) => - push({ - pathname: allNavigationItems.findings.path, - search: encodeQuery(getResourceTypeFailedFindingsQuery(resourceType)), - }), - [push] - ); - - const handleViewAllClick = useCallback( - () => - push({ - pathname: allNavigationItems.findings.path, - search: encodeQuery(getFailedFindingsQuery()), - }), - [push] - ); - +export const RisksTable = ({ + data: resourcesTypes, + maxItems, + onCellClick, + onViewAllClick, +}: RisksTableProps) => { const columns = useMemo( () => [ { - field: 'resourceType', + field: 'name', name: TEXT.RESOURCE_TYPE, - render: (resourceType: ResourceTypeAgg['resourceType']) => ( - handleCellClick(resourceType)}>{resourceType} + render: (resourceTypeName: ResourceType['name']) => ( + onCellClick(resourceTypeName)}>{resourceTypeName} ), }, { field: 'totalFailed', - name: TEXT.FAILED_FINDINGS, - render: (totalFailed: ResourceTypeAgg['totalFailed'], resource: ResourceTypeAgg) => ( + name: TEXT.FINDINGS, + render: (totalFailed: ResourceType['totalFailed'], resource: ResourceType) => ( <> @@ -133,22 +106,24 @@ export const RisksTable = ({ data: resourceTypesAggs }: RisksTableProps) => { ), }, ], - [handleCellClick] + [onCellClick] ); + const items = useMemo(() => getTopRisks(resourcesTypes, maxItems), [resourcesTypes, maxItems]); + return ( - - rowHeader="resourceType" - items={useMockData ? getTop5Risks(mock) : getTop5Risks(resourceTypesAggs)} + + rowHeader="name" + items={INTERNAL_FEATURE_FLAGS.showRisksMock ? getTopRisks(mockData, maxItems) : items} columns={columns} /> - + {TEXT.VIEW_ALL_FAILED_FINDINGS} diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/score_per_account_chart.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/score_per_account_chart.tsx deleted file mode 100644 index fd47a3ecf9e43..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/score_per_account_chart.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { Axis, BarSeries, Chart, Settings } from '@elastic/charts'; -import { statusColors } from '../../../common/constants'; - -// soon to be deprecated -export const ScorePerAccountChart = () => { - return ( - - - - `${Number(v * 100).toFixed(0)}%`, - }} - id="bars" - data={[]} - xAccessor={'resource'} - yAccessors={['value']} - splitSeriesAccessors={['evaluation']} - stackAccessors={['evaluation']} - stackMode="percentage" - /> - - ); -}; - -const theme = { - colors: { vizColors: [statusColors.success, statusColors.danger] }, - barSeriesStyle: { - displayValue: { - fontSize: 14, - fill: { color: 'white', borderColor: 'blue', borderWidth: 0 }, - offsetX: 5, - offsetY: -5, - }, - }, -}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx index fcbfe47ea6d2c..c94f138616f55 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx @@ -7,27 +7,26 @@ import React from 'react'; import { - EuiFlexGrid, EuiFlexItem, EuiPanel, EuiIcon, - EuiTitle, EuiSpacer, - EuiDescriptionList, + EuiFlexGroup, + EuiText, + EuiButtonEmpty, + useEuiTheme, } from '@elastic/eui'; import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; -import { Query } from '@kbn/es-query'; -import { useHistory } from 'react-router-dom'; import { PartitionElementEvent } from '@elastic/charts'; +import { EuiThemeComputed } from '@elastic/eui/src/services/theme/types'; import { CloudPostureScoreChart } from '../compliance_charts/cloud_posture_score_chart'; -import { ComplianceTrendChart } from '../compliance_charts/compliance_trend_chart'; import { useCloudPostureStatsApi } from '../../../common/api/use_cloud_posture_stats_api'; -import { CspHealthBadge } from '../../../components/csp_health_badge'; import { ChartPanel } from '../../../components/chart_panel'; import * as TEXT from '../translations'; -import { allNavigationItems } from '../../../common/navigation/constants'; -import { encodeQuery } from '../../../common/navigation/query_utils'; import { Evaluation } from '../../../../common/types'; +import { RisksTable } from '../compliance_charts/risks_table'; +import { INTERNAL_FEATURE_FLAGS, RULE_FAILED } from '../../../../common/constants'; +import { useNavigateFindings } from '../../../common/hooks/use_navigate_findings'; const logoMap: ReadonlyMap = new Map([['CIS Kubernetes', 'logoKubernetes']]); @@ -35,118 +34,120 @@ const getBenchmarkLogo = (benchmarkName: string): EuiIconType => { return logoMap.get(benchmarkName) ?? 'logoElastic'; }; -const getBenchmarkEvaluationQuery = (name: string, evaluation: Evaluation): Query => ({ - language: 'kuery', - query: `rule.benchmark : "${name}" and result.evaluation : "${evaluation}"`, -}); +const mockClusterId = '2468540'; + +const cardHeight = 300; export const BenchmarksSection = () => { - const history = useHistory(); + const { euiTheme } = useEuiTheme(); + const navToFindings = useNavigateFindings(); const getStats = useCloudPostureStatsApi(); - const benchmarks = getStats.isSuccess && getStats.data.benchmarksStats; - if (!benchmarks) return null; + const clusters = getStats.isSuccess && getStats.data.clusters; + if (!clusters) return null; - const handleElementClick = (name: string, elements: PartitionElementEvent[]) => { + const handleElementClick = (clusterId: string, elements: PartitionElementEvent[]) => { const [element] = elements; const [layerValue] = element; - const rollupValue = layerValue[0].groupByRollup as Evaluation; + const evaluation = layerValue[0].groupByRollup as Evaluation; - history.push({ - pathname: allNavigationItems.findings.path, - search: encodeQuery(getBenchmarkEvaluationQuery(name, rollupValue)), + navToFindings({ cluster_id: clusterId, 'result.evaluation': evaluation }); + }; + + const handleCellClick = (clusterId: string, resourceTypeName: string) => { + navToFindings({ + cluster_id: clusterId, + 'resource.type': resourceTypeName, + 'result.evaluation': RULE_FAILED, }); }; + const handleViewAllClick = (clusterId: string) => { + navToFindings({ cluster_id: clusterId, 'result.evaluation': RULE_FAILED }); + }; + return ( <> - {benchmarks.map((benchmark) => ( - - - - - - -

{benchmark.name}

-
-
- - - - handleElementClick(benchmark.name, elements) - } - /> - - ), - }, - ]} - /> - - - - {/* TODO: no api for this chart yet, using empty state for now. needs BE */} - - - ), - }, - ]} - /> - - - - ) : ( - TEXT.ERROR - ), - }, - { - title: TEXT.TOTAL_FAILURES, - description: benchmark.totalFailed || TEXT.ERROR, - }, - ]} - /> - -
-
- ))} + {clusters.map((cluster) => { + const shortId = cluster.meta.clusterId.slice(0, 6); + + return ( + <> + + + + + + +

{cluster.meta.benchmarkName}

+
+ +

{`Cluster ID ${shortId || mockClusterId}`}

+
+ {INTERNAL_FEATURE_FLAGS.showClusterMetaMock && ( + + + {' Updated 7 second ago'} + + )} +
+ + + + + {INTERNAL_FEATURE_FLAGS.showManageRulesMock && ( + {'Manage Rules'} + )} + +
+
+ + + + handleElementClick(cluster.meta.clusterId, elements) + } + /> + + + + + + handleCellClick(cluster.meta.clusterId, resourceTypeName) + } + onViewAllClick={() => handleViewAllClick(cluster.meta.clusterId)} + /> + + +
+
+ + + ); + })} ); }; + +const getIntegrationBoxStyle = (euiTheme: EuiThemeComputed) => ({ + border: `1px solid ${euiTheme.colors.lightShade}`, + borderRadius: `${euiTheme.border.radius.medium} 0 0 ${euiTheme.border.radius.medium}`, + background: euiTheme.colors.lightestShade, +}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx index db768aa5c7b78..01dd072907472 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx @@ -7,23 +7,16 @@ import React from 'react'; import { EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; -import { useHistory } from 'react-router-dom'; import { PartitionElementEvent } from '@elastic/charts'; -import { Query } from '@kbn/es-query'; -import { ScorePerAccountChart } from '../compliance_charts/score_per_account_chart'; import { ChartPanel } from '../../../components/chart_panel'; import { useCloudPostureStatsApi } from '../../../common/api'; import * as TEXT from '../translations'; import { CloudPostureScoreChart } from '../compliance_charts/cloud_posture_score_chart'; -import { allNavigationItems } from '../../../common/navigation/constants'; -import { encodeQuery } from '../../../common/navigation/query_utils'; import { Evaluation } from '../../../../common/types'; import { RisksTable } from '../compliance_charts/risks_table'; - -const getEvaluationQuery = (evaluation: Evaluation): Query => ({ - language: 'kuery', - query: `"result.evaluation : "${evaluation}"`, -}); +import { CasesTable } from '../compliance_charts/cases_table'; +import { useNavigateFindings } from '../../../common/hooks/use_navigate_findings'; +import { RULE_FAILED } from '../../../../common/constants'; const defaultHeight = 360; @@ -33,19 +26,24 @@ const summarySectionWrapperStyle = { }; export const SummarySection = () => { - const history = useHistory(); + const navToFindings = useNavigateFindings(); const getStats = useCloudPostureStatsApi(); if (!getStats.isSuccess) return null; const handleElementClick = (elements: PartitionElementEvent[]) => { const [element] = elements; const [layerValue] = element; - const rollupValue = layerValue[0].groupByRollup as Evaluation; + const evaluation = layerValue[0].groupByRollup as Evaluation; + + navToFindings({ 'result.evaluation': evaluation }); + }; - history.push({ - pathname: allNavigationItems.findings.path, - search: encodeQuery(getEvaluationQuery(rollupValue)), - }); + const handleCellClick = (resourceTypeName: string) => { + navToFindings({ 'resource.type': resourceTypeName, 'result.evaluation': RULE_FAILED }); + }; + + const handleViewAllClick = () => { + navToFindings({ 'result.evaluation': RULE_FAILED }); }; return ( @@ -58,24 +56,28 @@ export const SummarySection = () => { >
- + - {/* TODO: no api for this chart yet, using empty state for now. needs BE */} - + diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/translations.ts b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/translations.ts index 975c0069f1479..0d62625f98254 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/translations.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/translations.ts @@ -19,12 +19,13 @@ export const RISKS = i18n.translate('xpack.csp.risks', { defaultMessage: 'Risks', }); -export const SCORE_PER_CLUSTER_CHART_TITLE = i18n.translate( - 'xpack.csp.score_per_cluster_chart_title', - { - defaultMessage: 'Score Per Account / Cluster', - } -); +export const OPEN_CASES = i18n.translate('xpack.csp.open_cases', { + defaultMessage: 'Open Cases', +}); + +export const COMING_SOON = i18n.translate('xpack.csp.coming_soon', { + defaultMessage: 'Coming soon', +}); export const COMPLIANCE_SCORE = i18n.translate('xpack.csp.compliance_score', { defaultMessage: 'Compliance Score', @@ -78,10 +79,6 @@ export const RESOURCE_TYPE = i18n.translate('xpack.csp.resource_type', { defaultMessage: 'Resource Type', }); -export const FAILED_FINDINGS = i18n.translate('xpack.csp.failed_findings', { - defaultMessage: 'Failed Findings', -}); - -export const NO_DATA_TO_DISPLAY = i18n.translate('xpack.csp.complianceDashboard.noDataLabel', { - defaultMessage: 'No data to display', +export const FINDINGS = i18n.translate('xpack.csp.findings', { + defaultMessage: 'Findings', }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts index c1b83bc671d16..38228e513e31b 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts @@ -67,9 +67,10 @@ const mapEsQuerySortKey = (sort: readonly EsQuerySortValue[]): EsQuerySortValue[ }, []); const showResponseErrorToast = - ({ toasts: { addDanger } }: CoreStart['notifications']) => + ({ toasts }: CoreStart['notifications']) => (error: unknown): void => { - addDanger(extractErrorMessage(error, TEXT.SEARCH_FAILED)); + if (error instanceof Error) toasts.addError(error, { title: TEXT.SEARCH_FAILED }); + else toasts.addDanger(extractErrorMessage(error, TEXT.SEARCH_FAILED)); }; const extractFindings = ({ diff --git a/x-pack/plugins/cloud_security_posture/public/pages/index.ts b/x-pack/plugins/cloud_security_posture/public/pages/index.ts index 55d62913e4474..1e667a8949fc0 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/index.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/index.ts @@ -8,3 +8,4 @@ export { Findings } from './findings'; export * from './compliance_dashboard'; export { Benchmarks } from './benchmarks'; +export { Rules } from './rules'; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/index.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/index.tsx new file mode 100644 index 0000000000000..130f03fd5784d --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/index.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import type { EuiPageHeaderProps } from '@elastic/eui'; +import { CspPageTemplate } from '../../components/page_template'; + +// TODO: +// - get selected integration + +const pageHeader: EuiPageHeaderProps = { + pageTitle: 'Rules', +}; + +export const Rules = () => { + return ; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_rules.ts b/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_rules.ts new file mode 100644 index 0000000000000..a11256edaa40e --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_rules.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useQuery, useMutation, useQueryClient } from 'react-query'; +import { cspRuleAssetSavedObjectType, type CspRuleSchema } from '../../../common/schemas/csp_rule'; +import type { + SavedObjectsBatchResponse, + SavedObjectsFindOptions, +} from '../../../../../../src/core/public'; +import { useKibana } from '../../common/hooks/use_kibana'; + +export type UseCspRulesOptions = Pick< + SavedObjectsFindOptions, + 'search' | 'searchFields' | 'page' | 'perPage' +>; + +export const useFindCspRules = ({ + search, + searchFields, + page = 1, + perPage = 10, +}: UseCspRulesOptions) => { + const { savedObjects } = useKibana().services; + return useQuery( + [cspRuleAssetSavedObjectType, { search, searchFields, page, perPage }], + () => + savedObjects.client.find({ + type: cspRuleAssetSavedObjectType, + search, + searchFields, + page, + // NOTE: 'name.raw' is a field maping we defined on 'name' so it'd also be sortable + // TODO: this needs to be shared or removed + sortField: 'name.raw', + perPage, + }), + { refetchOnWindowFocus: false } + ); +}; + +export const useBulkUpdateCspRules = () => { + const { savedObjects } = useKibana().services; + const queryClient = useQueryClient(); + + return useMutation( + (rules: CspRuleSchema[]) => + savedObjects.client.bulkUpdate( + rules.map((rule) => ({ + type: cspRuleAssetSavedObjectType, + id: rule.id, + attributes: rule, + })) + // TODO: fix bulkUpdate types in core + ) as Promise>, + { + onSettled: () => + queryClient.invalidateQueries({ + queryKey: cspRuleAssetSavedObjectType, + exact: false, + }), + } + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/server/index.ts b/x-pack/plugins/cloud_security_posture/server/index.ts index c0912e68218c8..f790ac5256ff8 100755 --- a/x-pack/plugins/cloud_security_posture/server/index.ts +++ b/x-pack/plugins/cloud_security_posture/server/index.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import type { PluginInitializerContext } from '../../../../src/core/server'; import { CspPlugin } from './plugin'; diff --git a/x-pack/plugins/cloud_security_posture/server/lib/csp_app_services.ts b/x-pack/plugins/cloud_security_posture/server/lib/csp_app_services.ts new file mode 100644 index 0000000000000..f769ea171c176 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/lib/csp_app_services.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + AgentService, + PackageService, + AgentPolicyServiceInterface, + PackagePolicyServiceInterface, +} from '../../../fleet/server'; + +export interface CspAppServiceDependencies { + packageService: PackageService; + agentService: AgentService; + packagePolicyService: PackagePolicyServiceInterface; + agentPolicyService: AgentPolicyServiceInterface; +} + +export class CspAppService { + public agentService: AgentService | undefined; + public packageService: PackageService | undefined; + public packagePolicyService: PackagePolicyServiceInterface | undefined; + public agentPolicyService: AgentPolicyServiceInterface | undefined; + + public start(dependencies: CspAppServiceDependencies) { + this.agentService = dependencies.agentService; + this.packageService = dependencies.packageService; + this.packagePolicyService = dependencies.packagePolicyService; + this.agentPolicyService = dependencies.agentPolicyService; + } + + public stop() {} +} diff --git a/x-pack/plugins/cloud_security_posture/server/plugin.ts b/x-pack/plugins/cloud_security_posture/server/plugin.ts index 1225d3481d334..ce6e38e4c63c5 100755 --- a/x-pack/plugins/cloud_security_posture/server/plugin.ts +++ b/x-pack/plugins/cloud_security_posture/server/plugin.ts @@ -12,6 +12,7 @@ import type { Plugin, Logger, } from '../../../../src/core/server'; +import { CspAppService } from './lib/csp_app_services'; import type { CspServerPluginSetup, CspServerPluginStart, @@ -19,6 +20,13 @@ import type { CspServerPluginStartDeps, } from './types'; import { defineRoutes } from './routes'; +import { cspRuleAssetType } from './saved_objects/cis_1_4_1/csp_rule_type'; +import { initializeCspRules } from './saved_objects/cis_1_4_1/initialize_rules'; + +export interface CspAppContext { + logger: Logger; + service: CspAppService; +} export class CspPlugin implements @@ -33,20 +41,34 @@ export class CspPlugin constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); } + private readonly CspAppService = new CspAppService(); public setup( core: CoreSetup, plugins: CspServerPluginSetupDeps ): CspServerPluginSetup { + const cspAppContext: CspAppContext = { + logger: this.logger, + service: this.CspAppService, + }; + + core.savedObjects.registerType(cspRuleAssetType); + const router = core.http.createRouter(); // Register server side APIs - defineRoutes(router, this.logger); + defineRoutes(router, cspAppContext); return {}; } public start(core: CoreStart, plugins: CspServerPluginStartDeps): CspServerPluginStart { + this.CspAppService.start({ + ...plugins.fleet, + }); + + initializeCspRules(core.savedObjects.createInternalRepository()); + return {}; } public stop() {} diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts new file mode 100644 index 0000000000000..b728948cf2a05 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { httpServiceMock, loggingSystemMock, savedObjectsClientMock } from 'src/core/server/mocks'; +import { + defineGetBenchmarksRoute, + benchmarksInputSchema, + DEFAULT_BENCHMARKS_PER_PAGE, + PACKAGE_POLICY_SAVED_OBJECT_TYPE, + getPackagePolicies, + getAgentPolicies, + createBenchmarkEntry, +} from './benchmarks'; +import { SavedObjectsClientContract } from 'src/core/server'; +import { + createMockAgentPolicyService, + createPackagePolicyServiceMock, +} from '../../../../fleet/server/mocks'; +import { createPackagePolicyMock } from '../../../../fleet/common/mocks'; +import { AgentPolicy } from '../../../../fleet/common'; + +import { CspAppService } from '../../lib/csp_app_services'; +import { CspAppContext } from '../../plugin'; + +function createMockAgentPolicy(props: Partial = {}): AgentPolicy { + return { + id: 'some-uuid1', + namespace: 'default', + monitoring_enabled: [], + name: 'Test Policy', + description: '', + is_default: false, + is_preconfigured: false, + status: 'active', + is_managed: false, + revision: 1, + updated_at: '', + updated_by: 'elastic', + package_policies: [], + ...props, + }; +} +describe('benchmarks API', () => { + let logger: ReturnType; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + jest.clearAllMocks(); + }); + + it('validate the API route path', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineGetBenchmarksRoute(router, cspContext); + + const [config, _] = router.get.mock.calls[0]; + + expect(config.path).toEqual('/api/csp/benchmarks'); + }); + + describe('test input schema', () => { + it('expect to find default values', async () => { + const validatedQuery = benchmarksInputSchema.validate({}); + + expect(validatedQuery).toMatchObject({ + page: 1, + per_page: DEFAULT_BENCHMARKS_PER_PAGE, + }); + }); + + it('should throw when page field is not a positive integer', async () => { + expect(() => { + benchmarksInputSchema.validate({ page: -2 }); + }).toThrow(); + }); + + it('should throw when per_page field is not a positive integer', async () => { + expect(() => { + benchmarksInputSchema.validate({ per_page: -2 }); + }).toThrow(); + }); + }); + + describe('test benchmarks utils', () => { + let mockSoClient: jest.Mocked; + + beforeEach(() => { + mockSoClient = savedObjectsClientMock.create(); + }); + + describe('test getPackagePolicies', () => { + it('should throw when agentPolicyService is undefined', async () => { + const mockAgentPolicyService = undefined; + expect( + getPackagePolicies(mockSoClient, mockAgentPolicyService, 'myPackage', { + page: 1, + per_page: 100, + }) + ).rejects.toThrow(); + }); + + it('should format request by package name', async () => { + const mockAgentPolicyService = createPackagePolicyServiceMock(); + + await getPackagePolicies(mockSoClient, mockAgentPolicyService, 'myPackage', { + page: 1, + per_page: 100, + }); + + expect(mockAgentPolicyService.list.mock.calls[0][1]).toMatchObject( + expect.objectContaining({ + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:myPackage`, + page: 1, + perPage: 100, + }) + ); + }); + }); + + describe('test getAgentPolicies', () => { + it('should return one agent policy id when there is duplication', async () => { + const agentPolicyService = createMockAgentPolicyService(); + const packagePolicies = [createPackagePolicyMock(), createPackagePolicyMock()]; + + await getAgentPolicies(mockSoClient, packagePolicies, agentPolicyService); + + expect(agentPolicyService.getByIds.mock.calls[0][1]).toHaveLength(1); + }); + + it('should return full policy ids list when there is no id duplication', async () => { + const agentPolicyService = createMockAgentPolicyService(); + + const packagePolicy1 = createPackagePolicyMock(); + const packagePolicy2 = createPackagePolicyMock(); + packagePolicy2.policy_id = 'AnotherId'; + const packagePolicies = [packagePolicy1, packagePolicy2]; + + await getAgentPolicies(mockSoClient, packagePolicies, agentPolicyService); + + expect(agentPolicyService.getByIds.mock.calls[0][1]).toHaveLength(2); + }); + }); + + describe('test createBenchmarkEntry', () => { + it('should build benchmark entry agent policy and package policy', async () => { + const packagePolicy = createPackagePolicyMock(); + const agentPolicy = createMockAgentPolicy(); + // @ts-expect-error + agentPolicy.agents = 3; + + const enrichAgentPolicy = await createBenchmarkEntry(agentPolicy, packagePolicy); + + expect(enrichAgentPolicy).toMatchObject({ + package_policy: { + id: 'c6d16e42-c32d-4dce-8a88-113cfe276ad1', + name: 'endpoint-1', + policy_id: '93c46720-c217-11ea-9906-b5b8a21b268e', + namespace: 'default', + updated_at: '2020-06-25T16:03:38.159292', + updated_by: 'kibana', + created_at: '2020-06-25T16:03:38.159292', + created_by: 'kibana', + package: { + name: 'endpoint', + title: 'Elastic Endpoint', + version: '0.9.0', + }, + }, + agent_policy: { id: 'some-uuid1', name: 'Test Policy', agents: 3 }, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts new file mode 100644 index 0000000000000..80c526c248c0f --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.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 { uniq, map } from 'lodash'; +import type { IRouter, SavedObjectsClientContract } from 'src/core/server'; +import { schema as rt, TypeOf } from '@kbn/config-schema'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { string } from 'io-ts'; +import { + PackagePolicyServiceInterface, + AgentPolicyServiceInterface, + AgentService, +} from '../../../../fleet/server'; +import { GetAgentPoliciesResponseItem, PackagePolicy, AgentPolicy } from '../../../../fleet/common'; +import { BENCHMARKS_ROUTE_PATH, CIS_KUBERNETES_PACKAGE_NAME } from '../../../common/constants'; +import { CspAppContext } from '../../plugin'; + +// TODO: use the same method from common/ once PR 106 is merged +export const isNonNullable = (v: T): v is NonNullable => + v !== null && v !== undefined; + +type BenchmarksQuerySchema = TypeOf; + +export interface Benchmark { + package_policy: Pick< + PackagePolicy, + | 'id' + | 'name' + | 'policy_id' + | 'namespace' + | 'package' + | 'updated_at' + | 'updated_by' + | 'created_at' + | 'created_by' + >; + agent_policy: Pick; +} + +export const DEFAULT_BENCHMARKS_PER_PAGE = 20; +export const PACKAGE_POLICY_SAVED_OBJECT_TYPE = 'ingest-package-policies'; + +const getPackageNameQuery = (packageName: string): string => { + return `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${packageName}`; +}; + +export const getPackagePolicies = async ( + soClient: SavedObjectsClientContract, + packagePolicyService: PackagePolicyServiceInterface | undefined, + packageName: string, + queryParams: BenchmarksQuerySchema +): Promise => { + if (!packagePolicyService) { + throw new Error('packagePolicyService is undefined'); + } + + const packageNameQuery = getPackageNameQuery(packageName); + + const { items: packagePolicies } = (await packagePolicyService?.list(soClient, { + kuery: packageNameQuery, + page: queryParams.page, + perPage: queryParams.per_page, + })) ?? { items: [] as PackagePolicy[] }; + + return packagePolicies; +}; + +export const getAgentPolicies = async ( + soClient: SavedObjectsClientContract, + packagePolicies: PackagePolicy[], + agentPolicyService: AgentPolicyServiceInterface +): Promise => { + const agentPolicyIds = uniq(map(packagePolicies, 'policy_id')); + const agentPolicies = await agentPolicyService.getByIds(soClient, agentPolicyIds); + + return agentPolicies; +}; + +const addRunningAgentToAgentPolicy = async ( + agentService: AgentService, + agentPolicies: AgentPolicy[] +): Promise => { + if (!agentPolicies?.length) return []; + return Promise.all( + agentPolicies.map((agentPolicy) => + agentService.asInternalUser + .getAgentStatusForAgentPolicy(agentPolicy.id) + .then((agentStatus) => ({ + ...agentPolicy, + agents: agentStatus.total, + })) + ) + ); +}; + +export const createBenchmarkEntry = ( + agentPolicy: GetAgentPoliciesResponseItem, + packagePolicy: PackagePolicy +): Benchmark => ({ + package_policy: { + id: packagePolicy.id, + name: packagePolicy.name, + policy_id: packagePolicy.policy_id, + namespace: packagePolicy.namespace, + updated_at: packagePolicy.updated_at, + updated_by: packagePolicy.updated_by, + created_at: packagePolicy.created_at, + created_by: packagePolicy.created_by, + package: packagePolicy.package + ? { + name: packagePolicy.package.name, + title: packagePolicy.package.title, + version: packagePolicy.package.version, + } + : undefined, + }, + agent_policy: { + id: agentPolicy.id, + name: agentPolicy.name, + agents: agentPolicy.agents, + }, +}); + +const createBenchmarks = ( + agentPolicies: GetAgentPoliciesResponseItem[], + packagePolicies: PackagePolicy[] +): Benchmark[] => + agentPolicies + .flatMap((agentPolicy) => + agentPolicy.package_policies.map((agentPackagePolicy) => { + const id = string.is(agentPackagePolicy) ? agentPackagePolicy : agentPackagePolicy.id; + const packagePolicy = packagePolicies.find((pkgPolicy) => pkgPolicy.id === id); + if (!packagePolicy) return; + return createBenchmarkEntry(agentPolicy, packagePolicy); + }) + ) + .filter(isNonNullable); + +export const defineGetBenchmarksRoute = (router: IRouter, cspContext: CspAppContext): void => + router.get( + { + path: BENCHMARKS_ROUTE_PATH, + validate: { query: benchmarksInputSchema }, + }, + async (context, request, response) => { + try { + const soClient = context.core.savedObjects.client; + const { query } = request; + + const agentService = cspContext.service.agentService; + const agentPolicyService = cspContext.service.agentPolicyService; + const packagePolicyService = cspContext.service.packagePolicyService; + + // TODO: This validate can be remove after #2819 will be merged + if (!agentPolicyService || !agentService) { + throw new Error(`Failed to get Fleet services`); + } + + const packagePolicies = await getPackagePolicies( + soClient, + packagePolicyService, + CIS_KUBERNETES_PACKAGE_NAME, + query + ); + + const agentPolicies = await getAgentPolicies(soClient, packagePolicies, agentPolicyService); + const enrichAgentPolicies = await addRunningAgentToAgentPolicy(agentService, agentPolicies); + const benchmarks = createBenchmarks(enrichAgentPolicies, packagePolicies); + + return response.ok({ + body: benchmarks, + }); + } catch (err) { + const error = transformError(err); + cspContext.logger.error(`Failed to fetch benchmarks ${err}`); + return response.customError({ + body: { message: error.message }, + statusCode: error.statusCode, + }); + } + } + ); + +export const benchmarksInputSchema = rt.object({ + /** + * The page of objects to return + */ + page: rt.number({ defaultValue: 1, min: 1 }), + /** + * The number of objects to include in each page + */ + per_page: rt.number({ defaultValue: DEFAULT_BENCHMARKS_PER_PAGE, min: 0 }), +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts new file mode 100644 index 0000000000000..f554eb91a4a49 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient, IRouter } from 'src/core/server'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import type { + AggregationsMultiBucketAggregateBase as Aggregation, + AggregationsTopHitsAggregate, + QueryDslQueryContainer, + SearchRequest, +} from '@elastic/elasticsearch/lib/api/types'; +import type { CloudPostureStats } from '../../../common/types'; +import { CSP_KUBEBEAT_INDEX_PATTERN, STATS_ROUTE_PATH } from '../../../common/constants'; +import { CspAppContext } from '../../plugin'; +import { getResourcesTypes } from './get_resources_types'; +import { getClusters } from './get_clusters'; +import { getStats } from './get_stats'; + +export interface ClusterBucket { + ordered_top_hits: AggregationsTopHitsAggregate; +} + +interface ClustersQueryResult { + aggs_by_cluster_id: Aggregation; +} + +export interface KeyDocCount { + key: TKey; + doc_count: number; +} + +export const getLatestFindingQuery = (): SearchRequest => ({ + index: CSP_KUBEBEAT_INDEX_PATTERN, + size: 0, + query: { + match_all: {}, + }, + aggs: { + aggs_by_cluster_id: { + terms: { field: 'cluster_id.keyword' }, + aggs: { + ordered_top_hits: { + top_hits: { + size: 1, + sort: { + '@timestamp': { + order: 'desc', + }, + }, + }, + }, + }, + }, + }, +}); + +const getLatestCyclesIds = async (esClient: ElasticsearchClient): Promise => { + const queryResult = await esClient.search(getLatestFindingQuery(), { + meta: true, + }); + + const clusters = queryResult.body.aggregations?.aggs_by_cluster_id.buckets; + if (!Array.isArray(clusters)) throw new Error('missing aggs by cluster id'); + + return clusters.map((c) => { + const topHit = c.ordered_top_hits.hits.hits[0]; + if (!topHit) throw new Error('missing cluster latest hit'); + return topHit._source.cycle_id; + }); +}; + +// TODO: Utilize ES "Point in Time" feature https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html +export const defineGetComplianceDashboardRoute = ( + router: IRouter, + cspContext: CspAppContext +): void => + router.get( + { + path: STATS_ROUTE_PATH, + validate: false, + }, + async (context, _, response) => { + try { + const esClient = context.core.elasticsearch.client.asCurrentUser; + const latestCyclesIds = await getLatestCyclesIds(esClient); + const query: QueryDslQueryContainer = { + bool: { + should: latestCyclesIds.map((id) => ({ + match: { 'cycle_id.keyword': { query: id } }, + })), + }, + }; + + const [stats, resourcesTypes, clusters] = await Promise.all([ + getStats(esClient, query), + getResourcesTypes(esClient, query), + getClusters(esClient, query), + ]); + + const body: CloudPostureStats = { + stats, + resourcesTypes, + clusters, + }; + + return response.ok({ + body, + }); + } catch (err) { + const error = transformError(err); + + return response.customError({ + body: { message: error.message }, + statusCode: error.statusCode, + }); + } + } + ); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.test.ts new file mode 100644 index 0000000000000..8ee05a6e4755f --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.test.ts @@ -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 { ClusterBucket, getClustersFromAggs } from './get_clusters'; + +const mockClusterBuckets: ClusterBucket[] = [ + { + key: 'cluster_id', + doc_count: 10, + benchmarks: { + buckets: [{ key: 'CIS Kubernetes', doc_count: 10 }], + }, + failed_findings: { + doc_count: 6, + }, + passed_findings: { + doc_count: 6, + }, + aggs_by_resource_type: { + buckets: [ + { + key: 'foo_type', + doc_count: 6, + failed_findings: { + doc_count: 3, + }, + passed_findings: { + doc_count: 3, + }, + }, + { + key: 'boo_type', + doc_count: 6, + failed_findings: { + doc_count: 3, + }, + passed_findings: { + doc_count: 3, + }, + }, + ], + }, + }, +]; + +describe('getClustersFromAggs', () => { + it('should return value matching CloudPostureStats["clusters"]', async () => { + const clusters = getClustersFromAggs(mockClusterBuckets); + expect(clusters).toEqual([ + { + meta: { + clusterId: 'cluster_id', + benchmarkName: 'CIS Kubernetes', + }, + stats: { + totalFindings: 12, + totalFailed: 6, + totalPassed: 6, + postureScore: 50.0, + }, + resourcesTypes: [ + { + name: 'foo_type', + totalFindings: 6, + totalFailed: 3, + totalPassed: 3, + }, + { + name: 'boo_type', + totalFindings: 6, + totalFailed: 3, + totalPassed: 3, + }, + ], + }, + ]); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.ts new file mode 100644 index 0000000000000..5be94f7246e53 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient } from 'kibana/server'; +import type { + AggregationsMultiBucketAggregateBase as Aggregation, + QueryDslQueryContainer, + SearchRequest, +} from '@elastic/elasticsearch/lib/api/types'; +import { CloudPostureStats } from '../../../common/types'; +import { getResourceTypeFromAggs, resourceTypeAggQuery } from './get_resources_types'; +import type { ResourceTypeQueryResult } from './get_resources_types'; +import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; +import { findingsEvaluationAggsQuery, getStatsFromFindingsEvaluationsAggs } from './get_stats'; +import { KeyDocCount } from './compliance_dashboard'; + +export interface ClusterBucket extends ResourceTypeQueryResult, KeyDocCount { + failed_findings: { + doc_count: number; + }; + passed_findings: { + doc_count: number; + }; + benchmarks: Aggregation; +} + +interface ClustersQueryResult { + aggs_by_cluster_id: Aggregation; +} + +export const getClustersQuery = (query: QueryDslQueryContainer): SearchRequest => ({ + index: CSP_KUBEBEAT_INDEX_PATTERN, + size: 0, + query, + aggs: { + aggs_by_cluster_id: { + terms: { + field: 'cluster_id.keyword', + }, + aggs: { + benchmarks: { + terms: { + field: 'rule.benchmark.name.keyword', + }, + }, + ...resourceTypeAggQuery, + ...findingsEvaluationAggsQuery, + }, + }, + }, +}); + +export const getClustersFromAggs = (clusters: ClusterBucket[]): CloudPostureStats['clusters'] => + clusters.map((cluster) => { + // get cluster's meta data + const benchmarks = cluster.benchmarks.buckets; + if (!Array.isArray(benchmarks)) throw new Error('missing aggs by benchmarks per cluster'); + + const meta = { + clusterId: cluster.key, + benchmarkName: benchmarks[0].key, + }; + + // get cluster's stats + if (!cluster.failed_findings || !cluster.passed_findings) + throw new Error('missing findings evaluations per cluster'); + const stats = getStatsFromFindingsEvaluationsAggs(cluster); + + // get cluster's resource types aggs + const resourcesTypesAggs = cluster.aggs_by_resource_type.buckets; + if (!Array.isArray(resourcesTypesAggs)) + throw new Error('missing aggs by resource type per cluster'); + const resourcesTypes = getResourceTypeFromAggs(resourcesTypesAggs); + + return { + meta, + stats, + resourcesTypes, + }; + }); + +export const getClusters = async ( + esClient: ElasticsearchClient, + query: QueryDslQueryContainer +): Promise => { + const queryResult = await esClient.search(getClustersQuery(query), { + meta: true, + }); + + const clusters = queryResult.body.aggregations?.aggs_by_cluster_id.buckets; + if (!Array.isArray(clusters)) throw new Error('missing aggs by cluster id'); + + return getClustersFromAggs(clusters); +}; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.test.ts new file mode 100644 index 0000000000000..b01644fc3f45b --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getResourceTypeFromAggs, ResourceTypeBucket } from './get_resources_types'; + +const resourceTypeBuckets: ResourceTypeBucket[] = [ + { + key: 'foo_type', + doc_count: 41, + failed_findings: { + doc_count: 30, + }, + passed_findings: { + doc_count: 11, + }, + }, + { + key: 'boo_type', + doc_count: 11, + failed_findings: { + doc_count: 5, + }, + passed_findings: { + doc_count: 6, + }, + }, +]; + +describe('getResourceTypeFromAggs', () => { + it('should return value matching CloudPostureStats["resourcesTypes"]', async () => { + const resourceTypes = getResourceTypeFromAggs(resourceTypeBuckets); + expect(resourceTypes).toEqual([ + { + name: 'foo_type', + totalFindings: 41, + totalFailed: 30, + totalPassed: 11, + }, + { + name: 'boo_type', + totalFindings: 11, + totalFailed: 5, + totalPassed: 6, + }, + ]); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.ts new file mode 100644 index 0000000000000..459dce56042da --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient } from 'kibana/server'; +import { + AggregationsMultiBucketAggregateBase as Aggregation, + QueryDslQueryContainer, + SearchRequest, +} from '@elastic/elasticsearch/lib/api/types'; +import { CloudPostureStats } from '../../../common/types'; +import { KeyDocCount } from './compliance_dashboard'; +import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; + +export interface ResourceTypeQueryResult { + aggs_by_resource_type: Aggregation; +} + +export interface ResourceTypeBucket extends KeyDocCount { + failed_findings: { + doc_count: number; + }; + passed_findings: { + doc_count: number; + }; +} + +export const resourceTypeAggQuery = { + aggs_by_resource_type: { + terms: { + field: 'resource.type.keyword', + }, + aggs: { + failed_findings: { + filter: { term: { 'result.evaluation.keyword': 'failed' } }, + }, + passed_findings: { + filter: { term: { 'result.evaluation.keyword': 'passed' } }, + }, + }, + }, +}; + +export const getRisksEsQuery = (query: QueryDslQueryContainer): SearchRequest => ({ + index: CSP_KUBEBEAT_INDEX_PATTERN, + size: 0, + query, + aggs: resourceTypeAggQuery, +}); + +export const getResourceTypeFromAggs = ( + queryResult: ResourceTypeBucket[] +): CloudPostureStats['resourcesTypes'] => + queryResult.map((bucket) => ({ + name: bucket.key, + totalFindings: bucket.doc_count, + totalFailed: bucket.failed_findings.doc_count || 0, + totalPassed: bucket.passed_findings.doc_count || 0, + })); + +export const getResourcesTypes = async ( + esClient: ElasticsearchClient, + query: QueryDslQueryContainer +): Promise => { + const resourceTypesQueryResult = await esClient.search( + getRisksEsQuery(query), + { meta: true } + ); + + const resourceTypes = resourceTypesQueryResult.body.aggregations?.aggs_by_resource_type.buckets; + if (!Array.isArray(resourceTypes)) throw new Error('missing resources types buckets'); + + return getResourceTypeFromAggs(resourceTypes); +}; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.test.ts new file mode 100644 index 0000000000000..558fec85860ea --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + calculatePostureScore, + FindingsEvaluationsQueryResult, + getStatsFromFindingsEvaluationsAggs, + roundScore, +} from './get_stats'; + +const standardQueryResult: FindingsEvaluationsQueryResult = { + failed_findings: { + doc_count: 30, + }, + passed_findings: { + doc_count: 11, + }, +}; + +const oneIsZeroQueryResult: FindingsEvaluationsQueryResult = { + failed_findings: { + doc_count: 0, + }, + passed_findings: { + doc_count: 11, + }, +}; + +const bothAreZeroQueryResult: FindingsEvaluationsQueryResult = { + failed_findings: { + doc_count: 0, + }, + passed_findings: { + doc_count: 0, + }, +}; + +describe('roundScore', () => { + it('should return decimal values with one fraction digit', async () => { + const rounded = roundScore(0.85245); + expect(rounded).toEqual(85.2); + }); +}); + +describe('calculatePostureScore', () => { + it('should return calculated posture score', async () => { + const score = calculatePostureScore(4, 7); + expect(score).toEqual(36.4); + }); +}); + +describe('getStatsFromFindingsEvaluationsAggs', () => { + it('should throw error in case no findings were found', async () => { + const score = calculatePostureScore(4, 7); + expect(score).toEqual(36.4); + }); + + it('should return value matching CloudPostureStats["stats"]', async () => { + const stats = getStatsFromFindingsEvaluationsAggs(standardQueryResult); + expect(stats).toEqual({ + totalFailed: 30, + totalPassed: 11, + totalFindings: 41, + postureScore: 26.8, + }); + }); + + it('checks for stability in case one of the values is zero', async () => { + const stats = getStatsFromFindingsEvaluationsAggs(oneIsZeroQueryResult); + expect(stats).toEqual({ + totalFailed: 0, + totalPassed: 11, + totalFindings: 11, + postureScore: 100.0, + }); + }); + + it('should throw error if both evaluations are zero', async () => { + // const stats = getStatsFromFindingsEvaluationsAggs(bothAreZeroQueryResult); + expect(() => getStatsFromFindingsEvaluationsAggs(bothAreZeroQueryResult)).toThrow(); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.ts new file mode 100644 index 0000000000000..8d5417de24c52 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient } from 'kibana/server'; +import { QueryDslQueryContainer, SearchRequest } from '@elastic/elasticsearch/lib/api/types'; +import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; +import { CloudPostureStats, Score } from '../../../common/types'; + +/** + * @param value value is [0, 1] range + */ +export const roundScore = (value: number): Score => Number((value * 100).toFixed(1)); + +export const calculatePostureScore = (passed: number, failed: number): Score => + roundScore(passed / (passed + failed)); + +export interface FindingsEvaluationsQueryResult { + failed_findings: { + doc_count: number; + }; + passed_findings: { + doc_count: number; + }; +} + +export const findingsEvaluationAggsQuery = { + failed_findings: { + filter: { term: { 'result.evaluation.keyword': 'failed' } }, + }, + passed_findings: { + filter: { term: { 'result.evaluation.keyword': 'passed' } }, + }, +}; + +export const getEvaluationsQuery = (query: QueryDslQueryContainer): SearchRequest => ({ + index: CSP_KUBEBEAT_INDEX_PATTERN, + query, + aggs: findingsEvaluationAggsQuery, +}); + +export const getStatsFromFindingsEvaluationsAggs = ( + findingsEvaluationsAggs: FindingsEvaluationsQueryResult +): CloudPostureStats['stats'] => { + const failedFindings = findingsEvaluationsAggs.failed_findings.doc_count || 0; + const passedFindings = findingsEvaluationsAggs.passed_findings.doc_count || 0; + const totalFindings = failedFindings + passedFindings; + if (!totalFindings) throw new Error("couldn't calculate posture score"); + const postureScore = calculatePostureScore(passedFindings, failedFindings); + + return { + totalFailed: failedFindings, + totalPassed: passedFindings, + totalFindings, + postureScore, + }; +}; + +export const getStats = async ( + esClient: ElasticsearchClient, + query: QueryDslQueryContainer +): Promise => { + const evaluationsQueryResult = await esClient.search( + getEvaluationsQuery(query), + { meta: true } + ); + const findingsEvaluations = evaluationsQueryResult.body.aggregations; + if (!findingsEvaluations) throw new Error('missing findings evaluations'); + + return getStatsFromFindingsEvaluationsAggs(findingsEvaluations); +}; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts index ffc5526e2fe42..76fc97e921045 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts @@ -12,6 +12,8 @@ import { // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { KibanaRequest } from 'src/core/server/http/router/request'; import { httpServerMock, httpServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { CspAppService } from '../../lib/csp_app_services'; +import { CspAppContext } from '../../plugin'; import { defineFindingsIndexRoute, findingsInputSchema, @@ -41,7 +43,13 @@ describe('findings API', () => { it('validate the API route path', async () => { const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); const [config, _] = router.get.mock.calls[0]; @@ -130,7 +138,13 @@ describe('findings API', () => { it('takes cycle_id and validate the filter was built right', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); @@ -178,7 +192,14 @@ describe('findings API', () => { it('validate that default sort is timestamp desc', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); const mockResponse = httpServerMock.createResponseFactory(); @@ -202,7 +223,14 @@ describe('findings API', () => { it('should build sort request by `sort_field` and `sort_order` - asc', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); const mockResponse = httpServerMock.createResponseFactory(); @@ -227,7 +255,14 @@ describe('findings API', () => { it('should build sort request by `sort_field` and `sort_order` - desc', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); const mockResponse = httpServerMock.createResponseFactory(); @@ -249,10 +284,17 @@ describe('findings API', () => { }); }); - it('takes `page_number` and `per_page` validate that the requested selected page was called', async () => { + it('takes `page` number and `per_page` validate that the requested selected page was called', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); const mockResponse = httpServerMock.createResponseFactory(); @@ -278,7 +320,14 @@ describe('findings API', () => { it('should format request by fields filter', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts index a5c8f67a41cac..5fea7cdbba9db 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { IRouter, Logger } from 'src/core/server'; +import type { IRouter } from 'src/core/server'; import { SearchRequest, QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { schema as rt, TypeOf } from '@kbn/config-schema'; import type { SortOrder } from '@elastic/elasticsearch/lib/api/types'; @@ -13,6 +13,7 @@ import { transformError } from '@kbn/securitysolution-es-utils'; import { getLatestCycleIds } from './get_latest_cycle_ids'; import { CSP_KUBEBEAT_INDEX_PATTERN, FINDINGS_ROUTE_PATH } from '../../../common/constants'; +import { CspAppContext } from '../../plugin'; type FindingsQuerySchema = TypeOf; @@ -70,7 +71,7 @@ const buildOptionsRequest = (queryParams: FindingsQuerySchema): FindingsOptions ...getSearchFields(queryParams.fields), }); -export const defineFindingsIndexRoute = (router: IRouter, logger: Logger): void => +export const defineFindingsIndexRoute = (router: IRouter, cspContext: CspAppContext): void => router.get( { path: FINDINGS_ROUTE_PATH, @@ -83,7 +84,7 @@ export const defineFindingsIndexRoute = (router: IRouter, logger: Logger): void const latestCycleIds = request.query.latest_cycle === true - ? await getLatestCycleIds(esClient, logger) + ? await getLatestCycleIds(esClient, cspContext.logger) : undefined; const query = buildQueryRequest(latestCycleIds); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/index.ts b/x-pack/plugins/cloud_security_posture/server/routes/index.ts index ab8d1cc3bbedf..c0b333e4058aa 100755 --- a/x-pack/plugins/cloud_security_posture/server/routes/index.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/index.ts @@ -5,11 +5,14 @@ * 2.0. */ -import type { IRouter, Logger } from '../../../../../src/core/server'; -import { defineGetStatsRoute } from './stats/stats'; +import type { IRouter } from '../../../../../src/core/server'; +import { defineGetComplianceDashboardRoute } from './compliance_dashboard/compliance_dashboard'; +import { defineGetBenchmarksRoute } from './benchmarks/benchmarks'; import { defineFindingsIndexRoute as defineGetFindingsIndexRoute } from './findings/findings'; +import { CspAppContext } from '../plugin'; -export function defineRoutes(router: IRouter, logger: Logger) { - defineGetStatsRoute(router, logger); - defineGetFindingsIndexRoute(router, logger); +export function defineRoutes(router: IRouter, cspContext: CspAppContext) { + defineGetComplianceDashboardRoute(router, cspContext); + defineGetFindingsIndexRoute(router, cspContext); + defineGetBenchmarksRoute(router, cspContext); } diff --git a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.test.ts deleted file mode 100644 index 549e8d45c989f..0000000000000 --- a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.test.ts +++ /dev/null @@ -1,201 +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 { - elasticsearchClientMock, - ElasticsearchClientMock, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from 'src/core/server/elasticsearch/client/mocks'; - -import { - getBenchmarks, - getAllFindingsStats, - roundScore, - getBenchmarksStats, - getResourceTypesAggs, -} from './stats'; - -export const mockCountResultOnce = async (mockEsClient: ElasticsearchClientMock, count: number) => { - mockEsClient.count.mockReturnValueOnce( - // @ts-expect-error @elast ic/elasticsearch Aggregate only allows unknown values - elasticsearchClientMock.createSuccessTransportRequestPromise({ count }) - ); -}; - -export const mockSearchResultOnce = async ( - mockEsClient: ElasticsearchClientMock, - returnedMock: object -) => { - mockEsClient.search.mockReturnValueOnce( - // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values - elasticsearchClientMock.createSuccessTransportRequestPromise(returnedMock) - ); -}; - -const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; - -const resourceTypeAggsMockData = { - aggregations: { - resource_types: { - buckets: [ - { - key: 'pods', - doc_count: 3, - bucket_evaluation: { - buckets: [ - { - key: 'passed', - doc_count: 1, - }, - { - key: 'failed', - doc_count: 2, - }, - ], - }, - }, - { - key: 'etcd', - doc_count: 4, - bucket_evaluation: { - buckets: [ - // there is only one bucket here, in cases where aggs can't find an evaluation we count that as 0. - { - key: 'failed', - doc_count: 4, - }, - ], - }, - }, - ], - }, - }, -}; - -afterEach(() => { - jest.clearAllMocks(); -}); - -describe('testing round score', () => { - it('take decimal and expect the roundScore will return it with one digit after the dot ', async () => { - const score = roundScore(0.85245); - expect(score).toEqual(85.2); - }); -}); - -describe('general cloud posture score', () => { - it('expect to valid score from getAllFindingsStats', async () => { - mockCountResultOnce(mockEsClient, 10); // total findings - mockCountResultOnce(mockEsClient, 3); // pass findings - mockCountResultOnce(mockEsClient, 7); // fail findings - - const generalScore = await getAllFindingsStats(mockEsClient, 'randomCycleId'); - expect(generalScore).toEqual({ - name: 'general', - postureScore: 30, - totalFailed: 7, - totalFindings: 10, - totalPassed: 3, - }); - }); - - it("getAllFindingsStats throws when cycleId doesn't exists", async () => { - try { - await getAllFindingsStats(mockEsClient, 'randomCycleId'); - } catch (e) { - expect(e).toBeInstanceOf(Error); - expect(e.message).toEqual('missing stats'); - } - }); -}); - -describe('get benchmarks list', () => { - it('getBenchmarks - takes aggregated data and expect unique benchmarks array', async () => { - const returnedMock = { - aggregations: { - benchmarks: { - buckets: [ - { key: 'CIS Kubernetes', doc_count: 248514 }, - { key: 'GDPR', doc_count: 248514 }, - ], - }, - }, - }; - mockSearchResultOnce(mockEsClient, returnedMock); - const benchmarks = await getBenchmarks(mockEsClient); - expect(benchmarks).toEqual(['CIS Kubernetes', 'GDPR']); - }); -}); - -describe('score per benchmark, testing getBenchmarksStats', () => { - it('get data for only one benchmark and check', async () => { - mockCountResultOnce(mockEsClient, 10); // total findings - mockCountResultOnce(mockEsClient, 3); // pass findings - mockCountResultOnce(mockEsClient, 7); // fail findings - const benchmarkScore = await getBenchmarksStats(mockEsClient, 'randomCycleId', [ - 'CIS Benchmark', - ]); - expect(benchmarkScore).toEqual([ - { - name: 'CIS Benchmark', - postureScore: 30, - totalFailed: 7, - totalFindings: 10, - totalPassed: 3, - }, - ]); - }); - - it('get data two benchmarks and check', async () => { - mockCountResultOnce(mockEsClient, 10); // total findings - mockCountResultOnce(mockEsClient, 3); // pass findings - mockCountResultOnce(mockEsClient, 7); // fail findings - mockCountResultOnce(mockEsClient, 100); - mockCountResultOnce(mockEsClient, 50); - mockCountResultOnce(mockEsClient, 50); - const benchmarkScore = await getBenchmarksStats(mockEsClient, 'randomCycleId', [ - 'CIS Benchmark', - 'GDPR', - ]); - expect(benchmarkScore).toEqual([ - { - name: 'CIS Benchmark', - postureScore: 30, - totalFailed: 7, - totalFindings: 10, - totalPassed: 3, - }, - { - name: 'GDPR', - postureScore: 50, - totalFailed: 50, - totalFindings: 100, - totalPassed: 50, - }, - ]); - }); -}); - -describe('getResourceTypesAggs', () => { - it('get all resources types aggregations', async () => { - await mockSearchResultOnce(mockEsClient, resourceTypeAggsMockData); - const resourceTypeAggs = await getResourceTypesAggs(mockEsClient, 'RandomCycleId'); - expect(resourceTypeAggs).toEqual([ - { - resourceType: 'pods', - totalFindings: 3, - totalPassed: 1, - totalFailed: 2, - }, - { - resourceType: 'etcd', - totalFindings: 4, - totalPassed: 0, - totalFailed: 4, - }, - ]); - }); -}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.ts b/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.ts deleted file mode 100644 index 828d7f932113e..0000000000000 --- a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.ts +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ElasticsearchClient, IRouter, Logger } from 'src/core/server'; -import type { AggregationsMultiBucketAggregateBase } from '@elastic/elasticsearch/lib/api/types'; -import { number, UnknownRecord } from 'io-ts'; -import { transformError } from '@kbn/securitysolution-es-utils'; - -import type { BenchmarkStats, CloudPostureStats, Evaluation, Score } from '../../../common/types'; -import { - getBenchmarksQuery, - getFindingsEsQuery, - getLatestFindingQuery, - getRisksEsQuery, -} from './stats_queries'; -import { RULE_FAILED, RULE_PASSED } from '../../constants'; -import { STATS_ROUTE_PATH } from '../../../common/constants'; - -// TODO: use a schema decoder -function assertBenchmarkStats(v: unknown): asserts v is BenchmarkStats { - if ( - !UnknownRecord.is(v) || - !number.is(v.totalFindings) || - !number.is(v.totalPassed) || - !number.is(v.totalFailed) || - !number.is(v.postureScore) - ) { - throw new Error('missing stats'); - } -} - -interface LastCycle { - cycle_id: string; -} - -interface GroupFilename { - // TODO find the 'key', 'doc_count' interface - key: string; - doc_count: number; -} - -interface ResourceTypeBucket { - resource_types: AggregationsMultiBucketAggregateBase<{ - key: string; - doc_count: number; - bucket_evaluation: AggregationsMultiBucketAggregateBase; - }>; -} - -interface ResourceTypeEvaluationBucket { - key: Evaluation; - doc_count: number; -} - -/** - * @param value value is [0, 1] range - */ -export const roundScore = (value: number): Score => Number((value * 100).toFixed(1)); - -const calculatePostureScore = (total: number, passed: number, failed: number): Score | undefined => - passed + failed === 0 || total === undefined ? undefined : roundScore(passed / (passed + failed)); - -const getLatestCycleId = async (esClient: ElasticsearchClient) => { - const latestFinding = await esClient.search(getLatestFindingQuery(), { meta: true }); - const lastCycle = latestFinding.body.hits.hits[0]; - - if (lastCycle?._source?.cycle_id === undefined) { - throw new Error('cycle id is missing'); - } - return lastCycle?._source?.cycle_id; -}; - -export const getBenchmarks = async (esClient: ElasticsearchClient) => { - const queryResult = await esClient.search< - {}, - { benchmarks: AggregationsMultiBucketAggregateBase> } - >(getBenchmarksQuery(), { meta: true }); - const benchmarksBuckets = queryResult.body.aggregations?.benchmarks; - - if (!benchmarksBuckets || !Array.isArray(benchmarksBuckets?.buckets)) { - throw new Error('missing buckets'); - } - - return benchmarksBuckets.buckets.map((e) => e.key); -}; - -export const getAllFindingsStats = async ( - esClient: ElasticsearchClient, - cycleId: string -): Promise => { - const [findings, passedFindings, failedFindings] = await Promise.all([ - esClient.count(getFindingsEsQuery(cycleId), { meta: true }), - esClient.count(getFindingsEsQuery(cycleId, RULE_PASSED), { meta: true }), - esClient.count(getFindingsEsQuery(cycleId, RULE_FAILED), { meta: true }), - ]); - - const totalFindings = findings.body.count; - const totalPassed = passedFindings.body.count; - const totalFailed = failedFindings.body.count; - const postureScore = calculatePostureScore(totalFindings, totalPassed, totalFailed); - const stats = { - name: 'general', - postureScore, - totalFindings, - totalPassed, - totalFailed, - }; - - assertBenchmarkStats(stats); - - return stats; -}; - -export const getBenchmarksStats = async ( - esClient: ElasticsearchClient, - cycleId: string, - benchmarks: string[] -): Promise => { - const benchmarkPromises = benchmarks.map((benchmark) => { - const benchmarkFindings = esClient.count(getFindingsEsQuery(cycleId, undefined, benchmark), { - meta: true, - }); - const benchmarkPassedFindings = esClient.count( - getFindingsEsQuery(cycleId, RULE_PASSED, benchmark), - { meta: true } - ); - const benchmarkFailedFindings = esClient.count( - getFindingsEsQuery(cycleId, RULE_FAILED, benchmark), - { meta: true } - ); - - return Promise.all([benchmarkFindings, benchmarkPassedFindings, benchmarkFailedFindings]).then( - ([benchmarkFindingsResult, benchmarkPassedFindingsResult, benchmarkFailedFindingsResult]) => { - const totalFindings = benchmarkFindingsResult.body.count; - const totalPassed = benchmarkPassedFindingsResult.body.count; - const totalFailed = benchmarkFailedFindingsResult.body.count; - const postureScore = calculatePostureScore(totalFindings, totalPassed, totalFailed); - const stats = { - name: benchmark, - postureScore, - totalFindings, - totalPassed, - totalFailed, - }; - - assertBenchmarkStats(stats); - return stats; - } - ); - }); - - return Promise.all(benchmarkPromises); -}; - -export const getResourceTypesAggs = async ( - esClient: ElasticsearchClient, - cycleId: string -): Promise => { - const resourceTypesQueryResult = await esClient.search( - getRisksEsQuery(cycleId), - { meta: true } - ); - - const resourceTypesAggs = resourceTypesQueryResult.body.aggregations?.resource_types.buckets; - if (!Array.isArray(resourceTypesAggs)) throw new Error('missing resources types buckets'); - - return resourceTypesAggs.map((bucket) => { - const evalBuckets = bucket.bucket_evaluation.buckets; - if (!Array.isArray(evalBuckets)) throw new Error('missing resources types evaluations buckets'); - - const failedBucket = evalBuckets.find((evalBucket) => evalBucket.key === RULE_FAILED); - const passedBucket = evalBuckets.find((evalBucket) => evalBucket.key === RULE_PASSED); - - return { - resourceType: bucket.key, - totalFindings: bucket.doc_count, - totalFailed: failedBucket?.doc_count || 0, - totalPassed: passedBucket?.doc_count || 0, - }; - }); -}; - -export const defineGetStatsRoute = (router: IRouter, logger: Logger): void => - router.get( - { - path: STATS_ROUTE_PATH, - validate: false, - }, - async (context, _, response) => { - try { - const esClient = context.core.elasticsearch.client.asCurrentUser; - const [benchmarks, latestCycleID] = await Promise.all([ - getBenchmarks(esClient), - getLatestCycleId(esClient), - ]); - - // TODO: Utilize ES "Point in Time" feature https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html - const [allFindingsStats, benchmarksStats, resourceTypesAggs] = await Promise.all([ - getAllFindingsStats(esClient, latestCycleID), - getBenchmarksStats(esClient, latestCycleID, benchmarks), - getResourceTypesAggs(esClient, latestCycleID), - ]); - - const body: CloudPostureStats = { - ...allFindingsStats, - benchmarksStats, - resourceTypesAggs, - }; - - return response.ok({ - body, - }); - } catch (err) { - const error = transformError(err); - - return response.customError({ - body: { message: error.message }, - statusCode: error.statusCode, - }); - } - } - ); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats_queries.ts b/x-pack/plugins/cloud_security_posture/server/routes/stats/stats_queries.ts deleted file mode 100644 index b88182a27fee1..0000000000000 --- a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats_queries.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import type { - SearchRequest, - CountRequest, - QueryDslQueryContainer, -} from '@elastic/elasticsearch/lib/api/types'; - -import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; -import { Evaluation } from '../../../common/types'; - -export const getFindingsEsQuery = ( - cycleId: string, - evaluationResult?: string, - benchmark?: string -): CountRequest => { - const filter: QueryDslQueryContainer[] = [{ term: { 'cycle_id.keyword': cycleId } }]; - - if (benchmark) { - filter.push({ term: { 'rule.benchmark.keyword': benchmark } }); - } - - if (evaluationResult) { - filter.push({ term: { 'result.evaluation.keyword': evaluationResult } }); - } - - return { - index: CSP_KUBEBEAT_INDEX_PATTERN, - query: { - bool: { filter }, - }, - }; -}; - -export const getResourcesEvaluationEsQuery = ( - cycleId: string, - evaluation: Evaluation, - size: number, - resources?: string[] -): SearchRequest => { - const query: QueryDslQueryContainer = { - bool: { - filter: [ - { term: { 'cycle_id.keyword': cycleId } }, - { term: { 'result.evaluation.keyword': evaluation } }, - ], - }, - }; - if (resources) { - query.bool!.must = { terms: { 'resource.filename.keyword': resources } }; - } - return { - index: CSP_KUBEBEAT_INDEX_PATTERN, - size, - query, - aggs: { - group: { - terms: { field: 'resource.filename.keyword' }, - }, - }, - sort: 'resource.filename.keyword', - }; -}; - -export const getBenchmarksQuery = (): SearchRequest => ({ - index: CSP_KUBEBEAT_INDEX_PATTERN, - size: 0, - aggs: { - benchmarks: { - terms: { field: 'rule.benchmark.keyword' }, - }, - }, -}); - -export const getLatestFindingQuery = (): SearchRequest => ({ - index: CSP_KUBEBEAT_INDEX_PATTERN, - size: 1, - /* @ts-expect-error TS2322 - missing SearchSortContainer */ - sort: { '@timestamp': 'desc' }, - query: { - match_all: {}, - }, -}); - -export const getRisksEsQuery = (cycleId: string): SearchRequest => ({ - index: CSP_KUBEBEAT_INDEX_PATTERN, - size: 0, - query: { - bool: { - filter: [{ term: { 'cycle_id.keyword': cycleId } }], - }, - }, - aggs: { - resource_types: { - terms: { - field: 'resource.type.keyword', - }, - aggs: { - bucket_evaluation: { - terms: { - field: 'result.evaluation.keyword', - }, - }, - }, - }, - }, -}); diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts new file mode 100644 index 0000000000000..fcff7449fb3f5 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.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 { i18n } from '@kbn/i18n'; +import type { + SavedObjectsType, + SavedObjectsValidationMap, +} from '../../../../../../src/core/server'; +import { + type CspRuleSchema, + cspRuleSchema, + cspRuleAssetSavedObjectType, +} from '../../../common/schemas/csp_rule'; + +const validationMap: SavedObjectsValidationMap = { + '1.0.0': cspRuleSchema, +}; + +export const ruleAssetSavedObjectMappings: SavedObjectsType['mappings'] = { + dynamic: false, + properties: { + name: { + type: 'text', // search + fields: { + // TODO: how is fields mapping shared with UI ? + raw: { + type: 'keyword', // sort + }, + }, + }, + description: { + type: 'text', + }, + }, +}; + +export const cspRuleAssetType: SavedObjectsType = { + name: cspRuleAssetSavedObjectType, + hidden: false, + namespaceType: 'agnostic', + mappings: ruleAssetSavedObjectMappings, + schemas: validationMap, + // migrations: {} + management: { + importableAndExportable: true, + visibleInManagement: true, + getTitle: (savedObject) => + `${i18n.translate('xpack.csp.cspSettings.rules', { + defaultMessage: `CSP Security Rules - `, + })} ${savedObject.attributes.benchmark.name} ${savedObject.attributes.benchmark.version} ${ + savedObject.attributes.name + }`, + }, +}; diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts new file mode 100644 index 0000000000000..1cb08ddc1be1a --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ISavedObjectsRepository } from 'src/core/server'; +import { CIS_BENCHMARK_1_4_1_RULES } from './rules'; +import { cspRuleAssetSavedObjectType } from '../../../common/schemas/csp_rule'; + +export const initializeCspRules = async (client: ISavedObjectsRepository) => { + const existingRules = await client.find({ type: cspRuleAssetSavedObjectType, perPage: 1 }); + + // TODO: version? + if (existingRules.total !== 0) return; + + try { + await client.bulkCreate(CIS_BENCHMARK_1_4_1_RULES); + } catch (e) { + // TODO: add logger + // TODO: handle error + } +}; diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/rules.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/rules.ts new file mode 100644 index 0000000000000..8f3d6df65b6b5 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/rules.ts @@ -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 type { SavedObjectsBulkCreateObject } from 'src/core/server'; +import type { CspRuleSchema } from '../../../common/schemas/csp_rule'; +import { cspRuleAssetSavedObjectType } from '../../../common/schemas/csp_rule'; + +const benchmark = { name: 'CIS', version: '1.4.1' } as const; + +const RULES: CspRuleSchema[] = [ + { + id: '1.1.1', + name: 'Ensure that the API server pod specification file permissions are set to 644 or more restrictive (Automated)', + description: 'Disable anonymous requests to the API server', + rationale: + 'When enabled, requests that are not rejected by other configured authentication methods\nare treated as anonymous requests. These requests are then served by the API server. You\nshould rely on authentication to authorize access and disallow anonymous requests.\nIf you are using RBAC authorization, it is generally considered reasonable to allow\nanonymous access to the API Server for health checks and discovery purposes, and hence\nthis recommendation is not scored. However, you should consider whether anonymous\ndiscovery is an acceptable risk for your purposes.', + impact: 'Anonymous requests will be rejected.', + default_value: 'By default, anonymous access is enabled.', + remediation: + 'Edit the API server pod specification file /etc/kubernetes/manifests/kubeapiserver.yaml on the master node and set the below parameter.\n--anonymous-auth=false', + tags: [], + enabled: true, + muted: false, + benchmark, + }, + { + id: '1.1.2', + name: 'Ensure that the --basic-auth-file argument is not set (Scored)', + description: 'Do not use basic authentication', + rationale: + 'Basic authentication uses plaintext credentials for authentication. Currently, the basic\nauthentication credentials last indefinitely, and the password cannot be changed without\nrestarting API server. The basic authentication is currently supported for convenience.\nHence, basic authentication should not be used', + impact: + 'You will have to configure and use alternate authentication mechanisms such as tokens and\ncertificates. Username and password for basic authentication could no longer be used.', + default_value: 'By default, basic authentication is not set', + remediation: + 'Follow the documentation and configure alternate mechanisms for authentication. Then,\nedit the API server pod specification file /etc/kubernetes/manifests/kubeapiserver.yaml on the master node and remove the --basic-auth-file=\nparameter.', + tags: [], + enabled: true, + muted: false, + benchmark, + }, +]; + +export const CIS_BENCHMARK_1_4_1_RULES: Array> = + RULES.map((rule) => ({ + attributes: rule, + id: rule.id, + type: cspRuleAssetSavedObjectType, + })); diff --git a/x-pack/plugins/cloud_security_posture/server/types.ts b/x-pack/plugins/cloud_security_posture/server/types.ts index 707002461d2a6..4e70027013df8 100644 --- a/x-pack/plugins/cloud_security_posture/server/types.ts +++ b/x-pack/plugins/cloud_security_posture/server/types.ts @@ -10,6 +10,8 @@ import type { PluginStart as DataPluginStart, } from '../../../../src/plugins/data/server'; +import type { FleetStartContract } from '../../fleet/server'; + // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface CspServerPluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -25,6 +27,5 @@ export interface CspServerPluginSetupDeps { export interface CspServerPluginStartDeps { // required data: DataPluginStart; - - // optional + fleet: FleetStartContract; } diff --git a/x-pack/plugins/cloud_security_posture/tsconfig.json b/x-pack/plugins/cloud_security_posture/tsconfig.json index 47625c59eae6c..d7902b8b05977 100755 --- a/x-pack/plugins/cloud_security_posture/tsconfig.json +++ b/x-pack/plugins/cloud_security_posture/tsconfig.json @@ -19,6 +19,7 @@ "references": [ { "path": "../../../src/core/tsconfig.json" }, { "path": "../../../src/plugins/data/tsconfig.json" }, - { "path": "../../../src/plugins/navigation/tsconfig.json" } + { "path": "../../../src/plugins/navigation/tsconfig.json" }, + { "path": "../../../x-pack/plugins/fleet/tsconfig.json" }, ] } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts index b5309d8fedc1b..f262579f56c56 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts @@ -8,6 +8,7 @@ import { groups } from './groups.mock'; import { IndexingRule } from '../types'; +import { SourceConfigData } from '../views/content_sources/components/add_source/add_source_logic'; import { staticSourceData } from '../views/content_sources/source_data'; import { mergeServerAndStaticData } from '../views/content_sources/sources_logic'; @@ -339,23 +340,23 @@ export const mergedConfiguredSources = mergeServerAndStaticData( contentSources ); -export const sourceConfigData = { +export const sourceConfigData: SourceConfigData = { serviceType: 'confluence_cloud', name: 'Confluence', configured: true, needsPermissions: true, accountContextOnly: false, - supportedByLicense: true, privateSourcesEnabled: false, categories: ['wiki', 'atlassian', 'intranet'], configuredFields: { - isOauth1: false, clientId: 'CyztADsSECRETCSAUCEh1a', clientSecret: 'GSjJxqSECRETCSAUCEksHk', baseUrl: 'https://mine.atlassian.net', privateKey: '-----BEGIN PRIVATE KEY-----\nkeykeykeykey==\n-----END PRIVATE KEY-----\n', publicKey: '-----BEGIN PUBLIC KEY-----\nkeykeykeykey\n-----END PUBLIC KEY-----\n', consumerKey: 'elastic_enterprise_search_123', + apiKey: 'asdf1234', + url: 'https://www.elastic.co', }, }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts index fdccd536c3c6d..5b893250235f7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts @@ -19,6 +19,7 @@ import oneDrive from './onedrive.svg'; import salesforce from './salesforce.svg'; import serviceNow from './servicenow.svg'; import sharePoint from './sharepoint.svg'; +import sharePointServer from './sharepoint_server.svg'; import slack from './slack.svg'; import zendesk from './zendesk.svg'; @@ -29,6 +30,8 @@ export const images = { confluenceServer: confluence, custom, dropbox, + // TODO: For now external sources are all SharePoint. When this is no longer the case, this needs to be dynamic. + external: sharePoint, github, githubEnterpriseServer: github, githubViaApp: github, @@ -44,6 +47,7 @@ export const images = { salesforceSandbox: salesforce, serviceNow, sharePoint, + sharePointServer, slack, zendesk, } as { [key: string]: string }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/sharepoint_server.svg b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/sharepoint_server.svg new file mode 100644 index 0000000000000..aebfd7a8e49c0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/sharepoint_server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index 4510498465793..e83430504b389 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -192,6 +192,10 @@ export const SOURCE_NAMES = { 'xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.sharePoint', { defaultMessage: 'SharePoint Online' } ), + SHAREPOINT_SERVER: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.sharePointServer', + { defaultMessage: 'SharePoint Server' } + ), SLACK: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.slack', { defaultMessage: 'Slack', }), @@ -357,6 +361,7 @@ export const GITHUB_VIA_APP_SERVICE_TYPE = 'github_via_app'; export const GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE = 'github_enterprise_server_via_app'; export const CUSTOM_SERVICE_TYPE = 'custom'; +export const EXTERNAL_SERVICE_TYPE = 'external'; export const WORKPLACE_SEARCH_URL_PREFIX = '/app/enterprise_search/workplace_search'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index 4857fa2a158a0..cbcd1d885b120 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -7,11 +7,6 @@ import { generatePath } from 'react-router-dom'; -import { - GITHUB_VIA_APP_SERVICE_TYPE, - GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE, -} from './constants'; - export const SETUP_GUIDE_PATH = '/setup_guide'; export const NOT_FOUND_PATH = '/404'; @@ -40,25 +35,7 @@ export const PRIVATE_SOURCES_PATH = `${PERSONAL_PATH}${SOURCES_PATH}`; export const SOURCE_ADDED_PATH = `${SOURCES_PATH}/added`; export const ADD_SOURCE_PATH = `${SOURCES_PATH}/add`; -export const ADD_BOX_PATH = `${SOURCES_PATH}/add/box`; -export const ADD_CONFLUENCE_PATH = `${SOURCES_PATH}/add/confluence_cloud`; -export const ADD_CONFLUENCE_SERVER_PATH = `${SOURCES_PATH}/add/confluence_server`; -export const ADD_DROPBOX_PATH = `${SOURCES_PATH}/add/dropbox`; -export const ADD_GITHUB_ENTERPRISE_PATH = `${SOURCES_PATH}/add/github_enterprise_server`; -export const ADD_GITHUB_PATH = `${SOURCES_PATH}/add/github`; -export const ADD_GITHUB_VIA_APP_PATH = `${SOURCES_PATH}/add/${GITHUB_VIA_APP_SERVICE_TYPE}`; -export const ADD_GITHUB_ENTERPRISE_SERVER_VIA_APP_PATH = `${SOURCES_PATH}/add/${GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE}`; -export const ADD_GMAIL_PATH = `${SOURCES_PATH}/add/gmail`; -export const ADD_GOOGLE_DRIVE_PATH = `${SOURCES_PATH}/add/google_drive`; -export const ADD_JIRA_PATH = `${SOURCES_PATH}/add/jira_cloud`; -export const ADD_JIRA_SERVER_PATH = `${SOURCES_PATH}/add/jira_server`; -export const ADD_ONEDRIVE_PATH = `${SOURCES_PATH}/add/one_drive`; -export const ADD_SALESFORCE_PATH = `${SOURCES_PATH}/add/salesforce`; -export const ADD_SALESFORCE_SANDBOX_PATH = `${SOURCES_PATH}/add/salesforce_sandbox`; -export const ADD_SERVICENOW_PATH = `${SOURCES_PATH}/add/servicenow`; -export const ADD_SHAREPOINT_PATH = `${SOURCES_PATH}/add/share_point`; -export const ADD_SLACK_PATH = `${SOURCES_PATH}/add/slack`; -export const ADD_ZENDESK_PATH = `${SOURCES_PATH}/add/zendesk`; +export const ADD_EXTERNAL_PATH = `${SOURCES_PATH}/add/external`; export const ADD_CUSTOM_PATH = `${SOURCES_PATH}/add/custom`; export const PERSONAL_SETTINGS_PATH = `${PERSONAL_PATH}/settings`; @@ -83,24 +60,6 @@ export const ORG_SETTINGS_PATH = '/settings'; export const ORG_SETTINGS_CUSTOMIZE_PATH = `${ORG_SETTINGS_PATH}/customize`; export const ORG_SETTINGS_CONNECTORS_PATH = `${ORG_SETTINGS_PATH}/connectors`; export const ORG_SETTINGS_OAUTH_APPLICATION_PATH = `${ORG_SETTINGS_PATH}/oauth`; -export const EDIT_BOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/box/edit`; -export const EDIT_CONFLUENCE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/confluence_cloud/edit`; -export const EDIT_CONFLUENCE_SERVER_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/confluence_server/edit`; -export const EDIT_DROPBOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/dropbox/edit`; -export const EDIT_GITHUB_ENTERPRISE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/github_enterprise_server/edit`; -export const EDIT_GITHUB_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/github/edit`; -export const EDIT_GMAIL_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/gmail/edit`; -export const EDIT_GOOGLE_DRIVE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/google_drive/edit`; -export const EDIT_JIRA_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/jira_cloud/edit`; -export const EDIT_JIRA_SERVER_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/jira_server/edit`; -export const EDIT_ONEDRIVE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/one_drive/edit`; -export const EDIT_SALESFORCE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/salesforce/edit`; -export const EDIT_SALESFORCE_SANDBOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/salesforce_sandbox/edit`; -export const EDIT_SERVICENOW_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/servicenow/edit`; -export const EDIT_SHAREPOINT_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/share_point/edit`; -export const EDIT_SLACK_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/slack/edit`; -export const EDIT_ZENDESK_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/zendesk/edit`; -export const EDIT_CUSTOM_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/custom/edit`; export const getContentSourcePath = ( path: string, @@ -118,3 +77,6 @@ export const getReindexJobRoute = ( isOrganization: boolean ) => getSourcesPath(generatePath(REINDEX_JOB_PATH, { sourceId, activeReindexJobId }), isOrganization); +export const getAddPath = (serviceType: string): string => `${SOURCES_PATH}/add/${serviceType}`; +export const getEditPath = (serviceType: string): string => + `${ORG_SETTINGS_CONNECTORS_PATH}/${serviceType}/edit`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts index b01700b8bce34..b3bfebcd6b295 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -66,23 +66,27 @@ export interface Configuration { needsConfiguration?: boolean; hasOauthRedirect: boolean; baseUrlTitle?: string; - helpText: string; + helpText?: string; documentationUrl: string; applicationPortalUrl?: string; applicationLinkTitle?: string; + githubRepository?: string; } export interface SourceDataItem { name: string; + iconName: string; + categories?: string[]; serviceType: string; configuration: Configuration; configured?: boolean; connected?: boolean; features?: Features; objTypes?: string[]; - addPath: string; - editPath?: string; // undefined for GitHub apps, as they are configured on a source level, and don't use a connector where you can edit the configuration accountContextOnly: boolean; + internalConnectorAvailable?: boolean; + externalConnectorAvailable?: boolean; + customConnectorAvailable?: boolean; } export interface ContentSource { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts new file mode 100644 index 0000000000000..fbfda1ddf8d5e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SourceDataItem } from '../types'; + +export const hasMultipleConnectorOptions = ({ + internalConnectorAvailable, + externalConnectorAvailable, + customConnectorAvailable, +}: SourceDataItem) => + [externalConnectorAvailable, internalConnectorAvailable, customConnectorAvailable].filter( + (available) => !!available + ).length > 1; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts index 92f27500d7262..86d3e4f844bbd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts @@ -11,3 +11,5 @@ export { mimeType } from './mime_types'; export { readUploadedFileAsBase64 } from './read_uploaded_file_as_base64'; export { readUploadedFileAsText } from './read_uploaded_file_as_text'; export { handlePrivateKeyUpload } from './handle_private_key_upload'; +export { hasMultipleConnectorOptions } from './has_multiple_connector_options'; +export { isNotNullish } from './is_not_nullish'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/is_not_nullish.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/is_not_nullish.ts new file mode 100644 index 0000000000000..d492dad5d52c2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/is_not_nullish.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 function isNotNullish(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.test.tsx new file mode 100644 index 0000000000000..b13cc6583cf2f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.test.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockValues } from '../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { staticSourceData } from '../../source_data'; + +import { AddCustomSource } from './add_custom_source'; +import { AddCustomSourceSteps } from './add_custom_source_logic'; +import { ConfigureCustom } from './configure_custom'; +import { SaveCustom } from './save_custom'; + +describe('AddCustomSource', () => { + const props = { + sourceData: staticSourceData[0], + initialValues: undefined, + }; + + const values = { + sourceConfigData, + isOrganization: true, + }; + + beforeEach(() => { + setMockValues({ ...values }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(1); + }); + + it('should show correct layout for personal dashboard', () => { + setMockValues({ isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(0); + expect(wrapper.find(PersonalDashboardLayout)).toHaveLength(1); + }); + + it('should show Configure Custom for custom configuration step', () => { + setMockValues({ currentStep: AddCustomSourceSteps.ConfigureCustomStep }); + const wrapper = shallow(); + + expect(wrapper.find(ConfigureCustom)).toHaveLength(1); + }); + + it('should show Save Custom for save custom step', () => { + setMockValues({ currentStep: AddCustomSourceSteps.SaveCustomStep }); + const wrapper = shallow(); + + expect(wrapper.find(SaveCustom)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.tsx new file mode 100644 index 0000000000000..6f7dc2bcdb342 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues } from 'kea'; + +import { AppLogic } from '../../../../app_logic'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { NAV } from '../../../../constants'; + +import { SourceDataItem } from '../../../../types'; + +import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic'; +import { ConfigureCustom } from './configure_custom'; +import { SaveCustom } from './save_custom'; + +import './add_source.scss'; + +interface Props { + sourceData: SourceDataItem; + initialValue?: string; +} +export const AddCustomSource: React.FC = ({ sourceData, initialValue = '' }) => { + const addCustomSourceLogic = AddCustomSourceLogic({ sourceData, initialValue }); + const { currentStep } = useValues(addCustomSourceLogic); + const { isOrganization } = useValues(AppLogic); + + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + + return ( + + {currentStep === AddCustomSourceSteps.ConfigureCustomStep && } + {currentStep === AddCustomSourceSteps.SaveCustomStep && } + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.test.ts new file mode 100644 index 0000000000000..9360967985876 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.test.ts @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + LogicMounter, + mockFlashMessageHelpers, + mockHttpValues, +} from '../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; + +import { i18n } from '@kbn/i18n'; +import { nextTick } from '@kbn/test-jest-helpers'; + +import { docLinks } from '../../../../../shared/doc_links'; +import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; + +jest.mock('../../../../app_logic', () => ({ + AppLogic: { values: { isOrganization: true } }, +})); +import { AppLogic } from '../../../../app_logic'; + +import { SOURCE_NAMES } from '../../../../constants'; +import { CustomSource, SourceDataItem } from '../../../../types'; + +import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic'; + +const CUSTOM_SOURCE_DATA_ITEM: SourceDataItem = { + name: SOURCE_NAMES.CUSTOM, + iconName: SOURCE_NAMES.CUSTOM, + serviceType: 'custom', + configuration: { + isPublicKey: false, + hasOauthRedirect: false, + needsBaseUrl: false, + helpText: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.helpText.custom', { + defaultMessage: + 'To create a Custom API Source, provide a human-readable and descriptive name. The name will appear as-is in the various search experiences and management interfaces.', + }), + documentationUrl: docLinks.workplaceSearchCustomSources, + applicationPortalUrl: '', + }, + accountContextOnly: false, +}; + +const DEFAULT_VALUES = { + currentStep: AddCustomSourceSteps.ConfigureCustomStep, + buttonLoading: false, + customSourceNameValue: '', + newCustomSource: {} as CustomSource, + sourceData: CUSTOM_SOURCE_DATA_ITEM, +}; + +const MOCK_PROPS = { initialValue: '', sourceData: CUSTOM_SOURCE_DATA_ITEM }; + +const MOCK_NAME = 'name'; + +describe('AddCustomSourceLogic', () => { + const { mount } = new LogicMounter(AddCustomSourceLogic); + const { http } = mockHttpValues; + const { clearFlashMessages } = mockFlashMessageHelpers; + + beforeEach(() => { + jest.clearAllMocks(); + mount({}, MOCK_PROPS); + }); + + it('has expected default values', () => { + expect(AddCustomSourceLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('setButtonNotLoading', () => { + it('turns off the button loading flag', () => { + AddCustomSourceLogic.actions.setButtonNotLoading(); + + expect(AddCustomSourceLogic.values).toEqual({ + ...DEFAULT_VALUES, + buttonLoading: false, + }); + }); + }); + + describe('setCustomSourceNameValue', () => { + it('saves the name', () => { + AddCustomSourceLogic.actions.setCustomSourceNameValue('name'); + + expect(AddCustomSourceLogic.values).toEqual({ + ...DEFAULT_VALUES, + customSourceNameValue: 'name', + }); + }); + }); + + describe('setNewCustomSource', () => { + it('saves the custom source', () => { + const newCustomSource = { + accessToken: 'foo', + key: 'bar', + name: 'source', + id: '123key', + }; + + AddCustomSourceLogic.actions.setNewCustomSource(newCustomSource); + + expect(AddCustomSourceLogic.values).toEqual({ + ...DEFAULT_VALUES, + newCustomSource, + currentStep: AddCustomSourceSteps.SaveCustomStep, + }); + }); + }); + }); + + describe('listeners', () => { + beforeEach(() => { + mount( + { + customSourceNameValue: MOCK_NAME, + }, + MOCK_PROPS + ); + }); + + describe('organization context', () => { + describe('createContentSource', () => { + it('calls API and sets values', async () => { + const setButtonNotLoadingSpy = jest.spyOn( + AddCustomSourceLogic.actions, + 'setButtonNotLoading' + ); + const setNewCustomSourceSpy = jest.spyOn( + AddCustomSourceLogic.actions, + 'setNewCustomSource' + ); + http.post.mockReturnValue(Promise.resolve({ sourceConfigData })); + + AddCustomSourceLogic.actions.createContentSource(); + + expect(clearFlashMessages).toHaveBeenCalled(); + expect(AddCustomSourceLogic.values.buttonLoading).toEqual(true); + expect(http.post).toHaveBeenCalledWith('/internal/workplace_search/org/create_source', { + body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME }), + }); + await nextTick(); + expect(setNewCustomSourceSpy).toHaveBeenCalledWith({ sourceConfigData }); + expect(setButtonNotLoadingSpy).toHaveBeenCalled(); + }); + + itShowsServerErrorAsFlashMessage(http.post, () => { + AddCustomSourceLogic.actions.createContentSource(); + }); + }); + }); + + describe('account context routes', () => { + beforeEach(() => { + AppLogic.values.isOrganization = false; + }); + + describe('createContentSource', () => { + it('sends relevant fields to the API', () => { + AddCustomSourceLogic.actions.createContentSource(); + + expect(http.post).toHaveBeenCalledWith( + '/internal/workplace_search/account/create_source', + { + body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME }), + } + ); + }); + + itShowsServerErrorAsFlashMessage(http.post, () => { + AddCustomSourceLogic.actions.createContentSource(); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.ts new file mode 100644 index 0000000000000..5bf86f6df41c7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors, clearFlashMessages } from '../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../shared/http'; +import { AppLogic } from '../../../../app_logic'; +import { CustomSource, SourceDataItem } from '../../../../types'; + +export interface AddCustomSourceProps { + sourceData: SourceDataItem; + initialValue: string; +} + +export enum AddCustomSourceSteps { + ConfigureCustomStep = 'Configure Custom', + SaveCustomStep = 'Save Custom', +} + +export interface AddCustomSourceActions { + createContentSource(): void; + setButtonNotLoading(): void; + setCustomSourceNameValue(customSourceNameValue: string): string; + setNewCustomSource(data: CustomSource): CustomSource; +} + +interface AddCustomSourceValues { + buttonLoading: boolean; + currentStep: AddCustomSourceSteps; + customSourceNameValue: string; + newCustomSource: CustomSource; + sourceData: SourceDataItem; +} + +/** + * Workplace Search needs to know the host for the redirect. As of yet, we do not + * have access to this in Kibana. We parse it from the browser and pass it as a param. + */ + +export const AddCustomSourceLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'workplace_search', 'add_custom_source_logic'], + actions: { + createContentSource: true, + setButtonNotLoading: true, + setCustomSourceNameValue: (customSourceNameValue) => customSourceNameValue, + setNewCustomSource: (data) => data, + }, + reducers: ({ props }) => ({ + buttonLoading: [ + false, + { + setButtonNotLoading: () => false, + createContentSource: () => true, + }, + ], + currentStep: [ + AddCustomSourceSteps.ConfigureCustomStep, + { + setNewCustomSource: () => AddCustomSourceSteps.SaveCustomStep, + }, + ], + customSourceNameValue: [ + props.initialValue, + { + setCustomSourceNameValue: (_, customSourceNameValue) => customSourceNameValue, + }, + ], + newCustomSource: [ + {} as CustomSource, + { + setNewCustomSource: (_, newCustomSource) => newCustomSource, + }, + ], + sourceData: [props.sourceData], + }), + listeners: ({ actions, values }) => ({ + createContentSource: async () => { + clearFlashMessages(); + const { isOrganization } = AppLogic.values; + const route = isOrganization + ? '/internal/workplace_search/org/create_source' + : '/internal/workplace_search/account/create_source'; + + const { customSourceNameValue } = values; + + const params = { + service_type: 'custom', + name: customSourceNameValue, + }; + + try { + const response = await HttpLogic.values.http.post(route, { + body: JSON.stringify({ ...params }), + }); + actions.setNewCustomSource(response); + } catch (e) { + flashAPIErrors(e); + } finally { + actions.setButtonNotLoading(); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx index 0501509b3a8ef..4598ca337f4e2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx @@ -22,16 +22,16 @@ import { PersonalDashboardLayout, } from '../../../../components/layout'; +import { staticSourceData } from '../../source_data'; + import { AddSource } from './add_source'; import { AddSourceSteps } from './add_source_logic'; import { ConfigCompleted } from './config_completed'; import { ConfigurationIntro } from './configuration_intro'; -import { ConfigureCustom } from './configure_custom'; import { ConfigureOauth } from './configure_oauth'; import { ConnectInstance } from './connect_instance'; import { Reauthenticate } from './reauthenticate'; import { SaveConfig } from './save_config'; -import { SaveCustom } from './save_custom'; describe('AddSourceList', () => { const { navigateToUrl } = mockKibanaValues; @@ -65,7 +65,7 @@ describe('AddSourceList', () => { }); it('renders default state', () => { - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(ConfigurationIntro).prop('advanceStep')(); expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); @@ -74,14 +74,14 @@ describe('AddSourceList', () => { describe('layout', () => { it('renders the default workplace search layout when on an organization view', () => { setMockValues({ ...mockValues, isOrganization: true }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); }); it('renders the personal dashboard layout when not in an organization', () => { setMockValues({ ...mockValues, isOrganization: false }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.type()).toEqual(PersonalDashboardLayout); }); @@ -89,7 +89,7 @@ describe('AddSourceList', () => { it('renders a breadcrumb fallback while data is loading', () => { setMockValues({ ...mockValues, dataLoading: true, sourceConfigData: {} }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.prop('pageChrome')).toEqual(['Sources', 'Add Source', '...']); }); @@ -99,7 +99,7 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.ConfigCompletedStep, }); - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(ConfigCompleted).prop('advanceStep')(); expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); @@ -111,7 +111,7 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.SaveConfigStep, }); - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); saveConfig.prop('advanceStep')(); saveConfig.prop('goBackStep')!(); @@ -126,51 +126,30 @@ describe('AddSourceList', () => { sourceConfigData, addSourceCurrentStep: AddSourceSteps.ConnectInstanceStep, }); - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(ConnectInstance).prop('onFormCreated')('foo'); expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); }); - it('renders Configure Custom step', () => { - setMockValues({ - ...mockValues, - addSourceCurrentStep: AddSourceSteps.ConfigureCustomStep, - }); - const wrapper = shallow(); - wrapper.find(ConfigureCustom).prop('advanceStep')(); - - expect(createContentSource).toHaveBeenCalled(); - }); - it('renders Configure Oauth step', () => { setMockValues({ ...mockValues, addSourceCurrentStep: AddSourceSteps.ConfigureOauthStep, }); - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(ConfigureOauth).prop('onFormCreated')('foo'); expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); }); - it('renders Save Custom step', () => { - setMockValues({ - ...mockValues, - addSourceCurrentStep: AddSourceSteps.SaveCustomStep, - }); - const wrapper = shallow(); - - expect(wrapper.find(SaveCustom)).toHaveLength(1); - }); - it('renders Reauthenticate step', () => { setMockValues({ ...mockValues, addSourceCurrentStep: AddSourceSteps.ReauthenticateStep, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(Reauthenticate)).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx index f575ddb19ebdc..1e9be74224c5e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx @@ -18,49 +18,28 @@ import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, } from '../../../../components/layout'; -import { NAV, CUSTOM_SERVICE_TYPE } from '../../../../constants'; -import { SOURCES_PATH, getSourcesPath } from '../../../../routes'; -import { SourceDataItem } from '../../../../types'; -import { staticSourceData } from '../../source_data'; +import { NAV } from '../../../../constants'; +import { SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes'; import { AddSourceHeader } from './add_source_header'; import { AddSourceLogic, AddSourceProps, AddSourceSteps } from './add_source_logic'; import { ConfigCompleted } from './config_completed'; import { ConfigurationIntro } from './configuration_intro'; -import { ConfigureCustom } from './configure_custom'; import { ConfigureOauth } from './configure_oauth'; import { ConnectInstance } from './connect_instance'; import { Reauthenticate } from './reauthenticate'; import { SaveConfig } from './save_config'; -import { SaveCustom } from './save_custom'; import './add_source.scss'; export const AddSource: React.FC = (props) => { - const { - initializeAddSource, - setAddSourceStep, - saveSourceConfig, - createContentSource, - resetSourceState, - } = useActions(AddSourceLogic); - const { - addSourceCurrentStep, - sourceConfigData: { - name, - categories, - needsPermissions, - accountContextOnly, - privateSourcesEnabled, - }, - dataLoading, - newCustomSource, - } = useValues(AddSourceLogic); - - const { serviceType, configuration, features, objTypes, addPath } = staticSourceData[ - props.sourceIndex - ] as SourceDataItem; - + const { initializeAddSource, setAddSourceStep, saveSourceConfig, resetSourceState } = + useActions(AddSourceLogic); + const { addSourceCurrentStep, sourceConfigData, dataLoading } = useValues(AddSourceLogic); + const { name, categories, needsPermissions, accountContextOnly, privateSourcesEnabled } = + sourceConfigData; + const { serviceType, configuration, features, objTypes } = props.sourceData; + const addPath = getAddPath(serviceType); const { isOrganization } = useValues(AppLogic); useEffect(() => { @@ -85,9 +64,6 @@ export const AddSource: React.FC = (props) => { KibanaLogic.values.navigateToUrl(`${getSourcesPath(addPath, isOrganization)}/connect`); }; - const saveCustomSuccess = () => setAddSourceStep(AddSourceSteps.SaveCustomStep); - const goToSaveCustom = () => createContentSource(CUSTOM_SERVICE_TYPE, saveCustomSuccess); - const goToFormSourceCreated = () => { KibanaLogic.values.navigateToUrl(`${getSourcesPath(SOURCES_PATH, isOrganization)}`); flashSuccessToast(FORM_SOURCE_ADDED_SUCCESS_MESSAGE); @@ -131,24 +107,9 @@ export const AddSource: React.FC = (props) => { header={header} /> )} - {addSourceCurrentStep === AddSourceSteps.ConfigureCustomStep && ( - - )} {addSourceCurrentStep === AddSourceSteps.ConfigureOauthStep && ( )} - {addSourceCurrentStep === AddSourceSteps.SaveCustomStep && ( - - )} {addSourceCurrentStep === AddSourceSteps.ReauthenticateStep && ( )} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx index 08e002ee432a9..15160abb42809 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx @@ -27,7 +27,7 @@ import { } from '../../../../components/layout'; import { ContentSection } from '../../../../components/shared/content_section'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; -import { NAV, CUSTOM_SERVICE_TYPE } from '../../../../constants'; +import { NAV, CUSTOM_SERVICE_TYPE, EXTERNAL_SERVICE_TYPE } from '../../../../constants'; import { SourceDataItem } from '../../../../types'; import { SourcesLogic } from '../../sources_logic'; @@ -90,12 +90,12 @@ export const AddSourceList: React.FC = () => { const filterConfiguredSources = (source: SourceDataItem) => filterSources(source, configuredSources); - const visibleAvailableSources = availableSources.filter( - filterAvailableSources - ) as SourceDataItem[]; - const visibleConfiguredSources = configuredSources.filter( - filterConfiguredSources - ) as SourceDataItem[]; + const visibleAvailableSources = availableSources + .filter(filterAvailableSources) + .filter((source) => source.serviceType !== EXTERNAL_SERVICE_TYPE); + // The API returns available external sources as a separate entry, but we don't want to present them as options to add + + const visibleConfiguredSources = configuredSources.filter(filterConfiguredSources); const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts index 65ccd8d95256e..80f8a2fc18218 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts @@ -15,6 +15,7 @@ import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; import { nextTick } from '@kbn/test-jest-helpers'; +import { docLinks } from '../../../../../shared/doc_links'; import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; jest.mock('../../../../app_logic', () => ({ @@ -22,13 +23,9 @@ jest.mock('../../../../app_logic', () => ({ })); import { AppLogic } from '../../../../app_logic'; -import { - ADD_GITHUB_PATH, - SOURCES_PATH, - PRIVATE_SOURCES_PATH, - getSourcesPath, -} from '../../../../routes'; -import { CustomSource } from '../../../../types'; +import { SOURCE_NAMES, SOURCE_OBJ_TYPES } from '../../../../constants'; +import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath } from '../../../../routes'; +import { FeatureIds } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; import { SourcesLogic } from '../../sources_logic'; @@ -38,6 +35,8 @@ import { SourceConfigData, SourceConnectData, OrganizationsMap, + AddSourceValues, + AddSourceProps, } from './add_source_logic'; describe('AddSourceLogic', () => { @@ -46,13 +45,12 @@ describe('AddSourceLogic', () => { const { navigateToUrl } = mockKibanaValues; const { clearFlashMessages, flashAPIErrors, setErrorMessage } = mockFlashMessageHelpers; - const DEFAULT_VALUES = { + const DEFAULT_VALUES: AddSourceValues = { addSourceCurrentStep: AddSourceSteps.ConfigIntroStep, - addSourceProps: {}, + addSourceProps: {} as AddSourceProps, dataLoading: true, sectionLoading: true, buttonLoading: false, - customSourceNameValue: '', clientIdValue: '', clientSecretValue: '', baseUrlValue: '', @@ -62,7 +60,6 @@ describe('AddSourceLogic', () => { indexPermissionsValue: false, sourceConfigData: {} as SourceConfigData, sourceConnectData: {} as SourceConnectData, - newCustomSource: {} as CustomSource, oauthConfigCompleted: false, currentServiceType: '', githubOrganizations: [], @@ -81,8 +78,34 @@ describe('AddSourceLogic', () => { serviceType: 'github', githubOrganizations: ['foo', 'bar'], }; - - const CUSTOM_SERVICE_TYPE_INDEX = 17; + const DEFAULT_SERVICE_TYPE = { + name: SOURCE_NAMES.BOX, + iconName: SOURCE_NAMES.BOX, + serviceType: 'box', + configuration: { + isPublicKey: false, + hasOauthRedirect: true, + needsBaseUrl: false, + documentationUrl: docLinks.workplaceSearchBox, + applicationPortalUrl: 'https://app.box.com/developers/console', + }, + objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.ALL_FILES], + features: { + basicOrgContext: [ + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + FeatureIds.GlobalAccessPermissions, + ], + basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], + platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], + platinumPrivateContext: [ + FeatureIds.Private, + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + ], + }, + accountContextOnly: false, + }; beforeEach(() => { jest.clearAllMocks(); @@ -145,15 +168,6 @@ describe('AddSourceLogic', () => { }); }); - it('setCustomSourceNameValue', () => { - AddSourceLogic.actions.setCustomSourceNameValue('name'); - - expect(AddSourceLogic.values).toEqual({ - ...DEFAULT_VALUES, - customSourceNameValue: 'name', - }); - }); - it('setSourceLoginValue', () => { AddSourceLogic.actions.setSourceLoginValue('login'); @@ -190,22 +204,6 @@ describe('AddSourceLogic', () => { }); }); - it('setCustomSourceData', () => { - const newCustomSource = { - accessToken: 'foo', - key: 'bar', - name: 'source', - id: '123key', - }; - - AddSourceLogic.actions.setCustomSourceData(newCustomSource); - - expect(AddSourceLogic.values).toEqual({ - ...DEFAULT_VALUES, - newCustomSource, - }); - }); - it('setPreContentSourceConfigData', () => { AddSourceLogic.actions.setPreContentSourceConfigData(config); @@ -260,13 +258,14 @@ describe('AddSourceLogic', () => { }); it('handles fallback states', () => { - const { publicKey, privateKey, consumerKey } = sourceConfigData.configuredFields; - const sourceConfigDataMock = { + const { publicKey, privateKey, consumerKey, apiKey } = sourceConfigData.configuredFields; + const sourceConfigDataMock: SourceConfigData = { ...sourceConfigData, configuredFields: { publicKey, privateKey, consumerKey, + apiKey, }, }; AddSourceLogic.actions.setSourceConfigData(sourceConfigDataMock); @@ -284,7 +283,7 @@ describe('AddSourceLogic', () => { describe('listeners', () => { it('initializeAddSource', () => { - const addSourceProps = { sourceIndex: 1 }; + const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE }; const getSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'getSourceConfigData'); const setAddSourcePropsSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceProps'); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); @@ -293,21 +292,13 @@ describe('AddSourceLogic', () => { expect(setAddSourcePropsSpy).toHaveBeenCalledWith({ addSourceProps }); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigIntroStep); - expect(getSourceConfigDataSpy).toHaveBeenCalledWith('confluence_cloud'); + expect(getSourceConfigDataSpy).toHaveBeenCalledWith('box'); }); describe('getFirstStep', () => { - it('sets custom as first step', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceIndex: CUSTOM_SERVICE_TYPE_INDEX }; - AddSourceLogic.actions.initializeAddSource(addSourceProps); - - expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigureCustomStep); - }); - it('sets connect as first step', () => { const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceIndex: 1, connect: true }; + const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, connect: true }; AddSourceLogic.actions.initializeAddSource(addSourceProps); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); @@ -315,7 +306,7 @@ describe('AddSourceLogic', () => { it('sets configure as first step', () => { const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceIndex: 1, configure: true }; + const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, configure: true }; AddSourceLogic.actions.initializeAddSource(addSourceProps); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigureOauthStep); @@ -323,7 +314,7 @@ describe('AddSourceLogic', () => { it('sets reAuthenticate as first step', () => { const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceIndex: 1, reAuthenticate: true }; + const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, reAuthenticate: true }; AddSourceLogic.actions.initializeAddSource(addSourceProps); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ReauthenticateStep); @@ -401,7 +392,7 @@ describe('AddSourceLogic', () => { await nextTick(); expect(setPreContentSourceIdSpy).toHaveBeenCalledWith(preContentSourceId); - expect(navigateToUrl).toHaveBeenCalledWith(`${ADD_GITHUB_PATH}/configure${queryString}`); + expect(navigateToUrl).toHaveBeenCalledWith(`/sources/add/github/configure${queryString}`); }); describe('Github error edge case', () => { @@ -635,7 +626,6 @@ describe('AddSourceLogic', () => { const errorCallback = jest.fn(); const serviceType = 'zendesk'; - const name = 'name'; const login = 'login'; const password = 'password'; const indexPermissions = false; @@ -643,7 +633,6 @@ describe('AddSourceLogic', () => { let params: any; beforeEach(() => { - AddSourceLogic.actions.setCustomSourceNameValue(name); AddSourceLogic.actions.setSourceLoginValue(login); AddSourceLogic.actions.setSourcePasswordValue(password); AddSourceLogic.actions.setPreContentSourceConfigData(config); @@ -652,7 +641,6 @@ describe('AddSourceLogic', () => { params = { service_type: serviceType, - name, login, password, organizations: ['foo'], @@ -661,8 +649,7 @@ describe('AddSourceLogic', () => { it('calls API and sets values', async () => { const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); - const setCustomSourceDataSpy = jest.spyOn(AddSourceLogic.actions, 'setCustomSourceData'); - http.post.mockReturnValue(Promise.resolve({ sourceConfigData })); + http.post.mockReturnValue(Promise.resolve()); AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); @@ -672,7 +659,6 @@ describe('AddSourceLogic', () => { body: JSON.stringify({ ...params }), }); await nextTick(); - expect(setCustomSourceDataSpy).toHaveBeenCalledWith({ sourceConfigData }); expect(successCallback).toHaveBeenCalled(); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts index 6dbac2dcd1452..db0c5b9737263 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts @@ -21,20 +21,14 @@ import { import { HttpLogic } from '../../../../../shared/http'; import { KibanaLogic } from '../../../../../shared/kibana'; import { AppLogic } from '../../../../app_logic'; -import { CUSTOM_SERVICE_TYPE, WORKPLACE_SEARCH_URL_PREFIX } from '../../../../constants'; -import { - SOURCES_PATH, - ADD_GITHUB_PATH, - PRIVATE_SOURCES_PATH, - getSourcesPath, -} from '../../../../routes'; -import { CustomSource } from '../../../../types'; +import { WORKPLACE_SEARCH_URL_PREFIX } from '../../../../constants'; +import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes'; +import { SourceDataItem } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; -import { staticSourceData } from '../../source_data'; import { SourcesLogic } from '../../sources_logic'; export interface AddSourceProps { - sourceIndex: number; + sourceData: SourceDataItem; connect?: boolean; configure?: boolean; reAuthenticate?: boolean; @@ -45,9 +39,7 @@ export enum AddSourceSteps { SaveConfigStep = 'Save Config', ConfigCompletedStep = 'Config Completed', ConnectInstanceStep = 'Connect Instance', - ConfigureCustomStep = 'Configure Custom', ConfigureOauthStep = 'Configure Oauth', - SaveCustomStep = 'Save Custom', ReauthenticateStep = 'Reauthenticate', } @@ -71,12 +63,10 @@ export interface AddSourceActions { setClientIdValue(clientIdValue: string): string; setClientSecretValue(clientSecretValue: string): string; setBaseUrlValue(baseUrlValue: string): string; - setCustomSourceNameValue(customSourceNameValue: string): string; setSourceLoginValue(loginValue: string): string; setSourcePasswordValue(passwordValue: string): string; setSourceSubdomainValue(subdomainValue: string): string; setSourceIndexPermissionsValue(indexPermissionsValue: boolean): boolean; - setCustomSourceData(data: CustomSource): CustomSource; setPreContentSourceConfigData(data: PreContentSourceResponse): PreContentSourceResponse; setPreContentSourceId(preContentSourceId: string): string; setSelectedGithubOrganizations(option: string): string; @@ -119,6 +109,8 @@ export interface SourceConfigData { baseUrl?: string; clientId?: string; clientSecret?: string; + url?: string; + apiKey?: string; }; accountContextOnly?: boolean; } @@ -132,13 +124,12 @@ export interface OrganizationsMap { [key: string]: string | boolean; } -interface AddSourceValues { +export interface AddSourceValues { addSourceProps: AddSourceProps; addSourceCurrentStep: AddSourceSteps; dataLoading: boolean; sectionLoading: boolean; buttonLoading: boolean; - customSourceNameValue: string; clientIdValue: string; clientSecretValue: string; baseUrlValue: string; @@ -148,7 +139,6 @@ interface AddSourceValues { indexPermissionsValue: boolean; sourceConfigData: SourceConfigData; sourceConnectData: SourceConnectData; - newCustomSource: CustomSource; currentServiceType: string; githubOrganizations: string[]; selectedGithubOrganizationsMap: OrganizationsMap; @@ -185,12 +175,10 @@ export const AddSourceLogic = kea clientIdValue, setClientSecretValue: (clientSecretValue: string) => clientSecretValue, setBaseUrlValue: (baseUrlValue: string) => baseUrlValue, - setCustomSourceNameValue: (customSourceNameValue: string) => customSourceNameValue, setSourceLoginValue: (loginValue: string) => loginValue, setSourcePasswordValue: (passwordValue: string) => passwordValue, setSourceSubdomainValue: (subdomainValue: string) => subdomainValue, setSourceIndexPermissionsValue: (indexPermissionsValue: boolean) => indexPermissionsValue, - setCustomSourceData: (data: CustomSource) => data, setPreContentSourceConfigData: (data: PreContentSourceResponse) => data, setPreContentSourceId: (preContentSourceId: string) => preContentSourceId, setSelectedGithubOrganizations: (option: string) => option, @@ -322,20 +310,6 @@ export const AddSourceLogic = kea false, }, ], - customSourceNameValue: [ - '', - { - setCustomSourceNameValue: (_, customSourceNameValue) => customSourceNameValue, - resetSourceState: () => '', - }, - ], - newCustomSource: [ - {} as CustomSource, - { - setCustomSourceData: (_, newCustomSource) => newCustomSource, - resetSourceState: () => ({} as CustomSource), - }, - ], currentServiceType: [ '', { @@ -383,7 +357,7 @@ export const AddSourceLogic = kea ({ initializeAddSource: ({ addSourceProps }) => { - const { serviceType } = staticSourceData[addSourceProps.sourceIndex]; + const { serviceType } = addSourceProps.sourceData; actions.setAddSourceProps({ addSourceProps }); actions.setAddSourceStep(getFirstStep(addSourceProps)); actions.getSourceConfigData(serviceType); @@ -540,7 +514,9 @@ export const AddSourceLogic = kea 0 ? githubOrganizations : undefined, @@ -580,10 +554,9 @@ export const AddSourceLogic = kea params[key] === undefined && delete params[key]); try { - const response = await HttpLogic.values.http.post(route, { + await HttpLogic.values.http.post(route, { body: JSON.stringify({ ...params }), }); - actions.setCustomSourceData(response); successCallback(); } catch (e) { flashAPIErrors(e); @@ -596,11 +569,7 @@ export const AddSourceLogic = kea { - const { sourceIndex, connect, configure, reAuthenticate } = props; - const { serviceType } = staticSourceData[sourceIndex]; - const isCustom = serviceType === CUSTOM_SERVICE_TYPE; - - if (isCustom) return AddSourceSteps.ConfigureCustomStep; + const { connect, configure, reAuthenticate } = props; if (connect) return AddSourceSteps.ConnectInstanceStep; if (configure) return AddSourceSteps.ConfigureOauthStep; if (reAuthenticate) return AddSourceSteps.ReauthenticateStep; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx index f168dfbea91ce..fbcb8685f7ff9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx @@ -26,7 +26,7 @@ describe('AvailableSourcesList', () => { const wrapper = shallow(); expect(wrapper.find(EuiTitle)).toHaveLength(1); - expect(wrapper.find('[data-test-subj="AvailableSourceListItem"]')).toHaveLength(11); + expect(wrapper.find('[data-test-subj="AvailableSourceListItem"]')).toHaveLength(20); expect(wrapper.find('[data-test-subj="CustomAPISourceLink"]')).toHaveLength(1); }); @@ -34,7 +34,7 @@ describe('AvailableSourcesList', () => { setMockValues({ hasPlatinumLicense: false }); const wrapper = shallow(); - expect(wrapper.find(EuiToolTip)).toHaveLength(1); + expect(wrapper.find(EuiToolTip)).toHaveLength(2); }); it('handles empty state', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx index 13f0f41643e16..7dc9ad9ca0f60 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx @@ -24,9 +24,11 @@ import { i18n } from '@kbn/i18n'; import { LicensingLogic } from '../../../../../shared/licensing'; import { EuiButtonEmptyTo, EuiLinkTo } from '../../../../../shared/react_router_helpers'; import { SourceIcon } from '../../../../components/shared/source_icon'; -import { ADD_CUSTOM_PATH, getSourcesPath } from '../../../../routes'; +import { ADD_CUSTOM_PATH, getAddPath, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; +import { staticCustomSourceData } from '../../source_data'; + import { AVAILABLE_SOURCE_EMPTY_STATE, AVAILABLE_SOURCE_TITLE, @@ -41,7 +43,8 @@ interface AvailableSourcesListProps { export const AvailableSourcesList: React.FC = ({ sources }) => { const { hasPlatinumLicense } = useValues(LicensingLogic); - const getSourceCard = ({ name, serviceType, addPath, accountContextOnly }: SourceDataItem) => { + const getSourceCard = ({ name, serviceType, accountContextOnly }: SourceDataItem) => { + const addPath = getAddPath(serviceType); const disabled = !hasPlatinumLicense && accountContextOnly; const connectButton = () => { @@ -105,6 +108,15 @@ export const AvailableSourcesList: React.FC = ({ sour
))} + + + {getSourceCard(staticCustomSourceData)} + + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx new file mode 100644 index 0000000000000..bfb916847d865 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockKibanaValues, setMockValues } from '../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiText, EuiButton } from '@elastic/eui'; + +import { + PersonalDashboardLayout, + WorkplaceSearchPageTemplate, +} from '../../../../components/layout'; +import { staticSourceData } from '../../source_data'; + +import { ConfigurationChoice } from './configuration_choice'; + +describe('ConfigurationChoice', () => { + const { navigateToUrl } = mockKibanaValues; + const props = { + sourceData: staticSourceData[0], + }; + const mockValues = { + isOrganization: true, + }; + + beforeEach(() => { + setMockValues(mockValues); + jest.clearAllMocks(); + }); + + describe('layout', () => { + it('renders the default workplace search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + }); + + it('renders the personal dashboard layout when not in an organization', () => { + setMockValues({ ...mockValues, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + }); + }); + + it('renders internal connector if available', () => { + const wrapper = shallow(); + + expect(wrapper.find('EuiPanel')).toHaveLength(1); + expect(wrapper.find(EuiText)).toHaveLength(3); + expect(wrapper.find(EuiButton)).toHaveLength(1); + }); + it('should navigate to internal connector on internal connector click', () => { + const wrapper = shallow(); + const button = wrapper.find(EuiButton); + button.simulate('click'); + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/internal/'); + }); + + it('renders external connector if available', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('EuiPanel')).toHaveLength(1); + expect(wrapper.find(EuiText)).toHaveLength(3); + expect(wrapper.find(EuiButton)).toHaveLength(1); + }); + it('should navigate to external connector on external connector click', () => { + const wrapper = shallow( + + ); + const button = wrapper.find(EuiButton); + button.simulate('click'); + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/external/'); + }); + + it('renders custom connector if available', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('EuiPanel')).toHaveLength(1); + expect(wrapper.find(EuiText)).toHaveLength(3); + expect(wrapper.find(EuiButton)).toHaveLength(1); + }); + it('should navigate to custom connector on internal connector click', () => { + const wrapper = shallow( + + ); + const button = wrapper.find(EuiButton); + button.simulate('click'); + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/custom/'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx new file mode 100644 index 0000000000000..46a8998c9dd10 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx @@ -0,0 +1,236 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues } from 'kea'; + +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { KibanaLogic } from '../../../../../shared/kibana'; +import { AppLogic } from '../../../../app_logic'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { NAV } from '../../../../constants'; +import { getAddPath, getSourcesPath } from '../../../../routes'; +import { SourceDataItem } from '../../../../types'; + +import { AddSourceHeader } from './add_source_header'; + +interface ConfigurationIntroProps { + sourceData: SourceDataItem; +} + +export const ConfigurationChoice: React.FC = ({ + sourceData: { + name, + serviceType, + externalConnectorAvailable, + internalConnectorAvailable, + customConnectorAvailable, + }, +}) => { + const { isOrganization } = useValues(AppLogic); + const goToInternal = () => + KibanaLogic.values.navigateToUrl( + `${getSourcesPath( + `${getSourcesPath(getAddPath(serviceType), isOrganization)}/internal`, + isOrganization + )}/` + ); + const goToExternal = () => + KibanaLogic.values.navigateToUrl( + `${getSourcesPath( + `${getSourcesPath(getAddPath(serviceType), isOrganization)}/external`, + isOrganization + )}/` + ); + const goToCustom = () => + KibanaLogic.values.navigateToUrl( + `${getSourcesPath( + `${getSourcesPath(getAddPath(serviceType), isOrganization)}/custom`, + isOrganization + )}/` + ); + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + + return ( + + + + + {internalConnectorAvailable && ( + + + + + +

{name}

+
+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.title', + { + defaultMessage: 'Default connector', + } + )} +

+
+
+ + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.description', + { + defaultMessage: 'Use our out-of-the-box connector to get started quickly.', + } + )} + + + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.button', + { + defaultMessage: 'Connect', + } + )} + + +
+
+
+ )} + {externalConnectorAvailable && ( + + + + + +

{name}

+
+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.title', + { + defaultMessage: 'Custom connector', + } + )} +

+
+
+ + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.description', + { + defaultMessage: + 'Set up a custom connector for more configurability and control.', + } + )} + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.button', + { + defaultMessage: 'Instructions', + } + )} + + + +
+
+
+ )} + {customConnectorAvailable && ( + + + + + +

{name}

+
+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.custom.title', + { + defaultMessage: 'Custom connector', + } + )} +

+
+
+ + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.custom.description', + { + defaultMessage: + 'Set up a custom connector for more configurability and control.', + } + )} + + +
+ + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.custom.button', + { + defaultMessage: 'Instructions', + } + )} + + + +
+
+ )} +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.test.tsx index 6c0d87b7696ec..645226c546f10 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.test.tsx @@ -14,45 +14,45 @@ import { shallow } from 'enzyme'; import { EuiForm, EuiFieldText } from '@elastic/eui'; +import { staticSourceData } from '../../source_data'; + import { ConfigureCustom } from './configure_custom'; describe('ConfigureCustom', () => { - const advanceStep = jest.fn(); const setCustomSourceNameValue = jest.fn(); - - const props = { - header:

Header

, - helpText: 'I bet you could use a hand.', - advanceStep, - }; + const createContentSource = jest.fn(); beforeEach(() => { - setMockActions({ setCustomSourceNameValue }); - setMockValues({ customSourceNameValue: 'name', buttonLoading: false }); + setMockActions({ setCustomSourceNameValue, createContentSource }); + setMockValues({ + customSourceNameValue: 'name', + buttonLoading: false, + sourceData: staticSourceData[1], + }); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiForm)).toHaveLength(1); }); it('handles input change', () => { - const wrapper = shallow(); - const TEXT = 'changed for the better'; + const wrapper = shallow(); + const text = 'changed for the better'; const input = wrapper.find(EuiFieldText); - input.simulate('change', { target: { value: TEXT } }); + input.simulate('change', { target: { value: text } }); - expect(setCustomSourceNameValue).toHaveBeenCalledWith(TEXT); + expect(setCustomSourceNameValue).toHaveBeenCalledWith(text); }); it('handles form submission', () => { - const wrapper = shallow(); + const wrapper = shallow(); const preventDefault = jest.fn(); wrapper.find('form').simulate('submit', { preventDefault }); expect(preventDefault).toHaveBeenCalled(); - expect(advanceStep).toHaveBeenCalled(); + expect(createContentSource).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx index e794323dc169e..bf5a7fea21333 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx @@ -24,51 +24,64 @@ import { docLinks } from '../../../../../shared/doc_links'; import { SOURCE_NAME_LABEL } from '../../constants'; -import { AddSourceLogic } from './add_source_logic'; +import { AddCustomSourceLogic } from './add_custom_source_logic'; +import { AddSourceHeader } from './add_source_header'; import { CONFIG_CUSTOM_BUTTON, CONFIG_CUSTOM_LINK_TEXT } from './constants'; -interface ConfigureCustomProps { - header: React.ReactNode; - helpText: string; - advanceStep(): void; -} - -export const ConfigureCustom: React.FC = ({ - helpText, - advanceStep, - header, -}) => { - const { setCustomSourceNameValue } = useActions(AddSourceLogic); - const { customSourceNameValue, buttonLoading } = useValues(AddSourceLogic); +export const ConfigureCustom: React.FC = () => { + const { setCustomSourceNameValue, createContentSource } = useActions(AddCustomSourceLogic); + const { customSourceNameValue, buttonLoading, sourceData } = useValues(AddCustomSourceLogic); const handleFormSubmit = (e: FormEvent) => { e.preventDefault(); - advanceStep(); + createContentSource(); }; const handleNameChange = (e: ChangeEvent) => setCustomSourceNameValue(e.target.value); + const { + serviceType, + configuration: { documentationUrl, helpText }, + name, + categories = [], + } = sourceData; + return ( <> - {header} +

{helpText}

- - {CONFIG_CUSTOM_LINK_TEXT} - - ), - }} - /> + {serviceType === 'custom' ? ( + + {CONFIG_CUSTOM_LINK_TEXT} + + ), + }} + /> + ) : ( + + {CONFIG_CUSTOM_LINK_TEXT} + + ), + name, + }} + /> + )}

@@ -90,7 +103,17 @@ export const ConfigureCustom: React.FC = ({ isLoading={buttonLoading} data-test-subj="CreateCustomButton" > - {CONFIG_CUSTOM_BUTTON} + {serviceType === 'custom' ? ( + CONFIG_CUSTOM_BUTTON + ) : ( + + )}
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx index a1169cd582cba..a13558469cc08 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx @@ -22,9 +22,9 @@ describe('ConfiguredSourcesList', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(5); - expect(wrapper.find('[data-test-subj="AccountOnlyTooltip"]')).toHaveLength(1); - expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(6); + expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(16); + expect(wrapper.find('[data-test-subj="AccountOnlyTooltip"]')).toHaveLength(2); + expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(19); }); it('handles empty state', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx index ac465c43643a4..d4bb62901cdb6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx @@ -22,8 +22,9 @@ import { import { EuiButtonEmptyTo } from '../../../../../shared/react_router_helpers'; import { SourceIcon } from '../../../../components/shared/source_icon'; -import { getSourcesPath } from '../../../../routes'; +import { getAddPath, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; +import { hasMultipleConnectorOptions } from '../../../../utils'; import { CONFIGURED_SOURCES_LIST_UNCONNECTED_TOOLTIP, @@ -68,54 +69,62 @@ export const ConfiguredSourcesList: React.FC = ({ const visibleSources = ( - {sources.map(({ name, serviceType, addPath, connected, accountContextOnly }, i) => ( - - - - - - - - - - -

- {name} - {!connected && !accountContextOnly && isOrganization && unConnectedTooltip} - {accountContextOnly && isOrganization && accountOnlyTooltip} -

-
-
-
-
- - {((!isOrganization || (isOrganization && !accountContextOnly)) && ( - { + const { connected, accountContextOnly, name, serviceType } = sourceData; + return ( + + + + + - {CONFIGURED_SOURCES_CONNECT_BUTTON} - - )) || ( - - {ADD_SOURCE_ORG_SOURCES_TITLE} - - )} - -
-
-
- ))} + + + + + +

+ {name} + {!connected && + !accountContextOnly && + isOrganization && + unConnectedTooltip} + {accountContextOnly && isOrganization && accountOnlyTooltip} +

+
+
+ + + + {((!isOrganization || (isOrganization && !accountContextOnly)) && ( + + {CONFIGURED_SOURCES_CONNECT_BUTTON} + + )) || ( + + {ADD_SOURCE_ORG_SOURCES_TITLE} + + )} + + + + + ); + })}
); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx index c967b20e0450d..0ee80019ea720 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx @@ -43,7 +43,7 @@ describe('ConnectInstance', () => { const credentialsSourceData = staticSourceData[13]; const oauthSourceData = staticSourceData[0]; - const subdomainSourceData = staticSourceData[16]; + const subdomainSourceData = staticSourceData[18]; const props = { ...credentialsSourceData, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx new file mode 100644 index 0000000000000..6288a5fc79129 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.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 '../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiSteps } from '@elastic/eui'; + +import { staticSourceData } from '../../source_data'; + +import { ExternalConnectorConfig } from './external_connector_config'; + +describe('ExternalConnectorConfig', () => { + const goBack = jest.fn(); + const onDeleteConfig = jest.fn(); + const setExternalConnectorApiKey = jest.fn(); + const setExternalConnectorUrl = jest.fn(); + const saveExternalConnectorConfig = jest.fn(); + const fetchExternalSource = jest.fn(); + + const props = { + sourceData: staticSourceData[0], + goBack, + onDeleteConfig, + }; + + const values = { + sourceConfigData, + buttonLoading: false, + clientIdValue: 'foo', + clientSecretValue: 'bar', + baseUrlValue: 'http://foo.baz', + hasPlatinumLicense: true, + }; + + beforeEach(() => { + setMockActions({ + setExternalConnectorApiKey, + setExternalConnectorUrl, + saveExternalConnectorConfig, + fetchExternalSource, + }); + setMockValues({ ...values }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiSteps)).toHaveLength(1); + }); + + it('handles form submission', () => { + const wrapper = shallow(); + + const preventDefault = jest.fn(); + wrapper.find('form').simulate('submit', { preventDefault }); + + expect(preventDefault).toHaveBeenCalled(); + expect(saveExternalConnectorConfig).toHaveBeenCalled(); + }); + + describe('external connector configuration', () => { + it('handles url change', () => { + const wrapper = shallow(); + const steps = wrapper.find(EuiSteps); + const input = steps.dive().find('[name="external-connector-url"]'); + input.simulate('change', { target: { value: 'url' } }); + + expect(setExternalConnectorUrl).toHaveBeenCalledWith('url'); + }); + + it('handles Client secret change', () => { + const wrapper = shallow(); + const steps = wrapper.find(EuiSteps); + const input = steps.dive().find('[name="external-connector-api-key"]'); + input.simulate('change', { target: { value: 'api-key' } }); + + expect(setExternalConnectorApiKey).toHaveBeenCalledWith('api-key'); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx new file mode 100644 index 0000000000000..1f0528f492b9d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, { FormEvent, useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiSpacer, + EuiSteps, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { AppLogic } from '../../../../app_logic'; +import { + PersonalDashboardLayout, + WorkplaceSearchPageTemplate, +} from '../../../../components/layout'; +import { NAV, REMOVE_BUTTON } from '../../../../constants'; +import { SourceDataItem } from '../../../../types'; + +import { AddSourceHeader } from './add_source_header'; +import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './constants'; +import { ExternalConnectorLogic } from './external_connector_logic'; + +interface SaveConfigProps { + sourceData: SourceDataItem; + goBack?: () => void; + onDeleteConfig?: () => void; +} + +export const ExternalConnectorConfig: React.FC = ({ goBack, onDeleteConfig }) => { + const serviceType = 'external'; + const { + fetchExternalSource, + setExternalConnectorApiKey, + setExternalConnectorUrl, + saveExternalConnectorConfig, + } = useActions(ExternalConnectorLogic); + + const { buttonLoading, externalConnectorUrl, externalConnectorApiKey, sourceConfigData } = + useValues(ExternalConnectorLogic); + + useEffect(() => { + fetchExternalSource(); + }, []); + + const handleFormSubmission = (e: FormEvent) => { + e.preventDefault(); + saveExternalConnectorConfig({ url: externalConnectorUrl, apiKey: externalConnectorApiKey }); + }; + + const { name, categories } = sourceConfigData; + const { isOrganization } = useValues(AppLogic); + + const saveButton = ( + + {OAUTH_SAVE_CONFIG_BUTTON} + + ); + + const deleteButton = ( + + {REMOVE_BUTTON} + + ); + + const backButton = {OAUTH_BACK_BUTTON}; + + const formActions = ( + + + {saveButton} + + {goBack && backButton} + {onDeleteConfig && deleteButton} + + + + ); + + const connectorForm = ( + + {/* TODO: get a docs link in here for the external connector + */} + + + + setExternalConnectorUrl(e.target.value)} + name="external-connector-url" + /> + + + setExternalConnectorApiKey(e.target.value)} + name="external-connector-api-key" + /> + + + {formActions} + + + ); + + const configSteps = [ + { + title: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.addSource.externalConnectorConfig.stepTitle', + { + defaultMessage: 'Provide the appropriate configuration information', + } + ), + children: connectorForm, + }, + ]; + + const header = ; + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + + return ( + + {header} + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts new file mode 100644 index 0000000000000..22a36deeeccd5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LogicMounter, mockHttpValues, mockKibanaValues } from '../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; + +import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; + +jest.mock('../../../../app_logic', () => ({ + AppLogic: { values: { isOrganization: true } }, +})); + +import { ExternalConnectorLogic, ExternalConnectorValues } from './external_connector_logic'; + +describe('ExternalConnectorLogic', () => { + const { mount } = new LogicMounter(ExternalConnectorLogic); + const { http } = mockHttpValues; + const { navigateToUrl } = mockKibanaValues; + + const DEFAULT_VALUES: ExternalConnectorValues = { + dataLoading: true, + buttonLoading: false, + externalConnectorUrl: '', + externalConnectorApiKey: '', + sourceConfigData: { + name: '', + categories: [], + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('has expected default values', () => { + expect(ExternalConnectorLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('fetchExternalSourceSuccess', () => { + beforeEach(() => { + ExternalConnectorLogic.actions.fetchExternalSourceSuccess(sourceConfigData); + }); + + it('turns off the data loading flag', () => { + expect(ExternalConnectorLogic.values.dataLoading).toEqual(false); + }); + + it('saves the external url', () => { + expect(ExternalConnectorLogic.values.externalConnectorUrl).toEqual( + sourceConfigData.configuredFields.url + ); + }); + + it('saves the source config', () => { + expect(ExternalConnectorLogic.values.sourceConfigData).toEqual(sourceConfigData); + }); + + it('sets undefined url to empty string', () => { + ExternalConnectorLogic.actions.fetchExternalSourceSuccess({ + ...sourceConfigData, + configuredFields: { ...sourceConfigData.configuredFields, url: undefined }, + }); + expect(ExternalConnectorLogic.values.externalConnectorUrl).toEqual(''); + }); + it('sets undefined api key to empty string', () => { + ExternalConnectorLogic.actions.fetchExternalSourceSuccess({ + ...sourceConfigData, + configuredFields: { ...sourceConfigData.configuredFields, apiKey: undefined }, + }); + expect(ExternalConnectorLogic.values.externalConnectorApiKey).toEqual(''); + }); + }); + + describe('saveExternalConnectorConfigSuccess', () => { + it('turns off the button loading flag', () => { + mount({ + buttonLoading: true, + }); + + ExternalConnectorLogic.actions.saveExternalConnectorConfigSuccess('external'); + + expect(ExternalConnectorLogic.values.buttonLoading).toEqual(false); + }); + }); + + describe('setExternalConnectorApiKey', () => { + it('updates the api key', () => { + ExternalConnectorLogic.actions.setExternalConnectorApiKey('abcd1234'); + + expect(ExternalConnectorLogic.values.externalConnectorApiKey).toEqual('abcd1234'); + }); + }); + + describe('setExternalConnectorUrl', () => { + it('updates the url', () => { + ExternalConnectorLogic.actions.setExternalConnectorUrl('https://www.elastic.co'); + + expect(ExternalConnectorLogic.values.externalConnectorUrl).toEqual( + 'https://www.elastic.co' + ); + }); + }); + }); + + describe('listeners', () => { + describe('fetchExternalSource', () => { + it('retrieves config info on the "external" connector', () => { + const promise = Promise.resolve(); + http.get.mockReturnValue(promise); + ExternalConnectorLogic.actions.fetchExternalSource(); + + expect(http.get).toHaveBeenCalledWith( + '/internal/workplace_search/org/settings/connectors/external' + ); + }); + + itShowsServerErrorAsFlashMessage(http.get, () => { + mount(); + ExternalConnectorLogic.actions.fetchExternalSource(); + }); + }); + + describe('saveExternalConnectorConfig', () => { + it('saves the external connector config', () => { + const saveExternalConnectorConfigSuccess = jest.spyOn( + ExternalConnectorLogic.actions, + 'saveExternalConnectorConfigSuccess' + ); + ExternalConnectorLogic.actions.saveExternalConnectorConfig({ + url: 'url', + apiKey: 'apiKey', + }); + expect(saveExternalConnectorConfigSuccess).toHaveBeenCalled(); + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/external'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts new file mode 100644 index 0000000000000..13c0b9167310b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { + flashAPIErrors, + flashSuccessToast, + clearFlashMessages, +} from '../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../shared/http'; +import { KibanaLogic } from '../../../../../shared/kibana'; +import { AppLogic } from '../../../../app_logic'; + +import { getAddPath, getSourcesPath } from '../../../../routes'; + +import { SourceConfigData } from './add_source_logic'; + +export interface ExternalConnectorActions { + fetchExternalSource: () => true; + fetchExternalSourceSuccess(sourceConfigData: SourceConfigData): SourceConfigData; + saveExternalConnectorConfigSuccess(externalConnectorId: string): string; + setExternalConnectorApiKey(externalConnectorApiKey: string): string; + saveExternalConnectorConfig(config: ExternalConnectorConfig): ExternalConnectorConfig; + setExternalConnectorUrl(externalConnectorUrl: string): string; + resetSourceState: () => true; +} + +export interface ExternalConnectorConfig { + url: string; + apiKey: string; +} + +export interface ExternalConnectorValues { + buttonLoading: boolean; + dataLoading: boolean; + externalConnectorApiKey: string; + externalConnectorUrl: string; + sourceConfigData: SourceConfigData | Pick; +} + +export const ExternalConnectorLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'workplace_search', 'external_connector_logic'], + actions: { + fetchExternalSource: true, + fetchExternalSourceSuccess: (sourceConfigData) => sourceConfigData, + saveExternalConnectorConfigSuccess: (externalConnectorId) => externalConnectorId, + saveExternalConnectorConfig: (config) => config, + setExternalConnectorApiKey: (externalConnectorApiKey: string) => externalConnectorApiKey, + setExternalConnectorUrl: (externalConnectorUrl: string) => externalConnectorUrl, + }, + reducers: { + dataLoading: [ + true, + { + fetchExternalSourceSuccess: () => false, + }, + ], + buttonLoading: [ + false, + { + saveExternalConnectorConfigSuccess: () => false, + saveExternalConnectorConfig: () => true, + }, + ], + externalConnectorUrl: [ + '', + { + fetchExternalSourceSuccess: (_, { configuredFields: { url } }) => url || '', + setExternalConnectorUrl: (_, url) => url, + }, + ], + externalConnectorApiKey: [ + '', + { + fetchExternalSourceSuccess: (_, { configuredFields: { apiKey } }) => apiKey || '', + setExternalConnectorApiKey: (_, apiKey) => apiKey, + }, + ], + sourceConfigData: [ + { name: '', categories: [] }, + { + fetchExternalSourceSuccess: (_, sourceConfigData) => sourceConfigData, + }, + ], + }, + listeners: ({ actions }) => ({ + fetchExternalSource: async () => { + const route = '/internal/workplace_search/org/settings/connectors/external'; + + try { + const response = await HttpLogic.values.http.get(route); + actions.fetchExternalSourceSuccess(response); + } catch (e) { + flashAPIErrors(e); + } + }, + saveExternalConnectorConfig: async () => { + clearFlashMessages(); + // const route = '/internal/workplace_search/org/settings/connectors'; + // const http = HttpLogic.values.http.post; + // const params = { + // url, + // api_key: apiKey, + // service_type: 'external', + // }; + try { + // const response = await http(route, { + // body: JSON.stringify(params), + // }); + + flashSuccessToast( + i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.flashMessages.externalConnectorCreated', + { + defaultMessage: 'Successfully updated configuration.', + } + ) + ); + // TODO: use response data instead + actions.saveExternalConnectorConfigSuccess('external'); + KibanaLogic.values.navigateToUrl( + getSourcesPath(`${getAddPath('external')}`, AppLogic.values.isOrganization) + ); + } catch (e) { + // flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/github_via_app.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/github_via_app.tsx index b62648348ed80..c0e72d3b7a5a0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/github_via_app.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/github_via_app.tsx @@ -61,7 +61,8 @@ export const GitHubViaApp: React.FC = ({ isGithubEnterpriseSe const { hasPlatinumLicense } = useValues(LicensingLogic); const name = isGithubEnterpriseServer ? SOURCE_NAMES.GITHUB_ENTERPRISE : SOURCE_NAMES.GITHUB; - const data = staticSourceData.find((source) => source.name === name); + const serviceType = isGithubEnterpriseServer ? 'github_enterprise_server' : 'github'; + const data = staticSourceData.find((source) => source.serviceType === serviceType); const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; const handleSubmit = (e: FormEvent) => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx index 4715c50e4233c..c05110bd4e6ac 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx @@ -11,40 +11,45 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiLink, EuiPanel, EuiTitle } from '@elastic/eui'; +import { EuiPanel, EuiTitle } from '@elastic/eui'; import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; import { LicenseBadge } from '../../../../components/shared/license_badge'; +import { staticCustomSourceData } from '../../source_data'; import { SaveCustom } from './save_custom'; describe('SaveCustom', () => { - const props = { - documentationUrl: 'http://string.boolean', + const mockValues = { newCustomSource: { - accessToken: 'dsgfsd', - key: 'sdfs', - name: 'source', - id: '12e1', + id: 'id', + accessToken: 'token', + name: 'name', }, + sourceData: staticCustomSourceData, isOrganization: true, - header:

Header

, + hasPlatinumLicense: true, }; + + beforeEach(() => { + setMockValues(mockValues); + }); + it('renders', () => { - setMockValues({ hasPlatinumLicense: true }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiPanel)).toHaveLength(1); expect(wrapper.find(EuiTitle)).toHaveLength(4); expect(wrapper.find(EuiLinkTo)).toHaveLength(1); + expect(wrapper.find(LicenseBadge)).toHaveLength(0); }); - - it('renders platinum LicenseBadge and link', () => { - setMockValues({ hasPlatinumLicense: false }); - const wrapper = shallow(); + it('renders platinum license badge if license is not present', () => { + setMockValues({ ...mockValues, hasPlatinumLicense: false }); + const wrapper = shallow(); expect(wrapper.find(LicenseBadge)).toHaveLength(1); - expect(wrapper.find(EuiLink)).toHaveLength(1); + expect(wrapper.find(EuiTitle)).toHaveLength(4); + expect(wrapper.find(EuiLinkTo)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx index c136f22d91d3d..14d088f377f5e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx @@ -20,6 +20,7 @@ import { EuiTitle, EuiLink, EuiPanel, + EuiCode, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -27,6 +28,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { docLinks } from '../../../../../shared/doc_links'; import { LicensingLogic } from '../../../../../shared/licensing'; import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; +import { AppLogic } from '../../../../app_logic'; import { LicenseBadge } from '../../../../components/shared/license_badge'; import { SOURCES_PATH, @@ -34,11 +36,12 @@ import { getContentSourcePath, getSourcesPath, } from '../../../../routes'; -import { CustomSource } from '../../../../types'; import { LEARN_CUSTOM_FEATURES_BUTTON } from '../../constants'; import { SourceIdentifier } from '../source_identifier'; +import { AddCustomSourceLogic } from './add_custom_source_logic'; +import { AddSourceHeader } from './add_source_header'; import { SAVE_CUSTOM_BODY1, SAVE_CUSTOM_BODY2, @@ -51,23 +54,20 @@ import { SAVE_CUSTOM_DOC_PERMISSIONS_LINK, } from './constants'; -interface SaveCustomProps { - documentationUrl: string; - newCustomSource: CustomSource; - isOrganization: boolean; - header: React.ReactNode; -} - -export const SaveCustom: React.FC = ({ - documentationUrl, - newCustomSource: { id, name }, - isOrganization, - header, -}) => { +export const SaveCustom: React.FC = () => { + const { newCustomSource, sourceData } = useValues(AddCustomSourceLogic); + const { isOrganization } = useValues(AppLogic); const { hasPlatinumLicense } = useValues(LicensingLogic); + const { + serviceType, + configuration: { githubRepository, documentationUrl }, + name, + categories = [], + } = sourceData; + return ( <> - {header} + @@ -84,7 +84,7 @@ export const SaveCustom: React.FC = ({ 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.heading', { defaultMessage: '{name} Created', - values: { name }, + values: { name: newCustomSource.name }, } )} @@ -93,7 +93,22 @@ export const SaveCustom: React.FC = ({ {SAVE_CUSTOM_BODY1} -
+ + {serviceType !== 'custom' && githubRepository && ( + <> + +
+ + + {githubRepository} + + + + + )} {SAVE_CUSTOM_BODY2}
@@ -105,7 +120,7 @@ export const SaveCustom: React.FC = ({
- +
@@ -119,17 +134,32 @@ export const SaveCustom: React.FC = ({

- - {SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK} - - ), - }} - /> + {serviceType === 'custom' ? ( + + {SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK} + + ), + }} + /> + ) : ( + + {SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK} + + ), + name, + }} + /> + )}

@@ -149,7 +179,7 @@ export const SaveCustom: React.FC = ({ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index 484a9ca14b4e1..d57dc49683275 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -41,7 +41,7 @@ import { SAVE_CHANGES_BUTTON, REMOVE_BUTTON, } from '../../../constants'; -import { SourceDataItem } from '../../../types'; +import { getEditPath } from '../../../routes'; import { handlePrivateKeyUpload } from '../../../utils'; import { AddSourceLogic } from '../components/add_source/add_source_logic'; import { @@ -57,7 +57,6 @@ import { SYNC_DIAGNOSTICS_DESCRIPTION, SYNC_DIAGNOSTICS_BUTTON, } from '../constants'; -import { staticSourceData } from '../source_data'; import { SourceLogic } from '../source_logic'; import { DownloadDiagnosticsButton } from './download_diagnostics_button'; @@ -96,8 +95,7 @@ export const SourceSettings: React.FC = () => { const editPath = isGithubApp ? undefined // undefined for GitHub apps, as they are configured source-wide, and don't use a connector where you can edit the configuration - : (staticSourceData.find((source) => source.serviceType === serviceType) as SourceDataItem) - .editPath; + : getEditPath(serviceType); const [inputValue, setValue] = useState(name); const [confirmModalVisible, setModalVisibility] = useState(false); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index 20a0673709b5a..f99af41836419 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -10,52 +10,13 @@ import { i18n } from '@kbn/i18n'; import { docLinks } from '../../../shared/doc_links'; import { SOURCE_NAMES, SOURCE_OBJ_TYPES, GITHUB_LINK_TITLE } from '../../constants'; -import { - ADD_BOX_PATH, - ADD_CONFLUENCE_PATH, - ADD_CONFLUENCE_SERVER_PATH, - ADD_DROPBOX_PATH, - ADD_GITHUB_ENTERPRISE_PATH, - ADD_GITHUB_PATH, - ADD_GMAIL_PATH, - ADD_GOOGLE_DRIVE_PATH, - ADD_JIRA_PATH, - ADD_JIRA_SERVER_PATH, - ADD_ONEDRIVE_PATH, - ADD_SALESFORCE_PATH, - ADD_SALESFORCE_SANDBOX_PATH, - ADD_SERVICENOW_PATH, - ADD_SHAREPOINT_PATH, - ADD_SLACK_PATH, - ADD_ZENDESK_PATH, - ADD_CUSTOM_PATH, - EDIT_BOX_PATH, - EDIT_CONFLUENCE_PATH, - EDIT_CONFLUENCE_SERVER_PATH, - EDIT_DROPBOX_PATH, - EDIT_GITHUB_ENTERPRISE_PATH, - EDIT_GITHUB_PATH, - EDIT_GMAIL_PATH, - EDIT_GOOGLE_DRIVE_PATH, - EDIT_JIRA_PATH, - EDIT_JIRA_SERVER_PATH, - EDIT_ONEDRIVE_PATH, - EDIT_SALESFORCE_PATH, - EDIT_SALESFORCE_SANDBOX_PATH, - EDIT_SERVICENOW_PATH, - EDIT_SHAREPOINT_PATH, - EDIT_SLACK_PATH, - EDIT_ZENDESK_PATH, - EDIT_CUSTOM_PATH, -} from '../../routes'; import { FeatureIds, SourceDataItem } from '../../types'; -export const staticSourceData = [ +export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.BOX, + iconName: SOURCE_NAMES.BOX, serviceType: 'box', - addPath: ADD_BOX_PATH, - editPath: EDIT_BOX_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -79,12 +40,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.CONFLUENCE, + iconName: SOURCE_NAMES.CONFLUENCE, serviceType: 'confluence_cloud', - addPath: ADD_CONFLUENCE_PATH, - editPath: EDIT_CONFLUENCE_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -113,12 +74,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.CONFLUENCE_SERVER, + iconName: SOURCE_NAMES.CONFLUENCE_SERVER, serviceType: 'confluence_server', - addPath: ADD_CONFLUENCE_SERVER_PATH, - editPath: EDIT_CONFLUENCE_SERVER_PATH, configuration: { isPublicKey: true, hasOauthRedirect: true, @@ -145,12 +106,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.DROPBOX, + iconName: SOURCE_NAMES.DROPBOX, serviceType: 'dropbox', - addPath: ADD_DROPBOX_PATH, - editPath: EDIT_DROPBOX_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -174,12 +135,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GITHUB, + iconName: SOURCE_NAMES.GITHUB, serviceType: 'github', - addPath: ADD_GITHUB_PATH, - editPath: EDIT_GITHUB_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -210,12 +171,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GITHUB_ENTERPRISE, + iconName: SOURCE_NAMES.GITHUB_ENTERPRISE, serviceType: 'github_enterprise_server', - addPath: ADD_GITHUB_ENTERPRISE_PATH, - editPath: EDIT_GITHUB_ENTERPRISE_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -252,12 +213,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GMAIL, + iconName: SOURCE_NAMES.GMAIL, serviceType: 'gmail', - addPath: ADD_GMAIL_PATH, - editPath: EDIT_GMAIL_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -273,9 +234,8 @@ export const staticSourceData = [ }, { name: SOURCE_NAMES.GOOGLE_DRIVE, + iconName: SOURCE_NAMES.GOOGLE_DRIVE, serviceType: 'google_drive', - addPath: ADD_GOOGLE_DRIVE_PATH, - editPath: EDIT_GOOGLE_DRIVE_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -303,12 +263,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.JIRA, + iconName: SOURCE_NAMES.JIRA, serviceType: 'jira_cloud', - addPath: ADD_JIRA_PATH, - editPath: EDIT_JIRA_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -339,12 +299,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.JIRA_SERVER, + iconName: SOURCE_NAMES.JIRA_SERVER, serviceType: 'jira_server', - addPath: ADD_JIRA_SERVER_PATH, - editPath: EDIT_JIRA_SERVER_PATH, configuration: { isPublicKey: true, hasOauthRedirect: true, @@ -374,12 +334,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.ONEDRIVE, + iconName: SOURCE_NAMES.ONEDRIVE, serviceType: 'one_drive', - addPath: ADD_ONEDRIVE_PATH, - editPath: EDIT_ONEDRIVE_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -403,12 +363,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SALESFORCE, + iconName: SOURCE_NAMES.SALESFORCE, serviceType: 'salesforce', - addPath: ADD_SALESFORCE_PATH, - editPath: EDIT_SALESFORCE_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -439,12 +399,13 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, + { name: SOURCE_NAMES.SALESFORCE_SANDBOX, + iconName: SOURCE_NAMES.SALESFORCE_SANDBOX, serviceType: 'salesforce_sandbox', - addPath: ADD_SALESFORCE_SANDBOX_PATH, - editPath: EDIT_SALESFORCE_SANDBOX_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -475,12 +436,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SERVICENOW, + iconName: SOURCE_NAMES.SERVICENOW, serviceType: 'service_now', - addPath: ADD_SERVICENOW_PATH, - editPath: EDIT_SERVICENOW_PATH, configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -508,12 +469,44 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SHAREPOINT, + iconName: SOURCE_NAMES.SHAREPOINT, serviceType: 'share_point', - addPath: ADD_SHAREPOINT_PATH, - editPath: EDIT_SHAREPOINT_PATH, + configuration: { + isPublicKey: false, + hasOauthRedirect: true, + needsBaseUrl: false, + documentationUrl: docLinks.workplaceSearchSharePoint, + applicationPortalUrl: 'https://portal.azure.com/', + }, + objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES], + features: { + basicOrgContext: [ + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + FeatureIds.GlobalAccessPermissions, + ], + basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], + platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], + platinumPrivateContext: [ + FeatureIds.Private, + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + ], + }, + + accountContextOnly: false, + internalConnectorAvailable: true, + externalConnectorAvailable: true, + }, + // TODO: temporary hack until backend sends us stuff + { + name: SOURCE_NAMES.SHAREPOINT, + iconName: SOURCE_NAMES.SHAREPOINT, + serviceType: 'external', configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -537,12 +530,54 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, + externalConnectorAvailable: false, + customConnectorAvailable: false, + }, + { + name: SOURCE_NAMES.SHAREPOINT_SERVER, + iconName: SOURCE_NAMES.SHAREPOINT_SERVER, + categories: [ + i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.fileSharing', { + defaultMessage: 'File Sharing', + }), + i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.storage', { + defaultMessage: 'Storage', + }), + i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.cloud', { + defaultMessage: 'Cloud', + }), + i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.microsoft', { + defaultMessage: 'Microsoft', + }), + i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.office', { + defaultMessage: 'Office 365', + }), + ], + serviceType: 'share_point_server', // this doesn't exist on the BE + configuration: { + isPublicKey: false, + hasOauthRedirect: false, + needsBaseUrl: false, + // helpText: i18n.translate( // TODO updatae this + // 'xpack.enterpriseSearch.workplaceSearch.sources.helpText.sharepointServer', + // { + // defaultMessage: + // "Here is some help text. It should probably give the user a heads up that they're going to have to deploy some code.", + // } + // ), + documentationUrl: docLinks.workplaceSearchCustomSources, // TODO update this + applicationPortalUrl: '', + githubRepository: 'elastic/enterprise-search-sharepoint-server-connector', + }, + accountContextOnly: false, + internalConnectorAvailable: false, + customConnectorAvailable: true, }, { name: SOURCE_NAMES.SLACK, + iconName: SOURCE_NAMES.SLACK, serviceType: 'slack', - addPath: ADD_SLACK_PATH, - editPath: EDIT_SLACK_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -559,12 +594,13 @@ export const staticSourceData = [ platinumPrivateContext: [FeatureIds.Remote, FeatureIds.Private, FeatureIds.SearchableContent], }, accountContextOnly: true, + internalConnectorAvailable: true, }, + { name: SOURCE_NAMES.ZENDESK, + iconName: SOURCE_NAMES.ZENDESK, serviceType: 'zendesk', - addPath: ADD_ZENDESK_PATH, - editPath: EDIT_ZENDESK_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -588,23 +624,26 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, - { - name: SOURCE_NAMES.CUSTOM, - serviceType: 'custom', - addPath: ADD_CUSTOM_PATH, - editPath: EDIT_CUSTOM_PATH, - configuration: { - isPublicKey: false, - hasOauthRedirect: false, - needsBaseUrl: false, - helpText: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.helpText.custom', { - defaultMessage: - 'To create a Custom API Source, provide a human-readable and descriptive name. The name will appear as-is in the various search experiences and management interfaces.', - }), - documentationUrl: docLinks.workplaceSearchCustomSources, - applicationPortalUrl: '', - }, - accountContextOnly: false, +]; + +export const staticCustomSourceData: SourceDataItem = { + name: SOURCE_NAMES.CUSTOM, + iconName: SOURCE_NAMES.CUSTOM, + categories: ['API', 'Custom'], + serviceType: 'custom', + configuration: { + isPublicKey: false, + hasOauthRedirect: false, + needsBaseUrl: false, + helpText: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.helpText.custom', { + defaultMessage: + 'To create a Custom API Source, provide a human-readable and descriptive name. The name will appear as-is in the various search experiences and management interfaces.', + }), + documentationUrl: docLinks.workplaceSearchCustomSources, + applicationPortalUrl: '', }, -] as SourceDataItem[]; + accountContextOnly: false, + customConnectorAvailable: true, +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts index f7e41f6512017..a007d31ff67cb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts @@ -18,6 +18,7 @@ jest.mock('../../app_logic', () => ({ import { itShowsServerErrorAsFlashMessage } from '../../../test_helpers'; import { AppLogic } from '../../app_logic'; +import { staticSourceData } from './source_data'; import { SourcesLogic, fetchSourceStatuses, POLLING_INTERVAL } from './sources_logic'; describe('SourcesLogic', () => { @@ -32,8 +33,8 @@ describe('SourcesLogic', () => { const defaultValues = { contentSources: [], privateContentSources: [], - sourceData: [], - availableSources: [], + sourceData: staticSourceData.map((data) => ({ ...data, connected: false })), + availableSources: staticSourceData.map((data) => ({ ...data, connected: false })), configuredSources: [], serviceTypes: [], permissionsModal: null, @@ -316,7 +317,7 @@ describe('SourcesLogic', () => { it('availableSources & configuredSources have correct length', () => { SourcesLogic.actions.onInitializeSources(serverResponse); - expect(SourcesLogic.values.availableSources).toHaveLength(1); + expect(SourcesLogic.values.availableSources).toHaveLength(14); expect(SourcesLogic.values.configuredSources).toHaveLength(5); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts index 90b1f83281e94..b7bdef52fceb0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts @@ -178,7 +178,7 @@ export const SourcesLogic = kea>( if (isOrganization && !values.serverStatuses) { // We want to get the initial statuses from the server to compare our polling results to. const sourceStatuses = await fetchSourceStatuses(isOrganization, breakpoint); - actions.setServerSourceStatuses(sourceStatuses); + actions.setServerSourceStatuses(sourceStatuses ?? []); } }, // We poll the server and if the status update, we trigger a new fetch of the sources. @@ -190,7 +190,7 @@ export const SourcesLogic = kea>( pollingInterval = window.setInterval(async () => { const sourceStatuses = await fetchSourceStatuses(isOrganization, breakpoint); - sourceStatuses.some((source: ContentSourceStatus) => { + (sourceStatuses ?? []).some((source: ContentSourceStatus) => { if (serverStatuses && serverStatuses[source.id] !== source.status.status) { return actions.initializeSources(); } @@ -249,7 +249,7 @@ export const SourcesLogic = kea>( export const fetchSourceStatuses = async ( isOrganization: boolean, breakpoint: BreakPointFunction -) => { +): Promise => { const route = isOrganization ? '/internal/workplace_search/org/sources/status' : '/internal/workplace_search/account/sources/status'; @@ -267,8 +267,7 @@ export const fetchSourceStatuses = async ( } } - // TODO: remove casting. return type should be ContentSourceStatus[] | undefined - return response as ContentSourceStatus[]; + return response; }; const updateSourcesOnToggle = ( @@ -293,7 +292,7 @@ const updateSourcesOnToggle = ( * The second is the base list of available sources that the server sends back in the collection, * `availableTypes` that is the source of truth for the name and whether the source has been configured. * - * Fnally, also in the collection response is the current set of connected sources. We check for the + * Finally, also in the collection response is the current set of connected sources. We check for the * existence of a `connectedSource` of the type in the loop and set `connected` to true so that the UI * can diplay "Add New" instead of "Connect", the latter of which is displated only when a connector * has been configured but there are no connected sources yet. @@ -304,13 +303,13 @@ export const mergeServerAndStaticData = ( contentSources: ContentSourceDetails[] ) => { const combined = [] as CombinedDataItem[]; - serverData.forEach((serverItem) => { - const type = serverItem.serviceType; - const staticItem = staticData.find(({ serviceType }) => serviceType === type); + staticData.forEach((staticItem) => { + const type = staticItem.serviceType; + const serverItem = serverData.find(({ serviceType }) => serviceType === type); const connectedSource = contentSources.find(({ serviceType }) => serviceType === type); combined.push({ - ...serverItem, ...staticItem, + ...serverItem, connected: !!connectedSource, } as CombinedDataItem); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx index cf5dc48682ae8..49c8ebbbebc08 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx @@ -34,7 +34,7 @@ describe('SourcesRouter', () => { }); it('renders sources routes', () => { - const TOTAL_ROUTES = 63; + const TOTAL_ROUTES = 86; const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); @@ -45,8 +45,8 @@ describe('SourcesRouter', () => { setMockValues({ ...mockValues, hasPlatinumLicense: false }); const wrapper = shallow(); - expect(wrapper.find(Redirect).first().prop('from')).toEqual(ADD_SOURCE_PATH); - expect(wrapper.find(Redirect).first().prop('to')).toEqual(SOURCES_PATH); + expect(wrapper.find(Redirect).last().prop('from')).toEqual(ADD_SOURCE_PATH); + expect(wrapper.find(Redirect).last().prop('to')).toEqual(SOURCES_PATH); }); it('redirects when cannot create sources', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx index 23109506b364e..c2cd58a90f209 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx @@ -14,19 +14,27 @@ import { useActions, useValues } from 'kea'; import { LicensingLogic } from '../../../shared/licensing'; import { AppLogic } from '../../app_logic'; import { - ADD_GITHUB_VIA_APP_PATH, - ADD_GITHUB_ENTERPRISE_SERVER_VIA_APP_PATH, + GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE, + GITHUB_VIA_APP_SERVICE_TYPE, +} from '../../constants'; +import { ADD_SOURCE_PATH, SOURCE_DETAILS_PATH, PRIVATE_SOURCES_PATH, SOURCES_PATH, getSourcesPath, + getAddPath, + ADD_CUSTOM_PATH, } from '../../routes'; +import { hasMultipleConnectorOptions } from '../../utils'; import { AddSource, AddSourceList, GitHubViaApp } from './components/add_source'; +import { AddCustomSource } from './components/add_source/add_custom_source'; +import { ConfigurationChoice } from './components/add_source/configuration_choice'; +import { ExternalConnectorConfig } from './components/add_source/external_connector_config'; import { OrganizationSources } from './organization_sources'; import { PrivateSources } from './private_sources'; -import { staticSourceData } from './source_data'; +import { staticCustomSourceData, staticSourceData as sources } from './source_data'; import { SourceRouter } from './source_router'; import { SourcesLogic } from './sources_logic'; @@ -68,36 +76,121 @@ export const SourcesRouter: React.FC = () => { - + - + - {staticSourceData.map(({ addPath, accountContextOnly }, i) => ( - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ))} - {staticSourceData.map(({ addPath }, i) => ( - - + {sources.map((sourceData, i) => { + const { serviceType, externalConnectorAvailable, internalConnectorAvailable } = sourceData; + const path = `${getSourcesPath(getAddPath(serviceType), isOrganization)}`; + const defaultOption = internalConnectorAvailable + ? 'internal' + : externalConnectorAvailable + ? 'external' + : 'custom'; + return ( + + {hasMultipleConnectorOptions(sourceData) ? ( + + ) : ( + + )} + + ); + })} + + + + {sources + .filter((sourceData) => sourceData.internalConnectorAvailable) + .map((sourceData, i) => { + const { serviceType, accountContextOnly } = sourceData; + return ( + + {!hasPlatinumLicense && accountContextOnly ? ( + + ) : ( + + )} + + ); + })} + {sources + .filter((sourceData) => sourceData.externalConnectorAvailable) + .map((sourceData, i) => { + const { serviceType, accountContextOnly } = sourceData; + + return ( + + {!hasPlatinumLicense && accountContextOnly ? ( + + ) : ( + + )} + + ); + })} + {sources + .filter((sourceData) => sourceData.customConnectorAvailable) + .map((sourceData, i) => { + const { serviceType, accountContextOnly } = sourceData; + return ( + + {!hasPlatinumLicense && accountContextOnly ? ( + + ) : ( + + )} + + ); + })} + {sources.map((sourceData, i) => ( + + ))} - {staticSourceData.map(({ addPath }, i) => ( - - + {sources.map((sourceData, i) => ( + + ))} - {staticSourceData.map(({ addPath, configuration: { needsConfiguration } }, i) => { - if (needsConfiguration) + {sources.map((sourceData, i) => { + if (sourceData.configuration.needsConfiguration) return ( - - + + ); })} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx index 85f91f769cc77..be139fd6b38ea 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx @@ -33,9 +33,7 @@ import { PRIVATE_SOURCE, UPDATE_BUTTON, } from '../../../constants'; -import { getSourcesPath } from '../../../routes'; -import { SourceDataItem } from '../../../types'; -import { staticSourceData } from '../../content_sources/source_data'; +import { getAddPath, getEditPath, getSourcesPath } from '../../../routes'; import { SettingsLogic } from '../settings_logic'; export const Connectors: React.FC = () => { @@ -52,9 +50,9 @@ export const Connectors: React.FC = () => { ); const getRowActions = (configured: boolean, serviceType: string, supportedByLicense: boolean) => { - const { addPath, editPath } = staticSourceData.find( - (s) => s.serviceType === serviceType - ) as SourceDataItem; + const addPath = getAddPath(serviceType); + const editPath = getEditPath(serviceType); + const configurePath = getSourcesPath(addPath, true); const updateButtons = ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx index 35619d2b2d560..af8b8fe461f16 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx @@ -18,6 +18,8 @@ import { EuiConfirmModal } from '@elastic/eui'; import { SaveConfig } from '../../content_sources/components/add_source/save_config'; +import { staticSourceData } from '../../content_sources/source_data'; + import { SourceConfig } from './source_config'; describe('SourceConfig', () => { @@ -31,7 +33,7 @@ describe('SourceConfig', () => { }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -42,13 +44,13 @@ describe('SourceConfig', () => { it('renders a breadcrumb fallback while data is loading', () => { setMockValues({ dataLoading: true, sourceConfigData: {} }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.prop('pageChrome')).toEqual(['Settings', 'Content source connectors', '...']); }); it('handles delete click', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -60,7 +62,7 @@ describe('SourceConfig', () => { }); it('saves source config', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -72,7 +74,7 @@ describe('SourceConfig', () => { }); it('cancels and closes modal', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx index c2a0b60e1eca3..ea63f3bab77d9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx @@ -18,16 +18,15 @@ import { SourceDataItem } from '../../../types'; import { AddSourceHeader } from '../../content_sources/components/add_source/add_source_header'; import { AddSourceLogic } from '../../content_sources/components/add_source/add_source_logic'; import { SaveConfig } from '../../content_sources/components/add_source/save_config'; -import { staticSourceData } from '../../content_sources/source_data'; import { SettingsLogic } from '../settings_logic'; interface SourceConfigProps { - sourceIndex: number; + sourceData: SourceDataItem; } -export const SourceConfig: React.FC = ({ sourceIndex }) => { +export const SourceConfig: React.FC = ({ sourceData }) => { const [confirmModalVisible, setConfirmModalVisibility] = useState(false); - const { configuration, serviceType } = staticSourceData[sourceIndex] as SourceDataItem; + const { configuration, serviceType } = sourceData; const { deleteSourceConfig } = useActions(SettingsLogic); const { saveSourceConfig, getSourceConfigData } = useActions(AddSourceLogic); const { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx index d9aeba361d240..7c5e501d6a2a1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx @@ -14,6 +14,7 @@ import { ORG_SETTINGS_CUSTOMIZE_PATH, ORG_SETTINGS_CONNECTORS_PATH, ORG_SETTINGS_OAUTH_APPLICATION_PATH, + getEditPath, } from '../../routes'; import { staticSourceData } from '../content_sources/source_data'; @@ -41,9 +42,9 @@ export const SettingsRouter: React.FC = () => { - {staticSourceData.map(({ editPath }, i) => ( - - + {staticSourceData.map((sourceData, i) => ( + + ))} diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index 222288d369fdb..d106302128204 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -38,6 +38,14 @@ const oauthConfigSchema = schema.object({ consumer_key: schema.maybe(schema.string()), }); +const externalConnectorSchema = schema.object({ + url: schema.string(), + api_key: schema.string(), + service_type: schema.string(), +}); + +const postConnectorSchema = schema.oneOf([externalConnectorSchema, oauthConfigSchema]); + const displayFieldSchema = schema.object({ fieldName: schema.string(), label: schema.string(), @@ -872,7 +880,7 @@ export function registerOrgSourceOauthConfigurationsRoute({ { path: '/internal/workplace_search/org/settings/connectors', validate: { - body: oauthConfigSchema, + body: postConnectorSchema, }, }, enterpriseSearchRequestHandler.createRequest({ diff --git a/x-pack/plugins/ml/common/types/annotations.ts b/x-pack/plugins/ml/common/types/annotations.ts index dbc146c1175d8..57b7551c2308a 100644 --- a/x-pack/plugins/ml/common/types/annotations.ts +++ b/x-pack/plugins/ml/common/types/annotations.ts @@ -6,10 +6,10 @@ */ // The Annotation interface is based on annotation documents stored in the -// `.ml-annotations-6` index, accessed via the `.ml-annotations-[read|write]` aliases. +// `.ml-annotations-*` index, accessed via the `.ml-annotations-[read|write]` aliases. // Annotation document mapping: -// PUT .ml-annotations-6 +// PUT .ml-annotations-000001 // { // "mappings": { // "annotation": { @@ -54,8 +54,8 @@ // POST /_aliases // { // "actions" : [ -// { "add" : { "index" : ".ml-annotations-6", "alias" : ".ml-annotations-read" } }, -// { "add" : { "index" : ".ml-annotations-6", "alias" : ".ml-annotations-write" } } +// { "add" : { "index" : ".ml-annotations-000001", "alias" : ".ml-annotations-read" } }, +// { "add" : { "index" : ".ml-annotations-000001", "alias" : ".ml-annotations-write" } } // ] // } diff --git a/x-pack/plugins/ml/server/models/annotation_service/__mocks__/get_annotations_response.json b/x-pack/plugins/ml/server/models/annotation_service/__mocks__/get_annotations_response.json index a0b8f6b242319..829b9c6581bea 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/__mocks__/get_annotations_response.json +++ b/x-pack/plugins/ml/server/models/annotation_service/__mocks__/get_annotations_response.json @@ -15,8 +15,7 @@ "max_score": 0, "hits": [ { - "_index": ".ml-annotations-6", - "_type": "doc", + "_index": ".ml-annotations-000001", "_id": "T-CNvmgBQUJYQVn7TCPA", "_score": 0, "_source": { @@ -32,8 +31,7 @@ } }, { - "_index": ".ml-annotations-6", - "_type": "doc", + "_index": ".ml-annotations-000001", "_id": "3lVpvmgB5xYzd3PM-MSe", "_score": 0, "_source": { diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts index fdeacd148434c..e6aa31501a557 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts @@ -41,7 +41,7 @@ describe('annotation_service', () => { const annotationMockId = 'mockId'; const deleteParamsMock: DeleteParams = { - index: '.ml-annotations-6', + index: '.ml-annotations-000001', id: annotationMockId, refresh: 'wait_for', }; diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts index 4717a2ea1ce28..60d633b16097d 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts @@ -78,6 +78,31 @@ export interface AggByJob { } export function annotationProvider({ asInternalUser }: IScopedClusterClient) { + // Find the index the annotation is stored in. + async function fetchAnnotationIndex(id: string) { + const searchParams: estypes.SearchRequest = { + index: ML_ANNOTATIONS_INDEX_ALIAS_READ, + size: 1, + body: { + query: { + ids: { + values: [id], + }, + }, + }, + }; + + const body = await asInternalUser.search(searchParams); + const totalCount = + typeof body.hits.total === 'number' ? body.hits.total : body.hits.total!.value; + + if (totalCount === 0) { + throw Boom.notFound(`Cannot find annotation with ID ${id}`); + } + + return body.hits.hits[0]._index; + } + async function indexAnnotation(annotation: Annotation, username: string) { if (isAnnotation(annotation) === false) { // No need to translate, this will not be exposed in the UI. @@ -101,6 +126,8 @@ export function annotationProvider({ asInternalUser }: IScopedClusterClient) { if (typeof annotation._id !== 'undefined') { params.id = annotation._id; + params.index = await fetchAnnotationIndex(annotation._id); + params.require_alias = false; delete params.body._id; delete params.body.key; } @@ -387,28 +414,7 @@ export function annotationProvider({ asInternalUser }: IScopedClusterClient) { } async function deleteAnnotation(id: string) { - // Find the index the annotation is stored in. - const searchParams: estypes.SearchRequest = { - index: ML_ANNOTATIONS_INDEX_ALIAS_READ, - size: 1, - body: { - query: { - ids: { - values: [id], - }, - }, - }, - }; - - const body = await asInternalUser.search(searchParams); - const totalCount = - typeof body.hits.total === 'number' ? body.hits.total : body.hits.total!.value; - - if (totalCount === 0) { - throw Boom.notFound(`Cannot find annotation with ID ${id}`); - } - - const index = body.hits.hits[0]._index; + const index = await fetchAnnotationIndex(id); const deleteParams: DeleteParams = { index, diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts index 3593030913ba7..dbd9116498700 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts @@ -112,6 +112,7 @@ function createRule(shouldWriteAlerts: boolean = true) { services: { alertFactory, savedObjectsClient: {} as any, + uiSettingsClient: {} as any, scopedClusterClient: {} as any, shouldWriteAlerts: () => shouldWriteAlerts, shouldStopExecution: () => false, diff --git a/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts b/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts index 3d880988182b1..38843d95d6b73 100644 --- a/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts +++ b/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts @@ -7,6 +7,7 @@ import { elasticsearchServiceMock, savedObjectsClientMock, + uiSettingsServiceMock, } from '../../../../../src/core/server/mocks'; import { AlertExecutorOptions, @@ -69,6 +70,7 @@ export const createDefaultAlertExecutorOptions = < services: { alertFactory: alertsMock.createAlertServices().alertFactory, savedObjectsClient: savedObjectsClientMock.create(), + uiSettingsClient: uiSettingsServiceMock.createClient(), scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), shouldWriteAlerts: () => shouldWriteAlerts, shouldStopExecution: () => false, diff --git a/x-pack/plugins/screenshotting/server/layouts/preserve_layout.css b/x-pack/plugins/screenshotting/server/layouts/preserve_layout.css index 60513c417165f..7b692881d5bde 100644 --- a/x-pack/plugins/screenshotting/server/layouts/preserve_layout.css +++ b/x-pack/plugins/screenshotting/server/layouts/preserve_layout.css @@ -1,12 +1,5 @@ -/* - ****** - ****** This is a collection of CSS overrides that make Kibana look better for - ****** generating PDF reports with headless browser - ****** - */ - /** - * global + * Global utilities */ /* elements can hide themselves when shared */ @@ -14,26 +7,9 @@ display: none !important; } -/* hide unusable controls */ -kbn-top-nav, -filter-bar, -.kbnTopNavMenu__wrapper, -::-webkit-scrollbar, -.euiNavDrawer { - display: none !important; -} - /** - * Discover Tweaks - */ - -/* hide unusable controls */ -discover-app .dscTimechart, -discover-app .dscSidebar__container, -discover-app .dscCollapsibleSidebar__collapseButton, -discover-app .discover-table-footer { - display: none; -} +* Global overrides +*/ /** * The global banner (e.g. "Help us improve Elastic...") should not print. @@ -41,53 +17,3 @@ discover-app .discover-table-footer { #globalBannerList { display: none; } - -/** - * Visualize Editor Tweaks - */ - -/* hide unusable controls -* !important is required to override resizable panel inline display */ -.visEditor__content .visEditor--default > :not(.visEditor__visualization__wrapper) { - display: none !important; -} - -/** THIS IS FOR TSVB UNTIL REFACTOR **/ -.tvbEditorVisualization { - position: static !important; -} -.visualize .tvbVisTimeSeries__legendToggle, -.tvbEditor--hideForReporting { - /* all non-content rows in interface */ - display: none; -} -/** END TSVB BAD BAD HACKS **/ - -/* remove left padding from visualizations so that map lines up with .leaflet-container and -* setting the position to be fixed and to take up the entire screen, because some zoom levels/viewports -* are triggering the media breakpoints that cause the .visEditor__canvas to take up more room than the viewport */ -.visEditor .visEditor__canvas { - padding-left: 0px; - position: fixed; - width: 100%; - height: 100%; - top: 0; - left: 0; -} - -/** - * Visualization tweaks - */ - -/* hide unusable controls */ -.visualize .visLegend__toggle, -.visualize .kbnAggTable__controls/* export raw, export formatted, etc. */ , -.visualize .leaflet-container .leaflet-top.leaflet-left/* tilemap controls */ , -.visualize paginate-controls /* page numbers */ { - display: none; -} - -/* Ensure the min-height of the small breakpoint isn't used */ -.vis-editor visualization { - min-height: 0 !important; -} diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts index 5789db692eb45..f92408752d18e 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts @@ -256,7 +256,7 @@ export class ExceptionsListItemGenerator extends BaseDataGenerator = [ diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/services/index.ts b/x-pack/plugins/security_solution/public/management/pages/blocklist/services/index.ts index 002093007329d..fab5be9f7ab3b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/services/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/services/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './blocklists_api_client'; +export { BlocklistsApiClient } from './blocklists_api_client'; diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx index 8d98b401102f1..c016c10ad319f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx @@ -39,7 +39,7 @@ const BLOCKLIST_PAGE_LABELS: ArtifactListPageProps['labels'] = { defaultMessage: 'Blocklist', }), pageAboutInfo: i18n.translate('xpack.securitySolution.blocklist.pageAboutInfo', { - defaultMessage: 'Add a blocklist to block applications or files from running.', + defaultMessage: 'Add a blocklist to block applications or files from running on the endpoint.', }), pageAddButtonTitle: i18n.translate('xpack.securitySolution.blocklist.pageAddButtonTitle', { defaultMessage: 'Add blocklist entry', diff --git a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts index db39d4c5108e2..b5e787bd90c98 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts @@ -41,8 +41,8 @@ import { Manifest } from '../endpoint/lib/artifacts'; import { NewPackagePolicy } from '../../../fleet/common/types/models'; import { ManifestSchema } from '../../common/endpoint/schema/manifest'; import { DeletePackagePoliciesResponse } from '../../../fleet/common'; -import { ARTIFACT_LISTS_IDS_TO_REMOVE } from './handlers/remove_policy_from_artifacts'; import { createMockPolicyData } from '../endpoint/services/feature_usage'; +import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../common/endpoint/service/artifacts/constants'; describe('ingest_integration tests ', () => { let endpointAppContextMock: EndpointAppContextServiceStartContract; @@ -334,11 +334,11 @@ describe('ingest_integration tests ', () => { await invokeDeleteCallback(); expect(exceptionListClient.findExceptionListsItem).toHaveBeenCalledWith({ - listId: ARTIFACT_LISTS_IDS_TO_REMOVE, - filter: ARTIFACT_LISTS_IDS_TO_REMOVE.map( + listId: ALL_ENDPOINT_ARTIFACT_LIST_IDS, + filter: ALL_ENDPOINT_ARTIFACT_LIST_IDS.map( () => `exception-list-agnostic.attributes.tags:"policy:${policyId}"` ), - namespaceType: ARTIFACT_LISTS_IDS_TO_REMOVE.map(() => 'agnostic'), + namespaceType: ALL_ENDPOINT_ARTIFACT_LIST_IDS.map(() => 'agnostic'), page: 1, perPage: 50, sortField: undefined, diff --git a/x-pack/plugins/security_solution/server/fleet_integration/handlers/remove_policy_from_artifacts.ts b/x-pack/plugins/security_solution/server/fleet_integration/handlers/remove_policy_from_artifacts.ts index 57a23d677e014..28ee9d5ad81da 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/handlers/remove_policy_from_artifacts.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/handlers/remove_policy_from_artifacts.ts @@ -7,19 +7,9 @@ import pMap from 'p-map'; -import { - ENDPOINT_TRUSTED_APPS_LIST_ID, - ENDPOINT_EVENT_FILTERS_LIST_ID, - ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, -} from '@kbn/securitysolution-list-constants'; import { ExceptionListClient } from '../../../../lists/server'; import { PostPackagePolicyDeleteCallback } from '../../../../fleet/server'; - -export const ARTIFACT_LISTS_IDS_TO_REMOVE = [ - ENDPOINT_TRUSTED_APPS_LIST_ID, - ENDPOINT_EVENT_FILTERS_LIST_ID, - ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, -]; +import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../../common/endpoint/service/artifacts/constants'; /** * Removes policy from artifacts @@ -32,11 +22,11 @@ export const removePolicyFromArtifacts = async ( const findArtifactsByPolicy = (currentPage: number) => { return exceptionsClient.findExceptionListsItem({ - listId: ARTIFACT_LISTS_IDS_TO_REMOVE, - filter: ARTIFACT_LISTS_IDS_TO_REMOVE.map( + listId: ALL_ENDPOINT_ARTIFACT_LIST_IDS as string[], + filter: ALL_ENDPOINT_ARTIFACT_LIST_IDS.map( () => `exception-list-agnostic.attributes.tags:"policy:${policy.id}"` ), - namespaceType: ARTIFACT_LISTS_IDS_TO_REMOVE.map(() => 'agnostic'), + namespaceType: ALL_ENDPOINT_ARTIFACT_LIST_IDS.map(() => 'agnostic'), page: currentPage, perPage: 50, sortField: undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts index 11396864d802d..95dcb918cd165 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts @@ -195,6 +195,7 @@ export const previewRulesRoute = async ( }), savedObjectsClient: context.core.savedObjects.client, scopedClusterClient: context.core.elasticsearch.client, + uiSettingsClient: context.core.uiSettings.client, }, spaceId, startedAt: startedAt.toDate(),