From d87e30e8c385eae4f599367db64f06a6b5172b34 Mon Sep 17 00:00:00 2001 From: DeDe Morton Date: Wed, 2 Jun 2021 10:02:26 -0700 Subject: [PATCH 01/35] Edit text strings in Heartbeat setup prompt (#100753) * Edit text strings in Heartbeat setup prompt * Update snapshot to fix test failure Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../__snapshots__/data_or_index_missing.test.tsx.snap | 4 ++-- .../overview/empty_state/data_or_index_missing.tsx | 6 +++--- .../public/components/overview/empty_state/empty_state.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap index 41e46259715ee..45e40f71c0fde 100644 --- a/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap @@ -51,14 +51,14 @@ exports[`DataOrIndexMissing component renders headingMessage 1`] = `

diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.tsx b/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.tsx index 77927b5750ff3..7f9839ff94dbe 100644 --- a/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.tsx +++ b/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.tsx @@ -43,14 +43,14 @@ export const DataOrIndexMissing = ({ headingMessage, settings }: DataMissingProp

diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state.tsx b/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state.tsx index 5a28c7c2592d7..a6fd6579c49fa 100644 --- a/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state.tsx +++ b/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state.tsx @@ -38,7 +38,7 @@ export const EmptyStateComponent = ({ const noIndicesMessage = ( {settings?.heartbeatIndices} }} /> ); From e607b5859092317a9aa5a04a4f37c13dec53f0fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Wed, 2 Jun 2021 13:08:09 -0400 Subject: [PATCH 02/35] Fix alerting health API to consider rules in all spaces (#100879) * Initial commit * Expand tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../alerting/server/health/get_health.ts | 4 + .../alerting_api_integration/common/config.ts | 1 + .../tests/alerting/health.ts | 128 ++++++++++++++++++ .../tests/alerting/index.ts | 1 + 4 files changed, 134 insertions(+) create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/health.ts diff --git a/x-pack/plugins/alerting/server/health/get_health.ts b/x-pack/plugins/alerting/server/health/get_health.ts index 4a0266c9b729f..6966c9b75ca43 100644 --- a/x-pack/plugins/alerting/server/health/get_health.ts +++ b/x-pack/plugins/alerting/server/health/get_health.ts @@ -34,6 +34,7 @@ export const getHealth = async ( sortOrder: 'desc', page: 1, perPage: 1, + namespaces: ['*'], }); if (decryptErrorData.length > 0) { @@ -51,6 +52,7 @@ export const getHealth = async ( sortOrder: 'desc', page: 1, perPage: 1, + namespaces: ['*'], }); if (executeErrorData.length > 0) { @@ -68,6 +70,7 @@ export const getHealth = async ( sortOrder: 'desc', page: 1, perPage: 1, + namespaces: ['*'], }); if (readErrorData.length > 0) { @@ -83,6 +86,7 @@ export const getHealth = async ( type: 'alert', sortField: 'executionStatus.lastExecutionDate', sortOrder: 'desc', + namespaces: ['*'], }); const lastExecutionDate = noErrorData.length > 0 diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index c56e8adfbe34f..548b4d0db1124 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -151,6 +151,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) `--xpack.actions.allowedHosts=${JSON.stringify(['localhost', 'some.non.existent.com'])}`, '--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', '--xpack.alerting.invalidateApiKeysTask.interval="15s"', + '--xpack.alerting.healthCheck.interval="1s"', `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.actions.rejectUnauthorized=${rejectUnauthorized}`, `--xpack.actions.tls.verificationMode=${verificationMode}`, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/health.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/health.ts new file mode 100644 index 0000000000000..668de3eb4fb9e --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/health.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + getUrlPrefix, + getTestAlertData, + ObjectRemover, + AlertUtils, + ESTestIndexTool, + ES_TEST_INDEX_NAME, +} from '../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function createFindTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + const retry = getService('retry'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esTestIndexTool = new ESTestIndexTool(es, retry); + + describe('health', () => { + const objectRemover = new ObjectRemover(supertest); + + before(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + }); + + after(async () => { + await esTestIndexTool.destroy(); + }); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + + describe(scenario.id, () => { + let alertUtils: AlertUtils; + let indexRecordActionId: string; + + before(async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My action', + connector_type_id: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + indexRecordActionId = createdAction.id; + objectRemover.add(space.id, indexRecordActionId, 'connector', 'actions'); + + alertUtils = new AlertUtils({ + user, + space, + supertestWithoutAuth, + indexRecordActionId, + objectRemover, + }); + }); + + after(() => objectRemover.removeAll()); + + it('should return healthy status by default', async () => { + const { body: health } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerting/_health`) + .auth(user.username, user.password); + expect(health.is_sufficiently_secure).to.eql(true); + expect(health.has_permanent_encryption_key).to.eql(true); + expect(health.alerting_framework_heath.decryption_health.status).to.eql('ok'); + expect(health.alerting_framework_heath.execution_health.status).to.eql('ok'); + expect(health.alerting_framework_heath.read_health.status).to.eql('ok'); + }); + + it('should return error when a rule in the default space is failing', async () => { + const reference = alertUtils.generateReference(); + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + schedule: { + interval: '5m', + }, + rule_type_id: 'test.failing', + params: { + index: ES_TEST_INDEX_NAME, + reference, + }, + }) + ) + .expect(200); + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + const ruleInErrorStatus = await retry.tryForTime(30000, async () => { + const { body: rule } = await supertest + .get(`${getUrlPrefix(space.id)}/api/alerting/rule/${createdRule.id}`) + .expect(200); + expect(rule.execution_status.status).to.eql('error'); + return rule; + }); + + await retry.tryForTime(30000, async () => { + const { body: health } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerting/_health`) + .auth(user.username, user.password); + expect(health.alerting_framework_heath.execution_health.status).to.eql('warn'); + expect(health.alerting_framework_heath.execution_health.timestamp).to.eql( + ruleInErrorStatus.execution_status.last_execution_date + ); + }); + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts index b1b52d89997cd..6ca68bd188124 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts @@ -51,6 +51,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./alerts')); loadTestFile(require.resolve('./event_log')); loadTestFile(require.resolve('./mustache_templates')); + loadTestFile(require.resolve('./health')); }); }); } From 66553681c085e7a31d11a6f96139acaffa5d0e24 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 2 Jun 2021 13:44:04 -0400 Subject: [PATCH 03/35] [Fleet] Fix host input with empty value (#101178) --- .../components/settings_flyout/hosts_input.test.tsx | 9 +++++++++ .../fleet/components/settings_flyout/hosts_input.tsx | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx index 27bf5af72fb61..f441cfd951ba9 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx @@ -66,3 +66,12 @@ test('it should allow to update existing host with multiple hosts', async () => fireEvent.change(inputEl, { target: { value: 'http://newhost.com' } }); expect(mockOnChange).toHaveBeenCalledWith(['http://newhost.com', 'http://host2.com']); }); + +test('it should render an input if there is not hosts', async () => { + const { utils, mockOnChange } = renderInput([]); + + const inputEl = await utils.findByDisplayValue(''); + expect(inputEl).toBeDefined(); + fireEvent.change(inputEl, { target: { value: 'http://newhost.com' } }); + expect(mockOnChange).toHaveBeenCalledWith(['http://newhost.com']); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx index 0e5f9a5e028b5..6c87a983f58a4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx @@ -132,7 +132,7 @@ const SortableTextField: FunctionComponent = React.memo( export const HostsInput: FunctionComponent = ({ id, - value, + value: valueFromProps, onChange, helpText, label, @@ -140,6 +140,10 @@ export const HostsInput: FunctionComponent = ({ errors, }) => { const [autoFocus, setAutoFocus] = useState(false); + const value = useMemo(() => { + return valueFromProps.length ? valueFromProps : ['']; + }, [valueFromProps]); + const rows = useMemo( () => value.map((host, idx) => ({ From dc5511f73bfb631b50e4ddf4ebe6a6b8c6bbdfc8 Mon Sep 17 00:00:00 2001 From: Tre Date: Wed, 2 Jun 2021 12:28:59 -0600 Subject: [PATCH 04/35] [QA] Bind the retry to fixup error in it repo tests (#100948) Verfiied via: https://internal-ci.elastic.co/view/All/job/elastic+integration-test+master/487/ --- .../apps/reporting/reporting_watcher.js | 2 +- .../apps/reporting/reporting_watcher_png.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher.js b/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher.js index 31eb6d65ce7ac..fb881162f51e8 100644 --- a/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher.js +++ b/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher.js @@ -85,7 +85,7 @@ export default function ({ getService, getPageObjects }) { await putWatcher(watch, id, body, client, log); }); it('should be successful and increment revision', async () => { - await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime); + await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime.bind(retry)); }); it('should delete watch and update revision', async () => { await deleteWatcher(watch, id, client, log); diff --git a/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher_png.js b/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher_png.js index 7e9ced57fdc0b..db913f563ebb0 100644 --- a/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher_png.js +++ b/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher_png.js @@ -79,7 +79,7 @@ export default ({ getService, getPageObjects }) => { await putWatcher(watch, id, body, client, log); }); it('should be successful and increment revision', async () => { - await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime); + await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime.bind(retry)); }); it('should delete watch and update revision', async () => { await deleteWatcher(watch, id, client, log); From 6724a474dee0d2590996200c99ba2bffcae09560 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Wed, 2 Jun 2021 11:39:18 -0700 Subject: [PATCH 05/35] Convert $json to json in package README code blocks (#101187) --- .../epm/screens/detail/overview/markdown_renderers.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx index 47c327b17c241..cbc2f7b5f7888 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx @@ -60,8 +60,10 @@ export const markdownRenderers = { ), code: ({ language, value }: { language: string; value: string }) => { + // Old packages are using `$json`, which is not valid any more with the move to prism.js + const parsedLang = language === '$json' ? 'json' : language; return ( - + {value} ); From dbac313d406f4ae44351b134859fb7ecdd8962bd Mon Sep 17 00:00:00 2001 From: gchaps <33642766+gchaps@users.noreply.github.com> Date: Wed, 2 Jun 2021 11:42:26 -0700 Subject: [PATCH 06/35] [DOCS] Updates homebrew content to use latest version (#101199) --- docs/setup/install/brew.asciidoc | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/setup/install/brew.asciidoc b/docs/setup/install/brew.asciidoc index e3085f6e225aa..eeba869a259d4 100644 --- a/docs/setup/install/brew.asciidoc +++ b/docs/setup/install/brew.asciidoc @@ -14,15 +14,13 @@ brew tap elastic/tap ------------------------- Once you've tapped the Elastic Homebrew repo, you can use `brew install` to -install the default distribution of {kib}: +install the **latest version** of {kib}: [source,sh] ------------------------- brew install elastic/tap/kibana-full ------------------------- -This installs the most recently released distribution of {kib}. - [[brew-layout]] ==== Directory layout for Homebrew installs From 8e48d48f86633011b3e7eb411e8cfe672fa54c08 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 2 Jun 2021 20:20:26 +0100 Subject: [PATCH 07/35] docs(NA): update developer getting started guide to build on windows within Bazel (#101181) --- docs/developer/getting-started/index.asciidoc | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/developer/getting-started/index.asciidoc b/docs/developer/getting-started/index.asciidoc index ac8eff132fcfe..a28a95605bc6a 100644 --- a/docs/developer/getting-started/index.asciidoc +++ b/docs/developer/getting-started/index.asciidoc @@ -14,9 +14,14 @@ In order to support Windows development we currently require you to use one of t As well as installing https://www.microsoft.com/en-us/download/details.aspx?id=48145[Visual C++ Redistributable for Visual Studio 2015]. +In addition we also require you to do the following: + +- Install https://www.microsoft.com/en-us/download/details.aspx?id=48145[Visual C++ Redistributable for Visual Studio 2015] +- Enable the https://docs.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development[Windows Developer Mode] +- Enable https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/fsutil-8dot3name[8.3 filename support] by running the following command in a windows command prompt with admin rights `fsutil 8dot3name set 0` + Before running the steps listed below, please make sure you have installed everything -that we require and listed above and that you are running the mentioned commands -through Git bash or WSL. +that we require and listed above and that you are running all the commands from now on through Git bash or WSL. [discrete] [[get-kibana-code]] From 98527ad232a823ae72731062435f707d79644732 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 2 Jun 2021 14:14:54 -0700 Subject: [PATCH 08/35] skip suite failing es promotion (#101219) --- .../apis/management/index_lifecycle_management/policies.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js index 55b4da8b11ec2..8f40f5826c537 100644 --- a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js +++ b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js @@ -32,7 +32,8 @@ export default function ({ getService }) { const { addPolicyToIndex } = registerIndexHelpers({ supertest }); - describe('policies', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/101219 + describe.skip('policies', () => { after(() => Promise.all([cleanUpEsResources(), cleanUpPolicies()])); describe('list', () => { From 71b4c38c4a579b0b4871b9f437d6d855f5076511 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 2 Jun 2021 17:37:11 -0600 Subject: [PATCH 09/35] [Security Solution] [Bug Fix] Fix flakey cypress tests (#101231) --- .../cypress/support/commands.js | 57 ++----------------- 1 file changed, 6 insertions(+), 51 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/support/commands.js b/x-pack/plugins/security_solution/cypress/support/commands.js index d9de00be0ea9e..90eb9a38d7509 100644 --- a/x-pack/plugins/security_solution/cypress/support/commands.js +++ b/x-pack/plugins/security_solution/cypress/support/commands.js @@ -31,62 +31,17 @@ // -- This is will overwrite an existing command -- // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) -import { findIndex } from 'lodash/fp'; - -const getFindRequestConfig = (searchStrategyName, factoryQueryType) => { - if (!factoryQueryType) { - return { - options: { strategy: searchStrategyName }, - }; - } - - return { - options: { strategy: searchStrategyName }, - request: { factoryQueryType }, - }; -}; - Cypress.Commands.add( 'stubSearchStrategyApi', function (stubObject, factoryQueryType, searchStrategyName = 'securitySolutionSearchStrategy') { cy.intercept('POST', '/internal/bsearch', (req) => { - const findRequestConfig = getFindRequestConfig(searchStrategyName, factoryQueryType); - const requestIndex = findIndex(findRequestConfig, req.body.batch); - - if (requestIndex > -1) { - return req.reply((res) => { - const responseObjectsArray = res.body.split('\n').map((responseString) => { - try { - return JSON.parse(responseString); - } catch { - return responseString; - } - }); - const responseIndex = findIndex({ id: requestIndex }, responseObjectsArray); - - const stubbedResponseObjectsArray = [...responseObjectsArray]; - stubbedResponseObjectsArray[responseIndex] = { - ...stubbedResponseObjectsArray[responseIndex], - result: { - ...stubbedResponseObjectsArray[responseIndex].result, - ...stubObject, - }, - }; - - const stubbedResponse = stubbedResponseObjectsArray - .map((object) => { - try { - return JSON.stringify(object); - } catch { - return object; - } - }) - .join('\n'); - - res.send(stubbedResponse); - }); + if (searchStrategyName === 'securitySolutionIndexFields') { + req.reply(stubObject.rawResponse); + } else if (factoryQueryType === 'overviewHost') { + req.reply(stubObject.overviewHost); + } else if (factoryQueryType === 'overviewNetwork') { + req.reply(stubObject.overviewNetwork); } - req.reply(); }); } From 45ae6cc39b09e6ee4132ed32f74df290e532ed40 Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Wed, 2 Jun 2021 22:33:43 -0700 Subject: [PATCH 10/35] [Alerting UI] Reduced triggersActionsUi bundle size by making all action types UI validation messages translations asynchronous. (#100525) * [Alerting UI] Reduced triggersActionsUi bundle size by making all connectors validation messages translations asyncronus. * changed validation logic to be async * fixed action form * fixed tests * fixed tests * fixed validation usage in security * fixed due to comments * fixed due to comments * added spinner for the validation awaiting * fixed typechecks Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/connectors/case/index.ts | 4 +- .../public/alerts/alert_form.test.tsx | 8 +- .../rules/step_rule_actions/index.tsx | 1 + .../rules/step_rule_actions/schema.test.tsx | 24 ++--- .../rules/step_rule_actions/schema.tsx | 28 ++--- .../rules/step_rule_actions/utils.test.ts | 16 +-- .../rules/step_rule_actions/utils.ts | 6 +- x-pack/plugins/triggers_actions_ui/README.md | 32 +++--- .../builtin_action_types/email/email.test.tsx | 28 ++--- .../builtin_action_types/email/email.tsx | 100 ++++-------------- .../email/email_connector.tsx | 31 ++++-- .../email/email_params.tsx | 25 +++-- .../email/translations.ts | 78 ++++++++++++++ .../es_index/es_index.test.tsx | 24 ++--- .../es_index/es_index.tsx | 37 ++----- .../es_index/es_index_connector.tsx | 6 +- .../es_index/es_index_params.tsx | 6 +- .../es_index/translations.ts | 29 +++++ .../builtin_action_types/jira/jira.test.tsx | 20 ++-- .../builtin_action_types/jira/jira.tsx | 46 +++++--- .../jira/jira_connectors.tsx | 12 ++- .../builtin_action_types/jira/jira_params.tsx | 2 + .../builtin_action_types/jira/translations.ts | 14 --- .../pagerduty/pagerduty.test.tsx | 14 +-- .../pagerduty/pagerduty.tsx | 39 ++----- .../pagerduty/pagerduty_connectors.tsx | 7 +- .../pagerduty/pagerduty_params.tsx | 14 ++- .../pagerduty/translations.ts | 29 +++++ .../resilient/resilient.test.tsx | 16 +-- .../resilient/resilient.tsx | 47 +++++--- .../resilient/resilient_connectors.tsx | 13 ++- .../resilient/resilient_params.tsx | 6 +- .../resilient/translations.ts | 14 --- .../server_log/server_log.test.tsx | 12 +-- .../server_log/server_log.tsx | 8 +- .../servicenow/servicenow.test.tsx | 16 +-- .../servicenow/servicenow.tsx | 67 +++++++++--- .../servicenow/servicenow_connectors.tsx | 9 +- .../servicenow/servicenow_itsm_params.tsx | 1 + .../servicenow/servicenow_sir_params.tsx | 1 + .../servicenow/translations.ts | 28 ----- .../builtin_action_types/slack/slack.test.tsx | 24 ++--- .../builtin_action_types/slack/slack.tsx | 46 ++------ .../slack/slack_connectors.tsx | 6 +- .../slack/slack_params.tsx | 2 +- .../slack/translations.ts | 36 +++++++ .../builtin_action_types/teams/teams.test.tsx | 24 ++--- .../builtin_action_types/teams/teams.tsx | 46 ++------ .../teams/teams_connectors.tsx | 7 +- .../teams/teams_params.tsx | 2 +- .../teams/translations.ts | 36 +++++++ .../webhook/translations.ts | 64 +++++++++++ .../webhook/webhook.test.tsx | 24 ++--- .../builtin_action_types/webhook/webhook.tsx | 85 +++------------ .../webhook/webhook_connectors.tsx | 25 +++-- .../action_connector_form.test.tsx | 8 +- .../action_connector_form.tsx | 11 +- .../action_form.test.tsx | 40 +++---- .../action_connector_form/action_form.tsx | 81 +++++++------- .../action_type_form.test.tsx | 17 ++- .../action_type_form.tsx | 17 ++- .../action_type_menu.test.tsx | 24 ++--- .../connector_add_flyout.test.tsx | 8 +- .../connector_add_flyout.tsx | 76 +++++++++---- .../connector_add_modal.test.tsx | 8 +- .../connector_add_modal.tsx | 79 ++++++++++---- .../connector_edit_flyout.test.tsx | 16 +-- .../connector_edit_flyout.tsx | 97 ++++++++++------- .../test_connector_form.test.tsx | 8 +- .../test_connector_form.tsx | 13 ++- .../actions_connectors_list.test.tsx | 8 +- .../sections/alert_form/alert_add.test.tsx | 8 +- .../sections/alert_form/alert_add.tsx | 21 +++- .../sections/alert_form/alert_add_footer.tsx | 18 +++- .../sections/alert_form/alert_edit.test.tsx | 8 +- .../sections/alert_form/alert_edit.tsx | 35 ++++-- .../sections/alert_form/alert_form.test.tsx | 10 +- .../sections/alert_form/alert_form.tsx | 24 +++-- .../public/application/type_registry.test.ts | 8 +- .../triggers_actions_ui/public/types.ts | 4 +- 80 files changed, 1149 insertions(+), 843 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/translations.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/translations.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/translations.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/translations.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/translations.ts diff --git a/x-pack/plugins/cases/public/components/connectors/case/index.ts b/x-pack/plugins/cases/public/components/connectors/case/index.ts index c2cf4980da7ec..8e6680cd65387 100644 --- a/x-pack/plugins/cases/public/components/connectors/case/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/case/index.ts @@ -25,7 +25,7 @@ const validateParams = (actionParams: CaseActionParams) => { validationResult.errors.caseId.push(i18n.CASE_CONNECTOR_CASE_REQUIRED); } - return validationResult; + return Promise.resolve(validationResult); }; export function getActionType(): ActionTypeModel { @@ -34,7 +34,7 @@ export function getActionType(): ActionTypeModel { iconClass: 'securityAnalyticsApp', selectMessage: i18n.CASE_CONNECTOR_DESC, actionTypeTitle: i18n.CASE_CONNECTOR_TITLE, - validateConnector: () => ({ config: { errors: {} }, secrets: { errors: {} } }), + validateConnector: () => Promise.resolve({ config: { errors: {} }, secrets: { errors: {} } }), validateParams, actionConnectorFields: null, actionParamsFields: lazy(() => import('./alert_fields')), diff --git a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx index 11642f4083d39..3eda13f5bcb38 100644 --- a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx +++ b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx @@ -94,12 +94,12 @@ describe('alert_form', () => { id: 'alert-action-type', iconClass: '', selectMessage: '', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx index a31371c31cbbb..8a85d35d77fac 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx @@ -89,6 +89,7 @@ const StepRuleActionsComponent: FC = ({ ...(defaultValues ?? stepActionsDefaultValue), kibanaSiemAppUrl: kibanaAbsoluteUrl, }; + const schema = useMemo(() => getSchema({ actionTypeRegistry }), [actionTypeRegistry]); const { form } = useForm({ defaultValue: initialState, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx index 992f30e795bbf..3266d6f61eeed 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx @@ -15,13 +15,13 @@ describe('stepRuleActions schema', () => { const actionTypeRegistry = actionTypeRegistryMock.create(); describe('validateSingleAction', () => { - it('should validate single action', () => { + it('should validate single action', async () => { (isUuid as jest.Mock).mockReturnValue(true); (validateActionParams as jest.Mock).mockReturnValue([]); (validateMustache as jest.Mock).mockReturnValue([]); expect( - validateSingleAction( + await validateSingleAction( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', @@ -33,12 +33,12 @@ describe('stepRuleActions schema', () => { ).toHaveLength(0); }); - it('should validate single action with invalid mustache template', () => { + it('should validate single action with invalid mustache template', async () => { (isUuid as jest.Mock).mockReturnValue(true); (validateActionParams as jest.Mock).mockReturnValue([]); (validateMustache as jest.Mock).mockReturnValue(['Message is not valid mustache template']); - const errors = validateSingleAction( + const errors = await validateSingleAction( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', @@ -54,12 +54,12 @@ describe('stepRuleActions schema', () => { expect(errors[0]).toEqual('Message is not valid mustache template'); }); - it('should validate single action with incorrect id', () => { + it('should validate single action with incorrect id', async () => { (isUuid as jest.Mock).mockReturnValue(false); (validateMustache as jest.Mock).mockReturnValue([]); (validateActionParams as jest.Mock).mockReturnValue([]); - const errors = validateSingleAction( + const errors = await validateSingleAction( { id: '823d4', group: 'default', @@ -74,10 +74,10 @@ describe('stepRuleActions schema', () => { }); describe('validateRuleActionsField', () => { - it('should validate rule actions field', () => { + it('should validate rule actions field', async () => { const validator = validateRuleActionsField(actionTypeRegistry); - const result = validator({ + const result = await validator({ path: '', value: [], form: {} as FormHook, @@ -88,11 +88,11 @@ describe('stepRuleActions schema', () => { expect(result).toEqual(undefined); }); - it('should validate incorrect rule actions field', () => { + it('should validate incorrect rule actions field', async () => { (getActionTypeName as jest.Mock).mockReturnValue('Slack'); const validator = validateRuleActionsField(actionTypeRegistry); - const result = validator({ + const result = await validator({ path: '', value: [ { @@ -117,7 +117,7 @@ describe('stepRuleActions schema', () => { }); }); - it('should validate multiple incorrect rule actions field', () => { + it('should validate multiple incorrect rule actions field', async () => { (isUuid as jest.Mock).mockReturnValueOnce(false); (getActionTypeName as jest.Mock).mockReturnValueOnce('Slack'); (isUuid as jest.Mock).mockReturnValueOnce(true); @@ -126,7 +126,7 @@ describe('stepRuleActions schema', () => { (validateMustache as jest.Mock).mockReturnValue(['Component is not valid mustache template']); const validator = validateRuleActionsField(actionTypeRegistry); - const result = validator({ + const result = await validator({ path: '', value: [ { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx index bc32bdc387cd2..a697d922eda97 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx @@ -13,42 +13,46 @@ import { AlertAction, ActionTypeRegistryContract, } from '../../../../../../triggers_actions_ui/public'; -import { FormSchema, ValidationFunc, ERROR_CODE } from '../../../../shared_imports'; +import { + FormSchema, + ValidationFunc, + ERROR_CODE, + ValidationError, +} from '../../../../shared_imports'; import { ActionsStepRule } from '../../../pages/detection_engine/rules/types'; import * as I18n from './translations'; import { isUuid, getActionTypeName, validateMustache, validateActionParams } from './utils'; -export const validateSingleAction = ( +export const validateSingleAction = async ( actionItem: AlertAction, actionTypeRegistry: ActionTypeRegistryContract -): string[] => { +): Promise => { if (!isUuid(actionItem.id)) { return [I18n.NO_CONNECTOR_SELECTED]; } - const actionParamsErrors = validateActionParams(actionItem, actionTypeRegistry); + const actionParamsErrors = await validateActionParams(actionItem, actionTypeRegistry); const mustacheErrors = validateMustache(actionItem.params); return [...actionParamsErrors, ...mustacheErrors]; }; -export const validateRuleActionsField = (actionTypeRegistry: ActionTypeRegistryContract) => ( +export const validateRuleActionsField = (actionTypeRegistry: ActionTypeRegistryContract) => async ( ...data: Parameters -): ReturnType> | undefined => { +): Promise | void | undefined> => { const [{ value, path }] = data as [{ value: AlertAction[]; path: string }]; - const errors = value.reduce((acc, actionItem) => { - const errorsArray = validateSingleAction(actionItem, actionTypeRegistry); + const errors = []; + for (const actionItem of value) { + const errorsArray = await validateSingleAction(actionItem, actionTypeRegistry); if (errorsArray.length) { const actionTypeName = getActionTypeName(actionItem.actionTypeId); const errorsListItems = errorsArray.map((error) => `* ${error}\n`); - return [...acc, `\n**${actionTypeName}:**\n${errorsListItems.join('')}`]; + errors.push(`\n**${actionTypeName}:**\n${errorsListItems.join('')}`); } - - return acc; - }, [] as string[]); + } if (errors.length) { return { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.test.ts b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.test.ts index 3d7299c1673b1..7c4ea71c983c8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.test.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.test.ts @@ -61,11 +61,11 @@ describe('stepRuleActions utils', () => { actionTypeRegistry.get.mockReturnValue(actionMock); }); - it('should validate action params', () => { + it('should validate action params', async () => { validateParamsMock.mockReturnValue({ errors: [] }); expect( - validateActionParams( + await validateActionParams( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', @@ -79,13 +79,13 @@ describe('stepRuleActions utils', () => { ).toHaveLength(0); }); - it('should validate incorrect action params', () => { + it('should validate incorrect action params', async () => { validateParamsMock.mockReturnValue({ errors: ['Message is required'], }); expect( - validateActionParams( + await validateActionParams( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', @@ -97,7 +97,7 @@ describe('stepRuleActions utils', () => { ).toHaveLength(1); }); - it('should validate incorrect action params and filter error objects', () => { + it('should validate incorrect action params and filter error objects', async () => { validateParamsMock.mockReturnValue({ errors: [ { @@ -107,7 +107,7 @@ describe('stepRuleActions utils', () => { }); expect( - validateActionParams( + await validateActionParams( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', @@ -119,13 +119,13 @@ describe('stepRuleActions utils', () => { ).toHaveLength(0); }); - it('should validate incorrect action params and filter duplicated errors', () => { + it('should validate incorrect action params and filter duplicated errors', async () => { validateParamsMock.mockReturnValue({ errors: ['Message is required', 'Message is required', 'Message is required'], }); expect( - validateActionParams( + await validateActionParams( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.ts b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.ts index d241d4283fc77..22363df5164a6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.ts @@ -41,11 +41,11 @@ export const validateMustache = (params: AlertAction['params']) => { return errors; }; -export const validateActionParams = ( +export const validateActionParams = async ( actionItem: AlertAction, actionTypeRegistry: ActionTypeRegistryContract -): string[] => { - const actionErrors = actionTypeRegistry +): Promise => { + const actionErrors = await actionTypeRegistry .get(actionItem.actionTypeId) ?.validateParams(actionItem.params); diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index 7d736218af2d9..cd83be0138faf 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -888,10 +888,10 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send to Server log', } ), - validateConnector: (): ValidationResult => { + validateConnector: (): Promise => { return { errors: {} }; }, - validateParams: (actionParams: ServerLogActionParams): ValidationResult => { + validateParams: (actionParams: ServerLogActionParams): Promise => { // validation of action params implementation }, actionConnectorFields: null, @@ -929,10 +929,10 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send to email', } ), - validateConnector: (action: EmailActionConnector): ValidationResult => { + validateConnector: (action: EmailActionConnector): Promise => { // validation of connector properties implementation }, - validateParams: (actionParams: EmailActionParams): ValidationResult => { + validateParams: (actionParams: EmailActionParams): Promise => { // validation of action params implementation }, actionConnectorFields: EmailActionConnectorFields, @@ -967,10 +967,10 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send to Slack', } ), - validateConnector: (action: SlackActionConnector): ValidationResult => { + validateConnector: (action: SlackActionConnector): Promise => { // validation of connector properties implementation }, - validateParams: (actionParams: SlackActionParams): ValidationResult => { + validateParams: (actionParams: SlackActionParams): Promise => { // validation of action params implementation }, actionConnectorFields: SlackActionFields, @@ -1000,12 +1000,12 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Index data into Elasticsearch.', } ), - validateConnector: (): ValidationResult => { + validateConnector: (): Promise => { return { errors: {} }; }, actionConnectorFields: IndexActionConnectorFields, actionParamsFields: IndexParamsFields, - validateParams: (): ValidationResult => { + validateParams: (): Promise => { return { errors: {} }; }, }; @@ -1046,10 +1046,10 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send a request to a web service.', } ), - validateConnector: (action: WebhookActionConnector): ValidationResult => { + validateConnector: (action: WebhookActionConnector): Promise => { // validation of connector properties implementation }, - validateParams: (actionParams: WebhookActionParams): ValidationResult => { + validateParams: (actionParams: WebhookActionParams): Promise => { // validation of action params implementation }, actionConnectorFields: WebhookActionConnectorFields, @@ -1086,10 +1086,10 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send to PagerDuty', } ), - validateConnector: (action: PagerDutyActionConnector): ValidationResult => { + validateConnector: (action: PagerDutyActionConnector): Promise => { // validation of connector properties implementation }, - validateParams: (actionParams: PagerDutyActionParams): ValidationResult => { + validateParams: (actionParams: PagerDutyActionParams): Promise => { // validation of action params implementation }, actionConnectorFields: PagerDutyActionConnectorFields, @@ -1113,8 +1113,8 @@ Each action type should be defined as an `ActionTypeModel` object with the follo iconClass: IconType; selectMessage: string; actionTypeTitle?: string; - validateConnector: (connector: any) => ValidationResult; - validateParams: (actionParams: any) => ValidationResult; + validateConnector: (connector: any) => Promise; + validateParams: (actionParams: any) => Promise; actionConnectorFields: React.FunctionComponent | null; actionParamsFields: React.LazyExoticComponent>>; ``` @@ -1186,7 +1186,7 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Example Action', } ), - validateConnector: (action: ExampleActionConnector): ValidationResult => { + validateConnector: (action: ExampleActionConnector): Promise => { const validationResult = { errors: {} }; const errors = { someConnectorField: new Array(), @@ -1204,7 +1204,7 @@ export function getActionType(): ActionTypeModel { } return validationResult; }, - validateParams: (actionParams: ExampleActionParams): ValidationResult => { + validateParams: (actionParams: ExampleActionParams): Promise => { const validationResult = { errors: {} }; const errors = { message: new Array(), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx index bebddba0c1110..4d669ab4c76a1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -49,7 +49,7 @@ describe('connector validation', () => { }, } as EmailActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { from: [], @@ -66,7 +66,7 @@ describe('connector validation', () => { }); }); - test('connector validation succeeds when connector config is valid with empty user/password', () => { + test('connector validation succeeds when connector config is valid with empty user/password', async () => { const actionConnector = { secrets: { user: null, @@ -85,7 +85,7 @@ describe('connector validation', () => { }, } as EmailActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { from: [], @@ -101,7 +101,7 @@ describe('connector validation', () => { }, }); }); - test('connector validation fails when connector config is not valid', () => { + test('connector validation fails when connector config is not valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -116,7 +116,7 @@ describe('connector validation', () => { }, } as EmailActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { from: [], @@ -132,7 +132,7 @@ describe('connector validation', () => { }, }); }); - test('connector validation fails when user specified but not password', () => { + test('connector validation fails when user specified but not password', async () => { const actionConnector = { secrets: { user: 'user', @@ -151,7 +151,7 @@ describe('connector validation', () => { }, } as EmailActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { from: [], @@ -167,7 +167,7 @@ describe('connector validation', () => { }, }); }); - test('connector validation fails when password specified but not user', () => { + test('connector validation fails when password specified but not user', async () => { const actionConnector = { secrets: { user: null, @@ -186,7 +186,7 @@ describe('connector validation', () => { }, } as EmailActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { from: [], @@ -205,7 +205,7 @@ describe('connector validation', () => { }); describe('action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { to: [], cc: ['test1@test.com'], @@ -213,7 +213,7 @@ describe('action params validation', () => { subject: 'test', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { to: [], cc: [], @@ -224,13 +224,13 @@ describe('action params validation', () => { }); }); - test('action params validation fails when action params is not valid', () => { + test('action params validation fails when action params is not valid', async () => { const actionParams = { to: ['test@test.com'], subject: 'test', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { to: [], cc: [], diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx index 81eadda4fc278..5e23754621430 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx @@ -31,9 +31,12 @@ export function getActionType(): ActionTypeModel, EmailSecrets> => { + ): Promise< + ConnectorValidationResult, EmailSecrets> + > => { + const translations = await import('./translations'); const configErrors = { from: new Array(), port: new Array(), @@ -49,74 +52,25 @@ export function getActionType(): ActionTypeModel => { + ): Promise> => { + const translations = await import('./translations'); const errors = { to: new Array(), cc: new Array(), @@ -146,35 +101,16 @@ export function getActionType(): ActionTypeModel 0; + const isHostInvalid: boolean = + host !== undefined && errors.host !== undefined && errors.host.length > 0; + const isPortInvalid: boolean = + port !== undefined && errors.port !== undefined && errors.port.length > 0; + + const isPasswordInvalid: boolean = + password !== undefined && errors.password !== undefined && errors.password.length > 0; + const isUserInvalid: boolean = + user !== undefined && errors.user !== undefined && errors.user.length > 0; return ( <> @@ -46,7 +57,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< id="from" fullWidth error={errors.from} - isInvalid={errors.from.length > 0 && from !== undefined} + isInvalid={isFromInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.fromTextFieldLabel', { @@ -65,7 +76,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< 0 && from !== undefined} + isInvalid={isFromInvalid} name="from" value={from || ''} data-test-subj="emailFromInput" @@ -87,7 +98,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< id="emailHost" fullWidth error={errors.host} - isInvalid={errors.host.length > 0 && host !== undefined} + isInvalid={isHostInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.hostTextFieldLabel', { @@ -98,7 +109,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< 0 && host !== undefined} + isInvalid={isHostInvalid} name="host" value={host || ''} data-test-subj="emailHostInput" @@ -121,7 +132,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< fullWidth placeholder="587" error={errors.port} - isInvalid={errors.port.length > 0 && port !== undefined} + isInvalid={isPortInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.portTextFieldLabel', { @@ -131,7 +142,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< > 0 && port !== undefined} + isInvalid={isPortInvalid} fullWidth readOnly={readOnly} name="port" @@ -221,7 +232,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< id="emailUser" fullWidth error={errors.user} - isInvalid={errors.user.length > 0 && user !== undefined} + isInvalid={isUserInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel', { @@ -231,7 +242,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< > 0 && user !== undefined} + isInvalid={isUserInvalid} name="user" readOnly={readOnly} value={user || ''} @@ -252,7 +263,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< id="emailPassword" fullWidth error={errors.password} - isInvalid={errors.password.length > 0 && password !== undefined} + isInvalid={isPasswordInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.passwordFieldLabel', { @@ -263,7 +274,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< 0 && password !== undefined} + isInvalid={isPasswordInvalid} name="password" value={password || ''} data-test-subj="emailPasswordInput" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx index e2d6237af85da..5d19a1958c1c6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx @@ -44,13 +44,18 @@ export const EmailParamsFields = ({ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultMessage]); - + const isToInvalid: boolean = to !== undefined && errors.to !== undefined && errors.to.length > 0; + const isSubjectInvalid: boolean = + subject !== undefined && errors.subject !== undefined && errors.subject.length > 0; + const isCCInvalid: boolean = errors.cc !== undefined && errors.cc.length > 0 && cc !== undefined; + const isBCCInvalid: boolean = + errors.bcc !== undefined && errors.bcc.length > 0 && bcc !== undefined; return ( <> 0 && to !== undefined} + isInvalid={isToInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientTextFieldLabel', { @@ -82,7 +87,7 @@ export const EmailParamsFields = ({ > 0 && to !== undefined} + isInvalid={isToInvalid} fullWidth data-test-subj="toEmailAddressInput" selectedOptions={toOptions} @@ -112,7 +117,7 @@ export const EmailParamsFields = ({ 0 && cc !== undefined} + isInvalid={isCCInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientCopyTextFieldLabel', { @@ -122,7 +127,7 @@ export const EmailParamsFields = ({ > 0 && cc !== undefined} + isInvalid={isCCInvalid} fullWidth data-test-subj="ccEmailAddressInput" selectedOptions={ccOptions} @@ -153,7 +158,7 @@ export const EmailParamsFields = ({ 0 && bcc !== undefined} + isInvalid={isBCCInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientBccTextFieldLabel', { @@ -163,7 +168,7 @@ export const EmailParamsFields = ({ > 0 && bcc !== undefined} + isInvalid={isBCCInvalid} fullWidth data-test-subj="bccEmailAddressInput" selectedOptions={bccOptions} @@ -193,7 +198,7 @@ export const EmailParamsFields = ({ 0 && subject !== undefined} + isInvalid={isSubjectInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.subjectTextFieldLabel', { @@ -207,7 +212,7 @@ export const EmailParamsFields = ({ messageVariables={messageVariables} paramsProperty={'subject'} inputTargetValue={subject} - errors={errors.subject as string[]} + errors={(errors.subject ?? []) as string[]} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts new file mode 100644 index 0000000000000..5da9145ecec0b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SENDER_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredFromText', + { + defaultMessage: 'Sender is required.', + } +); + +export const SENDER_NOT_VALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.formatFromText', + { + defaultMessage: 'Sender is not a valid email address.', + } +); + +export const PORT_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPortText', + { + defaultMessage: 'Port is required.', + } +); + +export const HOST_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredHostText', + { + defaultMessage: 'Host is required.', + } +); + +export const USERNAME_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredAuthUserNameText', + { + defaultMessage: 'Username is required.', + } +); + +export const PASSWORD_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredAuthPasswordText', + { + defaultMessage: 'Password is required.', + } +); + +export const PASSWORD_REQUIRED_FOR_USER_USED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText', + { + defaultMessage: 'Password is required when username is used.', + } +); + +export const TO_CC_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredEntryText', + { + defaultMessage: 'No To, Cc, or Bcc entry. At least one entry is required.', + } +); + +export const MESSAGE_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredMessageText', + { + defaultMessage: 'Message is required.', + } +); + +export const SUBJECT_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSubjectText', + { + defaultMessage: 'Subject is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx index 9757653043175..f43d883be7add 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('index connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -43,7 +43,7 @@ describe('index connector validation', () => { }, } as EsIndexActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { index: [], @@ -57,7 +57,7 @@ describe('index connector validation', () => { }); describe('index connector validation with minimal config', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -68,7 +68,7 @@ describe('index connector validation with minimal config', () => { }, } as EsIndexActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { index: [], @@ -82,9 +82,9 @@ describe('index connector validation with minimal config', () => { }); describe('action params validation', () => { - test('action params validation succeeds when action params are valid', () => { + test('action params validation succeeds when action params are valid', async () => { expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{ test: 1234 }], }) ).toEqual({ @@ -95,7 +95,7 @@ describe('action params validation', () => { }); expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{ test: 1234 }], indexOverride: 'kibana-alert-history-anything', }) @@ -107,8 +107,8 @@ describe('action params validation', () => { }); }); - test('action params validation fails when action params are invalid', () => { - expect(actionTypeModel.validateParams({})).toEqual({ + test('action params validation fails when action params are invalid', async () => { + expect(await actionTypeModel.validateParams({})).toEqual({ errors: { documents: ['Document is required and should be a valid JSON object.'], indexOverride: [], @@ -116,7 +116,7 @@ describe('action params validation', () => { }); expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{}], }) ).toEqual({ @@ -127,7 +127,7 @@ describe('action params validation', () => { }); expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{}], indexOverride: 'kibana-alert-history-', }) @@ -139,7 +139,7 @@ describe('action params validation', () => { }); expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{}], indexOverride: 'this.is-a_string', }) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx index f4b8284c8cfa6..80d38bda22ab3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx @@ -31,44 +31,32 @@ export function getActionType(): ActionTypeModel, unknown> => { + ): Promise, unknown>> => { + const translations = await import('./translations'); const configErrors = { index: new Array(), }; const validationResult = { config: { errors: configErrors }, secrets: { errors: {} } }; if (!action.config.index) { - configErrors.index.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.error.requiredIndexText', - { - defaultMessage: 'Index is required.', - } - ) - ); + configErrors.index.push(translations.INDEX_REQUIRED); } return validationResult; }, actionConnectorFields: lazy(() => import('./es_index_connector')), actionParamsFields: lazy(() => import('./es_index_params')), - validateParams: ( + validateParams: async ( actionParams: IndexActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { documents: new Array(), indexOverride: new Array(), }; const validationResult = { errors }; if (!actionParams.documents?.length || Object.keys(actionParams.documents[0]).length === 0) { - errors.documents.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredDocumentJson', - { - defaultMessage: 'Document is required and should be a valid JSON object.', - } - ) - ); + errors.documents.push(translations.DOCUMENT_NOT_VALID); } if (actionParams.indexOverride) { if (!actionParams.indexOverride.startsWith(ALERT_HISTORY_PREFIX)) { @@ -85,14 +73,7 @@ export function getActionType(): ActionTypeModel 0 && index !== undefined; return ( <> @@ -95,7 +97,7 @@ const IndexActionConnectorFields: React.FunctionComponent< defaultMessage="Index" /> } - isInvalid={errors.index.length > 0 && index !== undefined} + isInvalid={isIndexInvalid} error={errors.index} helpText={ <> @@ -118,7 +120,7 @@ const IndexActionConnectorFields: React.FunctionComponent< singleSelection={{ asPlainText: true }} async isLoading={isIndiciesLoading} - isInvalid={errors.index.length > 0 && index !== undefined} + isInvalid={isIndexInvalid} noSuggestions={!indexOptions.length} options={indexOptions} data-test-subj="connectorIndexesComboBox" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx index 6973cdcc7a088..b5985cf724e09 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx @@ -117,7 +117,11 @@ export const IndexParamsFields = ({ 0} + isInvalid={ + errors.indexOverride !== undefined && + (errors.indexOverride as string[]) && + errors.indexOverride.length > 0 + } label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.preconfiguredIndex', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/translations.ts new file mode 100644 index 0000000000000..b7dd6ac749909 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/translations.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const INDEX_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.error.requiredIndexText', + { + defaultMessage: 'Index is required.', + } +); + +export const DOCUMENT_NOT_VALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredDocumentJson', + { + defaultMessage: 'Document is required and should be a valid JSON object.', + } +); + +export const HISTORY_NOT_VALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.badIndexOverrideSuffix', + { + defaultMessage: 'Alert history index must contain valid suffix.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx index ea1bcf82c314c..857582fa7cdaf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx @@ -29,7 +29,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('jira connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { email: 'email', @@ -45,7 +45,7 @@ describe('jira connector validation', () => { }, } as JiraActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: [], @@ -61,7 +61,7 @@ describe('jira connector validation', () => { }); }); - test('connector validation fails when connector config is not valid', () => { + test('connector validation fails when connector config is not valid', async () => { const actionConnector = ({ secrets: { email: 'user', @@ -72,7 +72,7 @@ describe('jira connector validation', () => { config: {}, } as unknown) as JiraActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: ['URL is required.'], @@ -90,22 +90,22 @@ describe('jira connector validation', () => { }); describe('jira action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { subActionParams: { incident: { summary: 'some title {{test}}' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.summary': [], 'subActionParams.incident.labels': [] }, }); }); - test('params validation fails when body is not valid', () => { + test('params validation fails when body is not valid', async () => { const actionParams = { subActionParams: { incident: { summary: '' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.summary': ['Summary is required.'], 'subActionParams.incident.labels': [], @@ -113,7 +113,7 @@ describe('jira action params validation', () => { }); }); - test('params validation fails when labels contain spaces', () => { + test('params validation fails when labels contain spaces', async () => { const actionParams = { subActionParams: { incident: { summary: 'some title', labels: ['label with spaces'] }, @@ -121,7 +121,7 @@ describe('jira action params validation', () => { }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.summary': [], 'subActionParams.incident.labels': ['Labels cannot contain spaces.'], diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx index ff7fd026f8e31..8e3424a16c295 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx @@ -6,18 +6,19 @@ */ import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; import { GenericValidationResult, ActionTypeModel, ConnectorValidationResult, } from '../../../../types'; import { JiraActionConnector, JiraConfig, JiraSecrets, JiraActionParams } from './types'; -import * as i18n from './translations'; import { isValidUrl } from '../../../lib/value_validators'; -const validateConnector = ( +const validateConnector = async ( action: JiraActionConnector -): ConnectorValidationResult => { +): Promise> => { + const translations = await import('./translations'); const configErrors = { apiUrl: new Array(), projectKey: new Array(), @@ -33,41 +34,58 @@ const validateConnector = ( }; if (!action.config.apiUrl) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRED]; } if (action.config.apiUrl) { if (!isValidUrl(action.config.apiUrl)) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_INVALID]; } else if (!isValidUrl(action.config.apiUrl, 'https:')) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRE_HTTPS]; } } if (!action.config.projectKey) { - configErrors.projectKey = [...configErrors.projectKey, i18n.JIRA_PROJECT_KEY_REQUIRED]; + configErrors.projectKey = [...configErrors.projectKey, translations.JIRA_PROJECT_KEY_REQUIRED]; } if (!action.secrets.email) { - secretsErrors.email = [...secretsErrors.email, i18n.JIRA_EMAIL_REQUIRED]; + secretsErrors.email = [...secretsErrors.email, translations.JIRA_EMAIL_REQUIRED]; } if (!action.secrets.apiToken) { - secretsErrors.apiToken = [...secretsErrors.apiToken, i18n.JIRA_API_TOKEN_REQUIRED]; + secretsErrors.apiToken = [...secretsErrors.apiToken, translations.JIRA_API_TOKEN_REQUIRED]; } return validationResult; }; +export const JIRA_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.selectMessageText', + { + defaultMessage: 'Create an incident in Jira.', + } +); + +export const JIRA_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.actionTypeTitle', + { + defaultMessage: 'Jira', + } +); + export function getActionType(): ActionTypeModel { return { id: '.jira', iconClass: lazy(() => import('./logo')), - selectMessage: i18n.JIRA_DESC, - actionTypeTitle: i18n.JIRA_TITLE, + selectMessage: JIRA_DESC, + actionTypeTitle: JIRA_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./jira_connectors')), - validateParams: (actionParams: JiraActionParams): GenericValidationResult => { + validateParams: async ( + actionParams: JiraActionParams + ): Promise> => { + const translations = await import('./translations'); const errors = { 'subActionParams.incident.summary': new Array(), 'subActionParams.incident.labels': new Array(), @@ -80,13 +98,13 @@ export function getActionType(): ActionTypeModel label.match(/\s/g))) - errors['subActionParams.incident.labels'].push(i18n.LABELS_WHITE_SPACES); + errors['subActionParams.incident.labels'].push(translations.LABELS_WHITE_SPACES); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx index f2753310d73ae..7aec0a405d0d5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx @@ -32,13 +32,17 @@ const JiraConnectorFields: React.FC { const { apiUrl, projectKey } = action.config; - const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl !== undefined; + const isApiUrlInvalid: boolean = + apiUrl !== undefined && errors.apiUrl !== undefined && errors.apiUrl.length > 0; const { email, apiToken } = action.secrets; - const isProjectKeyInvalid: boolean = errors.projectKey.length > 0 && projectKey !== undefined; - const isEmailInvalid: boolean = errors.email.length > 0 && email !== undefined; - const isApiTokenInvalid: boolean = errors.apiToken.length > 0 && apiToken !== undefined; + const isProjectKeyInvalid: boolean = + projectKey !== undefined && errors.projectKey !== undefined && errors.projectKey.length > 0; + const isEmailInvalid: boolean = + email !== undefined && errors.email !== undefined && errors.email.length > 0; + const isApiTokenInvalid: boolean = + apiToken !== undefined && errors.apiToken !== undefined && errors.apiToken.length > 0; const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx index 11123a81440bb..5897de46f94df 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx @@ -186,6 +186,7 @@ const JiraParamsFields: React.FunctionComponent 0 && incident.labels !== undefined; @@ -277,6 +278,7 @@ const JiraParamsFields: React.FunctionComponent 0 && incident.summary !== undefined } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts index 4577e55260d9d..5904eb05c31b6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts @@ -7,20 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const JIRA_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.jira.selectMessageText', - { - defaultMessage: 'Create an incident in Jira.', - } -); - -export const JIRA_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.jira.actionTypeTitle', - { - defaultMessage: 'Jira', - } -); - export const API_URL_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.jira.apiUrlTextFieldLabel', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx index eae8690dbdd98..d96ca76aea3be 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('pagerduty connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { routingKey: 'test', @@ -43,7 +43,7 @@ describe('pagerduty connector validation', () => { }, } as PagerDutyActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ secrets: { errors: { routingKey: [], @@ -53,7 +53,7 @@ describe('pagerduty connector validation', () => { delete actionConnector.config.apiUrl; actionConnector.secrets.routingKey = 'test1'; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ secrets: { errors: { routingKey: [], @@ -62,7 +62,7 @@ describe('pagerduty connector validation', () => { }); }); - test('connector validation fails when connector config is not valid', () => { + test('connector validation fails when connector config is not valid', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -73,7 +73,7 @@ describe('pagerduty connector validation', () => { }, } as PagerDutyActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ secrets: { errors: { routingKey: ['An integration key / routing key is required.'], @@ -84,7 +84,7 @@ describe('pagerduty connector validation', () => { }); describe('pagerduty action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { eventAction: 'trigger', dedupKey: 'test', @@ -97,7 +97,7 @@ describe('pagerduty action params validation', () => { class: 'test class', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { dedupKey: [], summary: [], diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx index 310c5cae24566..80dd360d620b2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx @@ -42,9 +42,10 @@ export function getActionType(): ActionTypeModel< defaultMessage: 'Send to PagerDuty', } ), - validateConnector: ( + validateConnector: async ( action: PagerDutyActionConnector - ): ConnectorValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const secretsErrors = { routingKey: new Array(), }; @@ -53,22 +54,16 @@ export function getActionType(): ActionTypeModel< }; if (!action.secrets.routingKey) { - secretsErrors.routingKey.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText', - { - defaultMessage: 'An integration key / routing key is required.', - } - ) - ); + secretsErrors.routingKey.push(translations.INTEGRATION_KEY_REQUIRED); } return validationResult; }, - validateParams: ( + validateParams: async ( actionParams: PagerDutyActionParams - ): GenericValidationResult< - Pick + ): Promise< + GenericValidationResult> > => { + const translations = await import('./translations'); const errors = { summary: new Array(), timestamp: new Array(), @@ -79,27 +74,13 @@ export function getActionType(): ActionTypeModel< !actionParams.dedupKey?.length && (actionParams.eventAction === 'resolve' || actionParams.eventAction === 'acknowledge') ) { - errors.dedupKey.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredDedupKeyText', - { - defaultMessage: 'DedupKey is required when resolving or acknowledging an incident.', - } - ) - ); + errors.dedupKey.push(translations.DEDUP_KEY_REQUIRED); } if ( actionParams.eventAction === EventActionOptions.TRIGGER && !actionParams.summary?.length ) { - errors.summary.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredSummaryText', - { - defaultMessage: 'Summary is required.', - } - ) - ); + errors.summary.push(translations.SUMMARY_REQUIRED); } if (actionParams.timestamp && !hasMustacheTokens(actionParams.timestamp)) { if (isNaN(Date.parse(actionParams.timestamp))) { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx index 7e9a5770c2158..3ac7832d0462e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx @@ -20,6 +20,9 @@ const PagerDutyActionConnectorFields: React.FunctionComponent< const { docLinks } = useKibana().services; const { apiUrl } = action.config; const { routingKey } = action.secrets; + const isRoutingKeyInvalid: boolean = + routingKey !== undefined && errors.routingKey !== undefined && errors.routingKey.length > 0; + return ( <> } error={errors.routingKey} - isInvalid={errors.routingKey.length > 0 && routingKey !== undefined} + isInvalid={isRoutingKeyInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.routingKeyTextFieldLabel', { @@ -80,7 +83,7 @@ const PagerDutyActionConnectorFields: React.FunctionComponent< )} 0 && routingKey !== undefined} + isInvalid={isRoutingKeyInvalid} name="routingKey" readOnly={readOnly} value={routingKey || ''} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx index 4961a27fd0ac1..8605832b92ea5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx @@ -101,6 +101,12 @@ const PagerDutyParamsFields: React.FunctionComponent 0; + const isSummaryInvalid: boolean = + errors.summary !== undefined && errors.summary.length > 0 && summary !== undefined; + const isTimestampInvalid: boolean = + errors.timestamp !== undefined && errors.timestamp.length > 0 && timestamp !== undefined; + return ( <> @@ -132,7 +138,7 @@ const PagerDutyParamsFields: React.FunctionComponent 0} + isInvalid={isDedupKeyInvalid} label={ isDedupeKeyRequired ? i18n.translate( @@ -166,7 +172,7 @@ const PagerDutyParamsFields: React.FunctionComponent 0 && summary !== undefined} + isInvalid={isSummaryInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.summaryFieldLabel', { @@ -180,7 +186,7 @@ const PagerDutyParamsFields: React.FunctionComponent @@ -211,7 +217,7 @@ const PagerDutyParamsFields: React.FunctionComponent 0 && timestamp !== undefined} + isInvalid={isTimestampInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.timestampTextFieldLabel', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/translations.ts new file mode 100644 index 0000000000000..a907b19a1d733 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/translations.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SUMMARY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredSummaryText', + { + defaultMessage: 'Summary is required.', + } +); + +export const DEDUP_KEY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredDedupKeyText', + { + defaultMessage: 'DedupKey is required when resolving or acknowledging an incident.', + } +); + +export const INTEGRATION_KEY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText', + { + defaultMessage: 'An integration key / routing key is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx index 892ab97b8627f..93fb419f509bc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx @@ -29,7 +29,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('resilient connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { apiKeyId: 'email', @@ -45,7 +45,7 @@ describe('resilient connector validation', () => { }, } as ResilientActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: [], @@ -61,7 +61,7 @@ describe('resilient connector validation', () => { }); }); - test('connector validation fails when connector config is not valid', () => { + test('connector validation fails when connector config is not valid', async () => { const actionConnector = ({ secrets: { apiKeyId: 'user', @@ -72,7 +72,7 @@ describe('resilient connector validation', () => { config: {}, } as unknown) as ResilientActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: ['URL is required.'], @@ -90,22 +90,22 @@ describe('resilient connector validation', () => { }); describe('resilient action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { subActionParams: { incident: { name: 'some title {{test}}' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.name': [] }, }); }); - test('params validation fails when body is not valid', () => { + test('params validation fails when body is not valid', async () => { const actionParams = { subActionParams: { incident: { name: '' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.name': ['Name is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx index e7074b7506e7a..f20204af17697 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx @@ -6,6 +6,7 @@ */ import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; import { GenericValidationResult, ActionTypeModel, @@ -17,12 +18,12 @@ import { ResilientSecrets, ResilientActionParams, } from './types'; -import * as i18n from './translations'; import { isValidUrl } from '../../../lib/value_validators'; -const validateConnector = ( +const validateConnector = async ( action: ResilientActionConnector -): ConnectorValidationResult => { +): Promise> => { + const translations = await import('./translations'); const configErrors = { apiUrl: new Array(), orgId: new Array(), @@ -38,32 +39,49 @@ const validateConnector = ( }; if (!action.config.apiUrl) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRED]; } if (action.config.apiUrl) { if (!isValidUrl(action.config.apiUrl)) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_INVALID]; } else if (!isValidUrl(action.config.apiUrl, 'https:')) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRE_HTTPS]; } } if (!action.config.orgId) { - configErrors.orgId = [...configErrors.orgId, i18n.ORG_ID_REQUIRED]; + configErrors.orgId = [...configErrors.orgId, translations.ORG_ID_REQUIRED]; } if (!action.secrets.apiKeyId) { - secretsErrors.apiKeyId = [...secretsErrors.apiKeyId, i18n.API_KEY_ID_REQUIRED]; + secretsErrors.apiKeyId = [...secretsErrors.apiKeyId, translations.API_KEY_ID_REQUIRED]; } if (!action.secrets.apiKeySecret) { - secretsErrors.apiKeySecret = [...secretsErrors.apiKeySecret, i18n.API_KEY_SECRET_REQUIRED]; + secretsErrors.apiKeySecret = [ + ...secretsErrors.apiKeySecret, + translations.API_KEY_SECRET_REQUIRED, + ]; } return validationResult; }; +export const DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.selectMessageText', + { + defaultMessage: 'Create an incident in IBM Resilient.', + } +); + +export const TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.actionTypeTitle', + { + defaultMessage: 'Resilient', + } +); + export function getActionType(): ActionTypeModel< ResilientConfig, ResilientSecrets, @@ -72,11 +90,14 @@ export function getActionType(): ActionTypeModel< return { id: '.resilient', iconClass: lazy(() => import('./logo')), - selectMessage: i18n.DESC, - actionTypeTitle: i18n.TITLE, + selectMessage: DESC, + actionTypeTitle: TITLE, validateConnector, actionConnectorFields: lazy(() => import('./resilient_connectors')), - validateParams: (actionParams: ResilientActionParams): GenericValidationResult => { + validateParams: async ( + actionParams: ResilientActionParams + ): Promise> => { + const translations = await import('./translations'); const errors = { 'subActionParams.incident.name': new Array(), }; @@ -88,7 +109,7 @@ export function getActionType(): ActionTypeModel< actionParams.subActionParams.incident && !actionParams.subActionParams.incident.name?.length ) { - errors['subActionParams.incident.name'].push(i18n.NAME_REQUIRED); + errors['subActionParams.incident.name'].push(translations.NAME_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx index 6996062899c39..1270f19820f4c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx @@ -30,14 +30,19 @@ const ResilientConnectorFields: React.FC { const { apiUrl, orgId } = action.config; - const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl !== undefined; + const isApiUrlInvalid: boolean = + apiUrl !== undefined && errors.apiUrl !== undefined && errors.apiUrl.length > 0; const { apiKeyId, apiKeySecret } = action.secrets; - const isOrgIdInvalid: boolean = errors.orgId.length > 0 && orgId !== undefined; - const isApiKeyInvalid: boolean = errors.apiKeyId.length > 0 && apiKeyId !== undefined; + const isOrgIdInvalid: boolean = + orgId !== undefined && errors.orgId !== undefined && errors.orgId.length > 0; + const isApiKeyInvalid: boolean = + apiKeyId !== undefined && errors.apiKeyId !== undefined && errors.apiKeyId.length > 0; const isApiKeySecretInvalid: boolean = - errors.apiKeySecret.length > 0 && apiKeySecret !== undefined; + apiKeySecret !== undefined && + errors.apiKeySecret !== undefined && + errors.apiKeySecret.length > 0; const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx index 4642226d40222..54a138a2bc7cf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx @@ -213,7 +213,9 @@ const ResilientParamsFields: React.FunctionComponent 0 && incident.name !== undefined + errors['subActionParams.incident.name'] !== undefined && + errors['subActionParams.incident.name'].length > 0 && + incident.name !== undefined } label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.nameFieldLabel', @@ -226,7 +228,7 @@ const ResilientParamsFields: React.FunctionComponent { }); describe('server-log connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector: UserConfiguredActionConnector<{}, {}> = { secrets: {}, id: 'test', @@ -39,7 +39,7 @@ describe('server-log connector validation', () => { isPreconfigured: false, }; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -51,23 +51,23 @@ describe('server-log connector validation', () => { }); describe('action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { message: 'test message', level: 'trace', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: [] }, }); }); - test('params validation fails when message is not valid', () => { + test('params validation fails when message is not valid', async () => { const actionParams = { message: '', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: ['Message is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx index 4550d2d65b9df..066c5c0a2f385 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx @@ -30,12 +30,12 @@ export function getActionType(): ActionTypeModel => { - return { config: { errors: {} }, secrets: { errors: {} } }; + validateConnector: (): Promise> => { + return Promise.resolve({ config: { errors: {} }, secrets: { errors: {} } }); }, validateParams: ( actionParams: ServerLogActionParams - ): GenericValidationResult> => { + ): Promise>> => { const errors = { message: new Array(), }; @@ -50,7 +50,7 @@ export function getActionType(): ActionTypeModel import('./server_log_params')), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx index 02ecab47ae49a..e25e8120b1650 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { describe('servicenow connector validation', () => { [SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => { - test(`${id}: connector validation succeeds when connector config is valid`, () => { + test(`${id}: connector validation succeeds when connector config is valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionConnector = { secrets: { @@ -46,7 +46,7 @@ describe('servicenow connector validation', () => { }, } as ServiceNowActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: [], @@ -61,7 +61,7 @@ describe('servicenow connector validation', () => { }); }); - test(`${id}: connector validation fails when connector config is not valid`, () => { + test(`${id}: connector validation fails when connector config is not valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionConnector = ({ secrets: { @@ -73,7 +73,7 @@ describe('servicenow connector validation', () => { config: {}, } as unknown) as ServiceNowActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: ['URL is required.'], @@ -92,24 +92,24 @@ describe('servicenow connector validation', () => { describe('servicenow action params validation', () => { [SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => { - test(`${id}: action params validation succeeds when action params is valid`, () => { + test(`${id}: action params validation succeeds when action params is valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionParams = { subActionParams: { incident: { short_description: 'some title {{test}}' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { ['subActionParams.incident.short_description']: [] }, }); }); - test(`${id}: params validation fails when body is not valid`, () => { + test(`${id}: params validation fails when body is not valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionParams = { subActionParams: { incident: { short_description: '' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { ['subActionParams.incident.short_description']: ['Short description is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx index a6cc116d3d7b4..24e2a87d42357 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx @@ -6,6 +6,7 @@ */ import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; import { GenericValidationResult, ActionTypeModel, @@ -18,12 +19,12 @@ import { ServiceNowITSMActionParams, ServiceNowSIRActionParams, } from './types'; -import * as i18n from './translations'; import { isValidUrl } from '../../../lib/value_validators'; -const validateConnector = ( +const validateConnector = async ( action: ServiceNowActionConnector -): ConnectorValidationResult => { +): Promise> => { + const translations = await import('./translations'); const configErrors = { apiUrl: new Array(), }; @@ -38,28 +39,56 @@ const validateConnector = ( }; if (!action.config.apiUrl) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRED]; } if (action.config.apiUrl) { if (!isValidUrl(action.config.apiUrl)) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_INVALID]; } else if (!isValidUrl(action.config.apiUrl, 'https:')) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRE_HTTPS]; } } if (!action.secrets.username) { - secretsErrors.username = [...secretsErrors.username, i18n.USERNAME_REQUIRED]; + secretsErrors.username = [...secretsErrors.username, translations.USERNAME_REQUIRED]; } if (!action.secrets.password) { - secretsErrors.password = [...secretsErrors.password, i18n.PASSWORD_REQUIRED]; + secretsErrors.password = [...secretsErrors.password, translations.PASSWORD_REQUIRED]; } return validationResult; }; +export const SERVICENOW_ITSM_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText', + { + defaultMessage: 'Create an incident in ServiceNow ITSM.', + } +); + +export const SERVICENOW_SIR_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.selectMessageText', + { + defaultMessage: 'Create an incident in ServiceNow SecOps.', + } +); + +export const SERVICENOW_ITSM_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle', + { + defaultMessage: 'ServiceNow ITSM', + } +); + +export const SERVICENOW_SIR_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle', + { + defaultMessage: 'ServiceNow SecOps', + } +); + export function getServiceNowITSMActionType(): ActionTypeModel< ServiceNowConfig, ServiceNowSecrets, @@ -68,13 +97,14 @@ export function getServiceNowITSMActionType(): ActionTypeModel< return { id: '.servicenow', iconClass: lazy(() => import('./logo')), - selectMessage: i18n.SERVICENOW_ITSM_DESC, - actionTypeTitle: i18n.SERVICENOW_ITSM_TITLE, + selectMessage: SERVICENOW_ITSM_DESC, + actionTypeTitle: SERVICENOW_ITSM_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./servicenow_connectors')), - validateParams: ( + validateParams: async ( actionParams: ServiceNowITSMActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { // eslint-disable-next-line @typescript-eslint/naming-convention 'subActionParams.incident.short_description': new Array(), @@ -87,7 +117,7 @@ export function getServiceNowITSMActionType(): ActionTypeModel< actionParams.subActionParams.incident && !actionParams.subActionParams.incident.short_description?.length ) { - errors['subActionParams.incident.short_description'].push(i18n.TITLE_REQUIRED); + errors['subActionParams.incident.short_description'].push(translations.TITLE_REQUIRED); } return validationResult; }, @@ -103,11 +133,14 @@ export function getServiceNowSIRActionType(): ActionTypeModel< return { id: '.servicenow-sir', iconClass: lazy(() => import('./logo')), - selectMessage: i18n.SERVICENOW_SIR_DESC, - actionTypeTitle: i18n.SERVICENOW_SIR_TITLE, + selectMessage: SERVICENOW_SIR_DESC, + actionTypeTitle: SERVICENOW_SIR_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./servicenow_connectors')), - validateParams: (actionParams: ServiceNowSIRActionParams): GenericValidationResult => { + validateParams: async ( + actionParams: ServiceNowSIRActionParams + ): Promise> => { + const translations = await import('./translations'); const errors = { // eslint-disable-next-line @typescript-eslint/naming-convention 'subActionParams.incident.short_description': new Array(), @@ -120,7 +153,7 @@ export function getServiceNowSIRActionType(): ActionTypeModel< actionParams.subActionParams.incident && !actionParams.subActionParams.incident.short_description?.length ) { - errors['subActionParams.incident.short_description'].push(i18n.TITLE_REQUIRED); + errors['subActionParams.incident.short_description'].push(translations.TITLE_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index e7b2c4bac5914..c9aafc58f3ede 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -32,12 +32,15 @@ const ServiceNowConnectorFields: React.FC< const { docLinks } = useKibana().services; const { apiUrl } = action.config; - const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl !== undefined; + const isApiUrlInvalid: boolean = + errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined; const { username, password } = action.secrets; - const isUsernameInvalid: boolean = errors.username.length > 0 && username !== undefined; - const isPasswordInvalid: boolean = errors.password.length > 0 && password !== undefined; + const isUsernameInvalid: boolean = + errors.username !== undefined && errors.username.length > 0 && username !== undefined; + const isPasswordInvalid: boolean = + errors.password !== undefined && errors.password.length > 0 && password !== undefined; const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx index dbd6fec3dad19..f0fc5ed42d24c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx @@ -240,6 +240,7 @@ const ServiceNowParamsFields: React.FunctionComponent< fullWidth error={errors['subActionParams.incident.short_description']} isInvalid={ + errors['subActionParams.incident.short_description'] !== undefined && errors['subActionParams.incident.short_description'].length > 0 && incident.short_description !== undefined } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx index be6756b1c1049..a991ee29c85f8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx @@ -151,6 +151,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< fullWidth error={errors['subActionParams.incident.short_description']} isInvalid={ + errors['subActionParams.incident.short_description'] !== undefined && errors['subActionParams.incident.short_description'].length > 0 && incident.short_description !== undefined } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index 288b6e629112d..ea646b896f5e9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -7,34 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const SERVICENOW_ITSM_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText', - { - defaultMessage: 'Create an incident in ServiceNow ITSM.', - } -); - -export const SERVICENOW_SIR_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.selectMessageText', - { - defaultMessage: 'Create an incident in ServiceNow SecOps.', - } -); - -export const SERVICENOW_ITSM_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle', - { - defaultMessage: 'ServiceNow ITSM', - } -); - -export const SERVICENOW_SIR_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle', - { - defaultMessage: 'ServiceNow SecOps', - } -); - export const API_URL_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlTextFieldLabel', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx index eabb63567ea86..dbdc123e0098f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('slack connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { webhookUrl: 'https:\\test', @@ -41,7 +41,7 @@ describe('slack connector validation', () => { config: {}, } as SlackActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -53,7 +53,7 @@ describe('slack connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - no webhook url', () => { + test('connector validation fails when connector config is not valid - no webhook url', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -62,7 +62,7 @@ describe('slack connector validation', () => { config: {}, } as SlackActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -74,7 +74,7 @@ describe('slack connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - invalid webhook protocol', () => { + test('connector validation fails when connector config is not valid - invalid webhook protocol', async () => { const actionConnector = { secrets: { webhookUrl: 'http:\\test', @@ -85,7 +85,7 @@ describe('slack connector validation', () => { config: {}, } as SlackActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -97,7 +97,7 @@ describe('slack connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - invalid webhook url', () => { + test('connector validation fails when connector config is not valid - invalid webhook url', async () => { const actionConnector = { secrets: { webhookUrl: 'h', @@ -108,7 +108,7 @@ describe('slack connector validation', () => { config: {}, } as SlackActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -122,22 +122,22 @@ describe('slack connector validation', () => { }); describe('slack action params validation', () => { - test('if action params validation succeeds when action params is valid', () => { + test('if action params validation succeeds when action params is valid', async () => { const actionParams = { message: 'message {test}', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: [] }, }); }); - test('params validation fails when message is not valid', () => { + test('params validation fails when message is not valid', async () => { const actionParams = { message: '', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: ['Message is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx index 30e60a6ac0156..d3df034a90bf2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx @@ -31,61 +31,35 @@ export function getActionType(): ActionTypeModel => { + ): Promise> => { + const translations = await import('./translations'); const secretsErrors = { webhookUrl: new Array(), }; const validationResult = { config: { errors: {} }, secrets: { errors: secretsErrors } }; if (!action.secrets.webhookUrl) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText', - { - defaultMessage: 'Webhook URL is required.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_REQUIRED); } else if (action.secrets.webhookUrl) { if (!isValidUrl(action.secrets.webhookUrl)) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.invalidWebhookUrlText', - { - defaultMessage: 'Webhook URL is invalid.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_INVALID); } else if (!isValidUrl(action.secrets.webhookUrl, 'https:')) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requireHttpsWebhookUrlText', - { - defaultMessage: 'Webhook URL must start with https://.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_HTTP_INVALID); } } return validationResult; }, - validateParams: ( + validateParams: async ( actionParams: SlackActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { message: new Array(), }; const validationResult = { errors }; if (!actionParams.message?.length) { - errors.message.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText', - { - defaultMessage: 'Message is required.', - } - ) - ); + errors.message.push(translations.MESSAGE_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx index ce6cda1294adc..e87b00dca9343 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx @@ -19,6 +19,8 @@ const SlackActionFields: React.FunctionComponent< > = ({ action, editActionSecrets, errors, readOnly }) => { const { docLinks } = useKibana().services; const { webhookUrl } = action.secrets; + const isWebhookUrlInvalid: boolean = + errors.webhookUrl !== undefined && errors.webhookUrl.length > 0 && webhookUrl !== undefined; return ( <> @@ -34,7 +36,7 @@ const SlackActionFields: React.FunctionComponent< } error={errors.webhookUrl} - isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined} + isInvalid={isWebhookUrlInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlTextFieldLabel', { @@ -54,7 +56,7 @@ const SlackActionFields: React.FunctionComponent< )} 0 && webhookUrl !== undefined} + isInvalid={isWebhookUrlInvalid} name="webhookUrl" readOnly={readOnly} value={webhookUrl || ''} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx index 3aa7fd8227496..59e10277cfe08 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx @@ -49,7 +49,7 @@ const SlackParamsFields: React.FunctionComponent ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/translations.ts new file mode 100644 index 0000000000000..bd1fd8ea194f6 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/translations.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 { i18n } from '@kbn/i18n'; + +export const WEBHOOK_URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText', + { + defaultMessage: 'Webhook URL is required.', + } +); + +export const WEBHOOK_URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.invalidWebhookUrlText', + { + defaultMessage: 'Webhook URL is invalid.', + } +); + +export const WEBHOOK_URL_HTTP_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requireHttpsWebhookUrlText', + { + defaultMessage: 'Webhook URL must start with https://.', + } +); + +export const MESSAGE_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText', + { + defaultMessage: 'Message is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx index 62be20a9bad90..641c46af6bfc1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx @@ -29,7 +29,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('teams connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { webhookUrl: 'https:\\test', @@ -40,7 +40,7 @@ describe('teams connector validation', () => { config: {}, } as TeamsActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -52,7 +52,7 @@ describe('teams connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - empty webhook url', () => { + test('connector validation fails when connector config is not valid - empty webhook url', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -61,7 +61,7 @@ describe('teams connector validation', () => { config: {}, } as TeamsActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -73,7 +73,7 @@ describe('teams connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - invalid webhook url', () => { + test('connector validation fails when connector config is not valid - invalid webhook url', async () => { const actionConnector = { secrets: { webhookUrl: 'h', @@ -84,7 +84,7 @@ describe('teams connector validation', () => { config: {}, } as TeamsActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -96,7 +96,7 @@ describe('teams connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - invalid webhook url protocol', () => { + test('connector validation fails when connector config is not valid - invalid webhook url protocol', async () => { const actionConnector = { secrets: { webhookUrl: 'http://insecure', @@ -107,7 +107,7 @@ describe('teams connector validation', () => { config: {}, } as TeamsActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -121,22 +121,22 @@ describe('teams connector validation', () => { }); describe('teams action params validation', () => { - test('if action params validation succeeds when action params is valid', () => { + test('if action params validation succeeds when action params is valid', async () => { const actionParams = { message: 'message {test}', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: [] }, }); }); - test('params validation fails when message is not valid', () => { + test('params validation fails when message is not valid', async () => { const actionParams = { message: '', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: ['Message is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx index e8c7be7311c1c..c48b4f950855d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx @@ -31,61 +31,35 @@ export function getActionType(): ActionTypeModel => { + ): Promise> => { + const translations = await import('./translations'); const secretsErrors = { webhookUrl: new Array(), }; const validationResult = { config: { errors: {} }, secrets: { errors: secretsErrors } }; if (!action.secrets.webhookUrl) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredWebhookUrlText', - { - defaultMessage: 'Webhook URL is required.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_REQUIRED); } else if (action.secrets.webhookUrl) { if (!isValidUrl(action.secrets.webhookUrl)) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.invalidWebhookUrlText', - { - defaultMessage: 'Webhook URL is invalid.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_INVALID); } else if (!isValidUrl(action.secrets.webhookUrl, 'https:')) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requireHttpsWebhookUrlText', - { - defaultMessage: 'Webhook URL must start with https://.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_HTTP_INVALID); } } return validationResult; }, - validateParams: ( + validateParams: async ( actionParams: TeamsActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { message: new Array(), }; const validationResult = { errors }; if (!actionParams.message?.length) { - errors.message.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredMessageText', - { - defaultMessage: 'Message is required.', - } - ) - ); + errors.message.push(translations.MESSAGE_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx index 454b938692225..8de1c68926f14 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx @@ -20,6 +20,9 @@ const TeamsActionFields: React.FunctionComponent< const { webhookUrl } = action.secrets; const { docLinks } = useKibana().services; + const isWebhookUrlInvalid: boolean = + errors.webhookUrl !== undefined && errors.webhookUrl.length > 0 && webhookUrl !== undefined; + return ( <> } error={errors.webhookUrl} - isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined} + isInvalid={isWebhookUrlInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.webhookUrlTextFieldLabel', { @@ -54,7 +57,7 @@ const TeamsActionFields: React.FunctionComponent< )} 0 && webhookUrl !== undefined} + isInvalid={isWebhookUrlInvalid} name="webhookUrl" readOnly={readOnly} value={webhookUrl || ''} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx index c0a20e214b4e1..0aea576c10b31 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx @@ -40,7 +40,7 @@ const TeamsParamsFields: React.FunctionComponent ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/translations.ts new file mode 100644 index 0000000000000..790a3b3bac32f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/translations.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 { i18n } from '@kbn/i18n'; + +export const WEBHOOK_URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredWebhookUrlText', + { + defaultMessage: 'Webhook URL is required.', + } +); + +export const WEBHOOK_URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.invalidWebhookUrlText', + { + defaultMessage: 'Webhook URL is invalid.', + } +); + +export const WEBHOOK_URL_HTTP_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requireHttpsWebhookUrlText', + { + defaultMessage: 'Webhook URL must start with https://.', + } +); + +export const MESSAGE_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredMessageText', + { + defaultMessage: 'Message is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/translations.ts new file mode 100644 index 0000000000000..3550121e81694 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/translations.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText', + { + defaultMessage: 'URL is required.', + } +); + +export const URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.invalidUrlTextField', + { + defaultMessage: 'URL is invalid.', + } +); + +export const METHOD_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText', + { + defaultMessage: 'Method is required.', + } +); + +export const USERNAME_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthUserNameText', + { + defaultMessage: 'Username is required.', + } +); + +export const PASSWORD_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthPasswordText', + { + defaultMessage: 'Password is required.', + } +); + +export const PASSWORD_REQUIRED_FOR_USER = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText', + { + defaultMessage: 'Password is required when username is used.', + } +); + +export const USERNAME_REQUIRED_FOR_PASSWORD = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredUserText', + { + defaultMessage: 'Username is required when password is used.', + } +); + +export const BODY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText', + { + defaultMessage: 'Body is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx index 8399316044f33..3e42e7965c5bd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('webhook connector validation', () => { - test('connector validation succeeds when hasAuth is true and connector config is valid', () => { + test('connector validation succeeds when hasAuth is true and connector config is valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -48,7 +48,7 @@ describe('webhook connector validation', () => { }, } as WebhookActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { url: [], @@ -64,7 +64,7 @@ describe('webhook connector validation', () => { }); }); - test('connector validation succeeds when hasAuth is false and connector config is valid', () => { + test('connector validation succeeds when hasAuth is false and connector config is valid', async () => { const actionConnector = { secrets: { user: '', @@ -82,7 +82,7 @@ describe('webhook connector validation', () => { }, } as WebhookActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { url: [], @@ -98,7 +98,7 @@ describe('webhook connector validation', () => { }); }); - test('connector validation fails when connector config is not valid', () => { + test('connector validation fails when connector config is not valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -112,7 +112,7 @@ describe('webhook connector validation', () => { }, } as WebhookActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { url: ['URL is required.'], @@ -128,7 +128,7 @@ describe('webhook connector validation', () => { }); }); - test('connector validation fails when url in config is not valid', () => { + test('connector validation fails when url in config is not valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -144,7 +144,7 @@ describe('webhook connector validation', () => { }, } as WebhookActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { url: ['URL is invalid.'], @@ -162,22 +162,22 @@ describe('webhook connector validation', () => { }); describe('webhook action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { body: 'message {test}', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { body: [] }, }); }); - test('params validation fails when body is not valid', () => { + test('params validation fails when body is not valid', async () => { const actionParams = { body: '', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { body: ['Body is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx index 3ba801b83c46c..a668f531a6d4c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx @@ -40,9 +40,12 @@ export function getActionType(): ActionTypeModel< defaultMessage: 'Webhook data', } ), - validateConnector: ( + validateConnector: async ( action: WebhookActionConnector - ): ConnectorValidationResult, WebhookSecrets> => { + ): Promise< + ConnectorValidationResult, WebhookSecrets> + > => { + const translations = await import('./translations'); const configErrors = { url: new Array(), method: new Array(), @@ -56,95 +59,39 @@ export function getActionType(): ActionTypeModel< secrets: { errors: secretsErrors }, }; if (!action.config.url) { - configErrors.url.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText', - { - defaultMessage: 'URL is required.', - } - ) - ); + configErrors.url.push(translations.URL_REQUIRED); } if (action.config.url && !isValidUrl(action.config.url)) { - configErrors.url = [ - ...configErrors.url, - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.invalidUrlTextField', - { - defaultMessage: 'URL is invalid.', - } - ), - ]; + configErrors.url = [...configErrors.url, translations.URL_INVALID]; } if (!action.config.method) { - configErrors.method.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText', - { - defaultMessage: 'Method is required.', - } - ) - ); + configErrors.method.push(translations.METHOD_REQUIRED); } if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) { - secretsErrors.user.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthUserNameText', - { - defaultMessage: 'Username is required.', - } - ) - ); + secretsErrors.user.push(translations.USERNAME_REQUIRED); } if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) { - secretsErrors.password.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthPasswordText', - { - defaultMessage: 'Password is required.', - } - ) - ); + secretsErrors.password.push(translations.PASSWORD_REQUIRED); } if (action.secrets.user && !action.secrets.password) { - secretsErrors.password.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText', - { - defaultMessage: 'Password is required when username is used.', - } - ) - ); + secretsErrors.password.push(translations.PASSWORD_REQUIRED_FOR_USER); } if (!action.secrets.user && action.secrets.password) { - secretsErrors.user.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredUserText', - { - defaultMessage: 'Username is required when password is used.', - } - ) - ); + secretsErrors.user.push(translations.USERNAME_REQUIRED_FOR_PASSWORD); } return validationResult; }, - validateParams: ( + validateParams: async ( actionParams: WebhookActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { body: new Array(), }; const validationResult = { errors }; validationResult.errors = errors; if (!actionParams.body?.length) { - errors.body.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText', - { - defaultMessage: 'Body is required.', - } - ) - ); + errors.body.push(translations.BODY_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx index d3231f52b4d7b..ba0e7016caa76 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx @@ -76,7 +76,11 @@ const WebhookActionConnectorFields: React.FunctionComponent< ) ); } - const hasHeaderErrors = headerErrors.keyHeader.length > 0 || headerErrors.valueHeader.length > 0; + const hasHeaderErrors: boolean = + (headerErrors.keyHeader !== undefined && + headerErrors.valueHeader !== undefined && + headerErrors.keyHeader.length > 0) || + headerErrors.valueHeader.length > 0; function addHeader() { if (headers && !!Object.keys(headers).find((key) => key === httpHeaderKey)) { @@ -219,6 +223,13 @@ const WebhookActionConnectorFields: React.FunctionComponent< ); }); + const isUrlInvalid: boolean = + errors.url !== undefined && errors.url.length > 0 && url !== undefined; + const isPasswordInvalid: boolean = + password !== undefined && errors.password !== undefined && errors.password.length > 0; + const isUserInvalid: boolean = + user !== undefined && errors.user !== undefined && errors.user.length > 0; + return ( <> @@ -248,7 +259,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< id="url" fullWidth error={errors.url} - isInvalid={errors.url.length > 0 && url !== undefined} + isInvalid={isUrlInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.urlTextFieldLabel', { @@ -258,7 +269,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< > 0 && url !== undefined} + isInvalid={isUrlInvalid} fullWidth readOnly={readOnly} value={url || ''} @@ -326,7 +337,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< id="webhookUser" fullWidth error={errors.user} - isInvalid={errors.user.length > 0 && user !== undefined} + isInvalid={isUserInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.userTextFieldLabel', { @@ -336,7 +347,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< > 0 && user !== undefined} + isInvalid={isUserInvalid} name="user" readOnly={readOnly} value={user || ''} @@ -357,7 +368,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< id="webhookPassword" fullWidth error={errors.password} - isInvalid={errors.password.length > 0 && password !== undefined} + isInvalid={isPasswordInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.passwordTextFieldLabel', { @@ -369,7 +380,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< fullWidth name="password" readOnly={readOnly} - isInvalid={errors.password.length > 0 && password !== undefined} + isInvalid={isPasswordInvalid} value={password || ''} data-test-subj="webhookPasswordInput" onChange={(e) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx index 964f538d54971..091ea1e305e35 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -23,12 +23,12 @@ describe('action_connector_form', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, }); actionTypeRegistry.get.mockReturnValue(actionType); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx index 0790dce9ca3d4..29232940da5c3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx @@ -51,11 +51,11 @@ export function validateBaseProperties( return validationResult; } -export function getConnectorErrors( +export async function getConnectorErrors( connector: UserConfiguredActionConnector, actionTypeModel: ActionTypeModel ) { - const connectorValidationResult = actionTypeModel?.validateConnector(connector); + const connectorValidationResult = await actionTypeModel?.validateConnector(connector); const configErrors = (connectorValidationResult.config ? connectorValidationResult.config.errors : {}) as IErrorObject; @@ -173,7 +173,8 @@ export const ActionConnectorForm = ({ ); const FieldsComponent = actionTypeRegistered.actionConnectorFields; - + const isNameInvalid: boolean = + connector.name !== undefined && errors.name !== undefined && errors.name.length > 0; return ( } - isInvalid={errors.name.length > 0 && connector.name !== undefined} + isInvalid={isNameInvalid} error={errors.name} > 0 && connector.name !== undefined} + isInvalid={isNameInvalid} name="name" placeholder="Untitled" data-test-subj="nameInput" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index ad727be58280f..bedde696e51c0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -53,12 +53,12 @@ describe('action_form', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -68,12 +68,12 @@ describe('action_form', () => { id: 'disabled-by-config', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -83,12 +83,12 @@ describe('action_form', () => { id: '.jira', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): ValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -98,12 +98,12 @@ describe('action_form', () => { id: 'disabled-by-license', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -113,12 +113,12 @@ describe('action_form', () => { id: 'preconfigured', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index e9f79633ef520..f12ce25abc492 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment, useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -30,7 +30,7 @@ import { ActionTypeRegistryContract, } from '../../../types'; import { SectionLoading } from '../../components/section_loading'; -import { ActionTypeForm, ActionTypeFormProps } from './action_type_form'; +import { ActionTypeForm } from './action_type_form'; import { AddConnectorInline } from './connector_add_inline'; import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; @@ -357,49 +357,42 @@ export const ActionForm = ({ ); } - const actionParamsErrors: ActionTypeFormProps['actionParamsErrors'] = actionTypeRegistry - .get(actionItem.actionTypeId) - ?.validateParams(actionItem.params); - return ( - - { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, indices: [index] }); - setAddModalVisibility(true); - }} - onConnectorSelected={(id: string) => { - setActionIdByIndex(id, index); - }} - actionTypeRegistry={actionTypeRegistry} - onDeleteAction={() => { - const updatedActions = actions.filter( - (_item: AlertAction, i: number) => i !== index - ); - setActions(updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: AlertAction) => item.id !== actionItem.id) - .length === 0 - ); - setActiveActionItem(undefined); - }} - /> - - + { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, indices: [index] }); + setAddModalVisibility(true); + }} + onConnectorSelected={(id: string) => { + setActionIdByIndex(id, index); + }} + actionTypeRegistry={actionTypeRegistry} + onDeleteAction={() => { + const updatedActions = actions.filter( + (_item: AlertAction, i: number) => i !== index + ); + setActions(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === + 0 + ); + setActiveActionItem(undefined); + }} + /> ); })} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx index 38f1e8f52254c..e8590595b9d61 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx @@ -43,12 +43,12 @@ describe('action_type_form', () => { id: '.pagerduty', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -92,12 +92,12 @@ describe('action_type_form', () => { id: '.pagerduty', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -220,7 +220,6 @@ function getActionTypeForm( onAddConnector={onAddConnector ?? jest.fn()} onDeleteAction={onDeleteAction ?? jest.fn()} onConnectorSelected={onConnectorSelected ?? jest.fn()} - actionParamsErrors={{ errors: { summary: [], timestamp: [], dedupKey: [] } }} defaultActionGroupId={defaultActionGroupId ?? 'default'} setActionParamsProperty={jest.fn()} index={index ?? 1} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index 2690aeaffad32..526d899b7efb1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -47,9 +47,6 @@ import { DefaultActionParams } from '../../lib/get_defaults_for_action_params'; export type ActionTypeFormProps = { actionItem: AlertAction; actionConnector: ActionConnector; - actionParamsErrors: { - errors: IErrorObject; - }; index: number; onAddConnector: () => void; onConnectorSelected: (id: string) => void; @@ -80,7 +77,6 @@ const preconfiguredMessage = i18n.translate( export const ActionTypeForm = ({ actionItem, actionConnector, - actionParamsErrors, index, onAddConnector, onConnectorSelected, @@ -106,6 +102,9 @@ export const ActionTypeForm = ({ const selectedActionGroup = actionGroups?.find(({ id }) => id === actionItem.group) ?? defaultActionGroup; const [actionGroup, setActionGroup] = useState(); + const [actionParamsErrors, setActionParamsErrors] = useState<{ errors: IErrorObject }>({ + errors: {}, + }); useEffect(() => { setAvailableActionVariables( @@ -130,6 +129,16 @@ export const ActionTypeForm = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionGroup]); + useEffect(() => { + (async () => { + const res: { errors: IErrorObject } = await actionTypeRegistry + .get(actionItem.actionTypeId) + ?.validateParams(actionItem.params); + setActionParamsErrors(res); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionItem]); + const canSave = hasSaveActionsCapability(capabilities); const getSelectedOptions = (actionItemId: string) => { const selectedConnector = connectors.find((connector) => connector.id === actionItemId); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx index 9a011823612c4..e15916138af71 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx @@ -40,12 +40,12 @@ describe('connector_add_flyout', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); @@ -77,12 +77,12 @@ describe('connector_add_flyout', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); @@ -114,12 +114,12 @@ describe('connector_add_flyout', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx index fedb2ed382994..8dbe5f105a0f7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx @@ -198,12 +198,12 @@ function createActionType() { id: `my-action-type-${++count}`, iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index d3a6d662720ca..1a3a186d891cc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useState, useReducer } from 'react'; +import React, { useCallback, useState, useReducer, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -30,7 +30,9 @@ import { ActionType, ActionConnector, UserConfiguredActionConnector, + IErrorObject, ConnectorAddFlyoutProps, + ActionTypeModel, } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { createActionConnector } from '../../lib/action_connector_api'; @@ -38,6 +40,7 @@ import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants'; import { useKibana } from '../../../common/lib/kibana'; import { createConnectorReducer, InitialConnector, ConnectorReducer } from './connector_reducer'; import { getConnectorWithInvalidatedFields } from '../../lib/value_validators'; +import { CenterJustifiedSpinner } from '../../components/center_justified_spinner'; const ConnectorAddFlyout: React.FunctionComponent = ({ onClose, @@ -47,7 +50,9 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ consumer, actionTypeRegistry, }) => { - let hasErrors = false; + const [hasErrors, setHasErrors] = useState(true); + let actionTypeModel: ActionTypeModel | undefined; + const { http, notifications: { toasts }, @@ -55,7 +60,17 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ } = useKibana().services; const [actionType, setActionType] = useState(undefined); const [hasActionsUpgradeableByTrial, setHasActionsUpgradeableByTrial] = useState(false); - + const [errors, setErrors] = useState<{ + configErrors: IErrorObject; + connectorBaseErrors: IErrorObject; + connectorErrors: IErrorObject; + secretsErrors: IErrorObject; + }>({ + configErrors: {}, + connectorBaseErrors: {}, + connectorErrors: {}, + secretsErrors: {}, + }); // hooks const initialConnector: InitialConnector, Record> = { actionTypeId: actionType?.id ?? '', @@ -73,6 +88,24 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ Record >, }); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + (async () => { + if (actionTypeModel) { + setIsLoading(true); + const res = await getConnectorErrors(connector, actionTypeModel); + setHasErrors( + !!Object.keys(res.connectorErrors).find( + (errorKey) => (res.connectorErrors as IErrorObject)[errorKey].length >= 1 + ) + ); + setIsLoading(false); + setErrors({ ...res }); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connector, actionType]); const setActionProperty = ( key: Key, @@ -101,7 +134,6 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ } let currentForm; - let actionTypeModel; let saveButton; if (!actionType) { currentForm = ( @@ -115,22 +147,12 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ } else { actionTypeModel = actionTypeRegistry.get(actionType.id); - const { - configErrors, - connectorBaseErrors, - connectorErrors, - secretsErrors, - } = getConnectorErrors(connector, actionTypeModel); - hasErrors = !!Object.keys(connectorErrors).find( - (errorKey) => connectorErrors[errorKey].length >= 1 - ); - currentForm = ( @@ -170,9 +192,9 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ setConnector( getConnectorWithInvalidatedFields( connector, - configErrors, - secretsErrors, - connectorBaseErrors + errors.configErrors, + errors.secretsErrors, + errors.connectorBaseErrors ) ); return; @@ -235,13 +257,13 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ - {actionTypeModel && actionTypeModel.iconClass ? ( + {!!actionTypeModel && actionTypeModel.iconClass ? ( ) : null} - {actionTypeModel && actionType ? ( + {!!actionTypeModel && actionType ? ( <>

@@ -280,7 +302,17 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ ) } > - {currentForm} + <> + {currentForm} + {isLoading ? ( + <> + + {' '} + + ) : ( + <> + )} + @@ -314,7 +346,7 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ - {canSave && actionTypeModel && actionType ? saveButton : null} + {canSave && !!actionTypeModel && actionType ? saveButton : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx index c18f6955d1217..1ae37cf96cd3e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx @@ -39,12 +39,12 @@ describe('connector_add_modal', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index d01ee08df2394..1e9669d1995dd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useMemo, useReducer, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiModal, @@ -19,6 +19,7 @@ import { EuiFlexItem, EuiIcon, EuiFlexGroup, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ActionConnectorForm, getConnectorErrors } from './action_connector_form'; @@ -31,9 +32,11 @@ import { ActionConnector, ActionTypeRegistryContract, UserConfiguredActionConnector, + IErrorObject, } from '../../../types'; import { useKibana } from '../../../common/lib/kibana'; import { getConnectorWithInvalidatedFields } from '../../lib/value_validators'; +import { CenterJustifiedSpinner } from '../../components/center_justified_spinner'; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions type ConnectorAddModalProps = { @@ -56,7 +59,7 @@ const ConnectorAddModal = ({ notifications: { toasts }, application: { capabilities }, } = useKibana().services; - let hasErrors = false; + const [hasErrors, setHasErrors] = useState(true); const initialConnector: InitialConnector< Record, Record @@ -69,6 +72,7 @@ const ConnectorAddModal = ({ [actionType.id] ); const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(false); const canSave = hasSaveActionsCapability(capabilities); const reducer: ConnectorReducer< @@ -81,6 +85,34 @@ const ConnectorAddModal = ({ Record >, }); + const [errors, setErrors] = useState<{ + configErrors: IErrorObject; + connectorBaseErrors: IErrorObject; + connectorErrors: IErrorObject; + secretsErrors: IErrorObject; + }>({ + configErrors: {}, + connectorBaseErrors: {}, + connectorErrors: {}, + secretsErrors: {}, + }); + + const actionTypeModel = actionTypeRegistry.get(actionType.id); + + useEffect(() => { + (async () => { + setIsLoading(true); + const res = await getConnectorErrors(connector, actionTypeModel); + setHasErrors( + !!Object.keys(res.connectorErrors).find( + (errorKey) => (res.connectorErrors as IErrorObject)[errorKey].length >= 1 + ) + ); + setIsLoading(false); + setErrors({ ...res }); + })(); + }, [connector, actionTypeModel]); + const setConnector = (value: any) => { dispatch({ command: { type: 'setConnector' }, payload: { key: 'connector', value } }); }; @@ -97,15 +129,6 @@ const ConnectorAddModal = ({ onClose(); }, [initialConnector, onClose]); - const actionTypeModel = actionTypeRegistry.get(actionType.id); - const { configErrors, connectorBaseErrors, connectorErrors, secretsErrors } = getConnectorErrors( - connector, - actionTypeModel - ); - hasErrors = !!Object.keys(connectorErrors).find( - (errorKey) => connectorErrors[errorKey].length >= 1 - ); - const onActionConnectorSave = async (): Promise => await createActionConnector({ http, connector }) .then((savedConnector) => { @@ -157,15 +180,25 @@ const ConnectorAddModal = ({ - + <> + + {isLoading ? ( + <> + + {' '} + + ) : ( + <> + )} + @@ -189,9 +222,9 @@ const ConnectorAddModal = ({ setConnector( getConnectorWithInvalidatedFields( connector, - configErrors, - secretsErrors, - connectorBaseErrors + errors.configErrors, + errors.secretsErrors, + errors.connectorBaseErrors ) ); return; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx index 56bf57cb45095..e6d3c0bde8113 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx @@ -51,12 +51,12 @@ describe('connector_edit_flyout', () => { id: 'test-action-type-id', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); @@ -95,12 +95,12 @@ describe('connector_edit_flyout', () => { id: 'test-action-type-id', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 66a4dcc452c51..ca729f9a61662 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useReducer, useState } from 'react'; +import React, { useCallback, useReducer, useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -23,6 +23,7 @@ import { EuiLink, EuiTabs, EuiTab, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Option, none, some } from 'fp-ts/lib/Option'; @@ -31,6 +32,7 @@ import { TestConnectorForm } from './test_connector_form'; import { ActionConnector, ConnectorEditFlyoutProps, + IErrorObject, EditConectorTabs, UserConfiguredActionConnector, } from '../../../types'; @@ -44,6 +46,7 @@ import { import './connector_edit_flyout.scss'; import { useKibana } from '../../../common/lib/kibana'; import { getConnectorWithInvalidatedFields } from '../../lib/value_validators'; +import { CenterJustifiedSpinner } from '../../components/center_justified_spinner'; const ConnectorEditFlyout = ({ initialConnector, @@ -53,12 +56,14 @@ const ConnectorEditFlyout = ({ consumer, actionTypeRegistry, }: ConnectorEditFlyoutProps) => { + const [hasErrors, setHasErrors] = useState(true); const { http, notifications: { toasts }, docLinks, application: { capabilities }, } = useKibana().services; + const getConnectorWithoutSecrets = () => ({ ...(initialConnector as UserConfiguredActionConnector< Record, @@ -75,6 +80,35 @@ const ConnectorEditFlyout = ({ const [{ connector }, dispatch] = useReducer(reducer, { connector: getConnectorWithoutSecrets(), }); + const [isLoading, setIsLoading] = useState(false); + const [errors, setErrors] = useState<{ + configErrors: IErrorObject; + connectorBaseErrors: IErrorObject; + connectorErrors: IErrorObject; + secretsErrors: IErrorObject; + }>({ + configErrors: {}, + connectorBaseErrors: {}, + connectorErrors: {}, + secretsErrors: {}, + }); + + const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); + + useEffect(() => { + (async () => { + setIsLoading(true); + const res = await getConnectorErrors(connector, actionTypeModel); + setHasErrors( + !!Object.keys(res.connectorErrors).find( + (errorKey) => (res.connectorErrors as IErrorObject)[errorKey].length >= 1 + ) + ); + setIsLoading(false); + setErrors({ ...res }); + })(); + }, [connector, actionTypeModel]); + const [isSaving, setIsSaving] = useState(false); const [selectedTab, setTab] = useState(tab); @@ -113,25 +147,6 @@ const ConnectorEditFlyout = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [onClose]); - const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); - const { - configErrors, - connectorBaseErrors, - connectorErrors, - secretsErrors, - } = !connector.isPreconfigured - ? getConnectorErrors(connector, actionTypeModel) - : { - configErrors: {}, - connectorBaseErrors: {}, - connectorErrors: {}, - secretsErrors: {}, - }; - - const hasErrors = !!Object.keys(connectorErrors).find( - (errorKey) => connectorErrors[errorKey].length >= 1 - ); - const onActionConnectorSave = async (): Promise => await updateActionConnector({ http, connector, id: connector.id }) .then((savedConnector) => { @@ -227,9 +242,9 @@ const ConnectorEditFlyout = ({ setConnector( getConnectorWithInvalidatedFields( connector, - configErrors, - secretsErrors, - connectorBaseErrors + errors.configErrors, + errors.secretsErrors, + errors.connectorBaseErrors ) ); return; @@ -286,19 +301,29 @@ const ConnectorEditFlyout = ({ {selectedTab === EditConectorTabs.Configuration ? ( !connector.isPreconfigured ? ( - { - setHasChanges(true); - // if the user changes the connector, "forget" the last execution - // so the user comes back to a clean form ready to run a fresh test - setTestExecutionResult(none); - dispatch(changes); - }} - actionTypeRegistry={actionTypeRegistry} - consumer={consumer} - /> + <> + { + setHasChanges(true); + // if the user changes the connector, "forget" the last execution + // so the user comes back to a clean form ready to run a fresh test + setTestExecutionResult(none); + dispatch(changes); + }} + actionTypeRegistry={actionTypeRegistry} + consumer={consumer} + /> + {isLoading ? ( + <> + + {' '} + + ) : ( + <> + )} + ) : ( <> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx index 5cdc15ab0375d..ae15670ce8ab9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx @@ -53,12 +53,12 @@ const actionType = { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx index 92a17a2e4cfae..242c1c33d8d79 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Suspense } from 'react'; +import React, { Suspense, useEffect, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -46,11 +46,18 @@ export const TestConnectorForm = ({ isExecutingAction, actionTypeRegistry, }: ConnectorAddFlyoutProps) => { + const [actionErrors, setActionErrors] = useState({}); + const [hasErrors, setHasErrors] = useState(false); const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); const ParamsFieldsComponent = actionTypeModel.actionParamsFields; - const actionErrors = actionTypeModel?.validateParams(actionParams).errors as IErrorObject; - const hasErrors = !!Object.values(actionErrors).find((errors) => errors.length > 0); + useEffect(() => { + (async () => { + const res = (await actionTypeModel?.validateParams(actionParams)).errors as IErrorObject; + setActionErrors({ ...res }); + setHasErrors(!!Object.values(res).find((errors) => errors.length > 0)); + })(); + }, [actionTypeModel, actionParams]); const steps = [ { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx index 7b6453e705ec3..90eadaf5f9b8b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx @@ -162,12 +162,12 @@ describe('actions_connectors_list component with items', () => { id: 'test', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index cb43c168aa999..b40b7cbc1a387 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -135,12 +135,12 @@ describe('alert_add', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx index a40f77998d6ee..2d111d5405230 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx @@ -15,9 +15,10 @@ import { AlertTypeParams, AlertUpdates, AlertFlyoutCloseReason, + IErrorObject, AlertAddProps, } from '../../../types'; -import { AlertForm, getAlertErrors, isValidAlert } from './alert_form'; +import { AlertForm, getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_form'; import { alertReducer, InitialAlert, InitialAlertReducer } from './alert_reducer'; import { createAlert } from '../../lib/alert_api'; import { HealthCheck } from '../../components/health_check'; @@ -102,6 +103,18 @@ const AlertAdd = ({ } }, [alert.params, initialAlertParams, setInitialAlertParams]); + const [alertActionsErrors, setAlertActionsErrors] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + (async () => { + setIsLoading(true); + const res = await getAlertActionErrors(alert as Alert, actionTypeRegistry); + setIsLoading(false); + setAlertActionsErrors([...res]); + })(); + }, [alert, actionTypeRegistry]); + const checkForChangesAndCloseFlyout = () => { if ( hasAlertChanged(alert, initialAlert, false) || @@ -125,9 +138,8 @@ const AlertAdd = ({ }; const alertType = alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null; - const { alertActionsErrors, alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( + const { alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( alert as Alert, - actionTypeRegistry, alertType ); @@ -195,9 +207,10 @@ const AlertAdd = ({ { setIsSaving(true); - if (!isValidAlert(alert, alertErrors, alertActionsErrors)) { + if (isLoading || !isValidAlert(alert, alertErrors, alertActionsErrors)) { setAlert( getAlertWithInvalidatedFields( alert as Alert, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add_footer.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add_footer.tsx index fe4b9d066429d..ee36257dedf0b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add_footer.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add_footer.tsx @@ -13,17 +13,25 @@ import { EuiFlexItem, EuiButtonEmpty, EuiButton, + EuiLoadingSpinner, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useHealthContext } from '../../context/health_context'; interface AlertAddFooterProps { isSaving: boolean; + isFormLoading: boolean; onSave: () => void; onCancel: () => void; } -export const AlertAddFooter = ({ isSaving, onSave, onCancel }: AlertAddFooterProps) => { +export const AlertAddFooter = ({ + isSaving, + onSave, + onCancel, + isFormLoading, +}: AlertAddFooterProps) => { const { loadingHealthCheck } = useHealthContext(); return ( @@ -36,6 +44,14 @@ export const AlertAddFooter = ({ isSaving, onSave, onCancel }: AlertAddFooterPro })} + {isFormLoading ? ( + + + + + ) : ( + <> + )} { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx index f6569f32088ee..bf6f0ef43b820 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useReducer, useState } from 'react'; +import React, { useReducer, useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -20,11 +20,12 @@ import { EuiPortal, EuiCallOut, EuiSpacer, + EuiLoadingSpinner, } from '@elastic/eui'; import { cloneDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { Alert, AlertEditProps, AlertFlyoutCloseReason } from '../../../types'; -import { AlertForm, getAlertErrors, isValidAlert } from './alert_form'; +import { Alert, AlertFlyoutCloseReason, AlertEditProps, IErrorObject } from '../../../types'; +import { AlertForm, getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_form'; import { alertReducer, ConcreteAlertReducer } from './alert_reducer'; import { updateAlert } from '../../lib/alert_api'; import { HealthCheck } from '../../components/health_check'; @@ -53,6 +54,8 @@ export const AlertEdit = ({ false ); const [isConfirmAlertCloseModalOpen, setIsConfirmAlertCloseModalOpen] = useState(false); + const [alertActionsErrors, setAlertActionsErrors] = useState([]); + const [isLoading, setIsLoading] = useState(false); const { http, @@ -64,9 +67,17 @@ export const AlertEdit = ({ const alertType = alertTypeRegistry.get(alert.alertTypeId); - const { alertActionsErrors, alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( + useEffect(() => { + (async () => { + setIsLoading(true); + const res = await getAlertActionErrors(alert as Alert, actionTypeRegistry); + setAlertActionsErrors([...res]); + setIsLoading(false); + })(); + }, [alert, actionTypeRegistry]); + + const { alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( alert as Alert, - actionTypeRegistry, alertType ); @@ -80,7 +91,11 @@ export const AlertEdit = ({ async function onSaveAlert(): Promise { try { - if (isValidAlert(alert, alertErrors, alertActionsErrors) && !hasActionsWithBrokenConnector) { + if ( + !isLoading && + isValidAlert(alert, alertErrors, alertActionsErrors) && + !hasActionsWithBrokenConnector + ) { const newAlert = await updateAlert({ http, alert, id: alert.id }); toasts.addSuccess( i18n.translate('xpack.triggersActionsUI.sections.alertEdit.saveSuccessNotificationText', { @@ -177,6 +192,14 @@ export const AlertEdit = ({ )} + {isLoading ? ( + + + + + ) : ( + <> + )} { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return { + validateConnector: (): Promise> => { + return Promise.resolve({ config: { errors: {}, }, secrets: { errors: {}, }, - }; + }); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index b4b6477fd5947..16878abc362d0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -121,11 +121,7 @@ export function validateBaseProperties(alertObject: InitialAlert): ValidationRes return validationResult; } -export function getAlertErrors( - alert: Alert, - actionTypeRegistry: ActionTypeRegistryContract, - alertTypeModel: AlertTypeModel | null -) { +export function getAlertErrors(alert: Alert, alertTypeModel: AlertTypeModel | null) { const alertParamsErrors: IErrorObject = alertTypeModel ? alertTypeModel.validate(alert.params).errors : []; @@ -135,18 +131,26 @@ export function getAlertErrors( ...alertBaseErrors, } as IErrorObject; - const alertActionsErrors = alert.actions.map((alertAction: AlertAction) => { - return actionTypeRegistry.get(alertAction.actionTypeId)?.validateParams(alertAction.params) - .errors; - }); return { alertParamsErrors, alertBaseErrors, - alertActionsErrors, alertErrors, }; } +export async function getAlertActionErrors( + alert: Alert, + actionTypeRegistry: ActionTypeRegistryContract +): Promise { + return await Promise.all( + alert.actions.map( + async (alertAction: AlertAction) => + (await actionTypeRegistry.get(alertAction.actionTypeId)?.validateParams(alertAction.params)) + .errors + ) + ); +} + export const hasObjectErrors: (errors: IErrorObject) => boolean = (errors) => !!Object.values(errors).find((errorList) => { if (isObject(errorList)) return hasObjectErrors(errorList as IErrorObject); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts index 8c7876c3f7255..ee561a65069e5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts @@ -42,12 +42,12 @@ const getTestActionType = ( id: id || 'my-action-type', iconClass: iconClass || 'test', selectMessage: selectedMessage || 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 0f2b961b1f2da..5ddddcb73a843 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -109,10 +109,10 @@ export interface ActionTypeModel - ) => ConnectorValidationResult, Partial>; + ) => Promise, Partial>>; validateParams: ( actionParams: ActionParams - ) => GenericValidationResult | unknown>; + ) => Promise | unknown>>; actionConnectorFields: React.LazyExoticComponent< ComponentType< ActionConnectorFieldsProps> From f367deca48a95ca017e98bbf9dba68c646187fc9 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 3 Jun 2021 08:59:22 +0200 Subject: [PATCH 11/35] [Exploratory View] Refactor series storage (#100571) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../exploratory_view/configurations/utils.ts | 2 +- .../exploratory_view.test.tsx | 22 ++-- .../exploratory_view/exploratory_view.tsx | 19 ++- .../exploratory_view/header/header.test.tsx | 13 +- .../shared/exploratory_view/header/header.tsx | 6 +- .../hooks/use_lens_attributes.ts | 6 +- .../hooks/use_series_filters.ts | 6 +- ...url_storage.tsx => use_series_storage.tsx} | 121 +++++++++++------- .../shared/exploratory_view/index.tsx | 26 ++-- .../shared/exploratory_view/rtl_helpers.tsx | 58 ++++----- .../columns/chart_types.test.tsx | 12 +- .../series_builder/columns/chart_types.tsx | 6 +- .../columns/data_types_col.test.tsx | 16 +-- .../series_builder/columns/data_types_col.tsx | 5 +- .../columns/operation_type_select.test.tsx | 26 ++-- .../columns/operation_type_select.tsx | 6 +- .../columns/report_breakdowns.test.tsx | 17 +-- .../columns/report_definition_col.test.tsx | 22 ++-- .../columns/report_definition_col.tsx | 8 +- .../columns/report_definition_field.tsx | 6 +- .../columns/report_filters.test.tsx | 5 +- .../columns/report_types_col.test.tsx | 22 ++-- .../columns/report_types_col.tsx | 9 +- .../series_builder/custom_report_field.tsx | 6 +- .../series_builder/series_builder.tsx | 11 +- .../series_date_picker/index.tsx | 6 +- .../series_date_picker.test.tsx | 41 +++--- .../series_editor/columns/breakdowns.test.tsx | 13 +- .../series_editor/columns/breakdowns.tsx | 6 +- .../columns/filter_expanded.test.tsx | 22 ++-- .../series_editor/columns/filter_expanded.tsx | 6 +- .../columns/filter_value_btn.test.tsx | 3 +- .../columns/filter_value_btn.tsx | 6 +- .../series_editor/columns/remove_series.tsx | 4 +- .../series_editor/columns/series_actions.tsx | 5 +- .../series_editor/columns/series_filter.tsx | 5 +- .../series_editor/selected_filters.test.tsx | 8 +- .../series_editor/selected_filters.tsx | 6 +- .../series_editor/series_editor.tsx | 4 +- 39 files changed, 332 insertions(+), 259 deletions(-) rename x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/{use_url_storage.tsx => use_series_storage.tsx} (51%) diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts index 0d79f76be341c..fc60800bc4403 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ import rison, { RisonValue } from 'rison-node'; -import type { AllSeries, AllShortSeries } from '../hooks/use_url_storage'; +import type { AllSeries, AllShortSeries } from '../hooks/use_series_storage'; import type { SeriesUrl } from '../types'; import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns'; import { esFilters } from '../../../../../../../../src/plugins/data/public'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx index cf51c4614e543..fc0062694e0a3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/dom'; -import { render, mockUrlStorage, mockCore, mockAppIndexPattern } from './rtl_helpers'; +import { render, mockCore, mockAppIndexPattern } from './rtl_helpers'; import { ExploratoryView } from './exploratory_view'; import { getStubIndexPattern } from '../../../../../../../src/plugins/data/public/test_utils'; import * as obsvInd from './utils/observability_index_patterns'; @@ -41,26 +41,26 @@ describe('ExploratoryView', () => { it('renders exploratory view', async () => { render(); - await waitFor(() => { - screen.getByText(/open in lens/i); - screen.getByRole('heading', { name: /analyze data/i }); - }); + expect(await screen.findByText(/open in lens/i)).toBeInTheDocument(); + expect( + await screen.findByRole('heading', { name: /Performance Distribution/i }) + ).toBeInTheDocument(); }); it('renders lens component when there is series', async () => { - mockUrlStorage({ + const initSeries = { data: { 'ux-series': { - dataType: 'ux', - reportType: 'pld', - breakdown: 'user_agent.name', + dataType: 'ux' as const, + reportType: 'pld' as const, + breakdown: 'user_agent .name', reportDefinitions: { 'service.name': ['elastic-co'] }, time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + render(, { initSeries }); expect(await screen.findByText(/open in lens/i)).toBeInTheDocument(); expect(await screen.findByText('Performance Distribution')).toBeInTheDocument(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx index 19136cda6387c..7958dca6e396e 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { ExploratoryViewHeader } from './header/header'; -import { useUrlStorage } from './hooks/use_url_storage'; +import { useSeriesStorage } from './hooks/use_series_storage'; import { useLensAttributes } from './hooks/use_lens_attributes'; import { EmptyView } from './components/empty_view'; import { TypedLensByValueInput } from '../../../../../lens/public'; @@ -19,7 +19,11 @@ import { useAppIndexPatternContext } from './hooks/use_app_index_pattern'; import { ReportToDataTypeMap } from './configurations/constants'; import { SeriesBuilder } from './series_builder/series_builder'; -export function ExploratoryView() { +export function ExploratoryView({ + saveAttributes, +}: { + saveAttributes?: (attr: TypedLensByValueInput['attributes'] | null) => void; +}) { const { services: { lens, notifications }, } = useKibana(); @@ -28,6 +32,7 @@ export function ExploratoryView() { const wrapperRef = useRef(null); const [height, setHeight] = useState('100vh'); + const [seriesId, setSeriesId] = useState(''); const [lensAttributes, setLensAttributes] = useState( null @@ -37,7 +42,11 @@ export function ExploratoryView() { const LensComponent = lens?.EmbeddableComponent; - const { firstSeriesId: seriesId, firstSeries: series, setSeries } = useUrlStorage(); + const { firstSeriesId, firstSeries: series, setSeries, allSeries } = useSeriesStorage(); + + useEffect(() => { + setSeriesId(firstSeriesId); + }, [allSeries, firstSeriesId]); const lensAttributesT = useLensAttributes({ seriesId, @@ -59,6 +68,10 @@ export function ExploratoryView() { useEffect(() => { setLensAttributes(lensAttributesT); + if (saveAttributes) { + saveAttributes(lensAttributesT); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(lensAttributesT ?? {}), series?.reportType, series?.time?.from]); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx index dec69dc0a7b33..ca9f2c9e73eb8 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { mockUrlStorage, render } from '../rtl_helpers'; +import { render } from '../rtl_helpers'; import { ExploratoryViewHeader } from './header'; import { fireEvent } from '@testing-library/dom'; @@ -22,22 +22,23 @@ describe('ExploratoryViewHeader', function () { }); it('should be able to click open in lens', function () { - mockUrlStorage({ + const initSeries = { data: { 'uptime-pings-histogram': { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; const { getByText, core } = render( + />, + { initSeries } ); fireEvent.click(getByText('Open in Lens')); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx index 8f2f30185d37f..3265287a7f915 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx @@ -12,7 +12,7 @@ import { TypedLensByValueInput } from '../../../../../../lens/public'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../../plugin'; import { DataViewLabels } from '../configurations/constants'; -import { useUrlStorage } from '../hooks/use_url_storage'; +import { useSeriesStorage } from '../hooks/use_series_storage'; interface Props { seriesId: string; @@ -24,7 +24,9 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) { services: { lens }, } = useKibana(); - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); return ( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts index ea6f435460401..4e9c360745b6b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts @@ -9,7 +9,7 @@ import { useMemo } from 'react'; import { isEmpty } from 'lodash'; import { TypedLensByValueInput } from '../../../../../../lens/public'; import { LensAttributes } from '../configurations/lens_attributes'; -import { useUrlStorage } from './use_url_storage'; +import { useSeriesStorage } from './use_series_storage'; import { getDefaultConfigs } from '../configurations/default_configs'; import { DataSeries, SeriesUrl, UrlFilter } from '../types'; @@ -40,8 +40,8 @@ export const getFiltersFromDefs = ( export const useLensAttributes = ({ seriesId, }: Props): TypedLensByValueInput['attributes'] | null => { - const { series } = useUrlStorage(seriesId); - + const { getSeries } = useSeriesStorage(); + const series = getSeries(seriesId); const { breakdown, seriesType, operationType, reportType, reportDefinitions = {} } = series ?? {}; const { indexPattern } = useAppIndexPatternContext(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts index 2605818ed7846..2d2618bc46152 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useUrlStorage } from './use_url_storage'; +import { useSeriesStorage } from './use_series_storage'; import { UrlFilter } from '../types'; export interface UpdateFilter { @@ -15,7 +15,9 @@ export interface UpdateFilter { } export const useSeriesFilters = ({ seriesId }: { seriesId: string }) => { - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const filters = series.filters ?? []; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx similarity index 51% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx index 498886cc94410..fac75f910a93f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx @@ -5,8 +5,11 @@ * 2.0. */ -import React, { createContext, useContext, Context } from 'react'; -import { IKbnUrlStateStorage } from '../../../../../../../../src/plugins/kibana_utils/public'; +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { + IKbnUrlStateStorage, + ISessionStorageStateStorage, +} from '../../../../../../../../src/plugins/kibana_utils/public'; import type { AppDataType, ReportViewTypeId, @@ -18,17 +21,81 @@ import { convertToShortUrl } from '../configurations/utils'; import { OperationType, SeriesType } from '../../../../../../lens/public'; import { URL_KEYS } from '../configurations/constants/url_constants'; -export const UrlStorageContext = createContext(null); +export interface SeriesContextValue { + firstSeries: SeriesUrl; + firstSeriesId: string; + allSeriesIds: string[]; + allSeries: AllSeries; + setSeries: (seriesIdN: string, newValue: SeriesUrl) => void; + getSeries: (seriesId: string) => SeriesUrl; + removeSeries: (seriesId: string) => void; +} +export const UrlStorageContext = createContext({} as SeriesContextValue); interface ProviderProps { - storage: IKbnUrlStateStorage; + storage: IKbnUrlStateStorage | ISessionStorageStateStorage; } export function UrlStorageContextProvider({ children, storage, }: ProviderProps & { children: JSX.Element }) { - return {children}; + const allSeriesKey = 'sr'; + + const [allShortSeries, setAllShortSeries] = useState( + () => storage.get(allSeriesKey) ?? {} + ); + const [allSeries, setAllSeries] = useState({}); + const [firstSeriesId, setFirstSeriesId] = useState(''); + + useEffect(() => { + const allSeriesIds = Object.keys(allShortSeries); + const allSeriesN: AllSeries = {}; + allSeriesIds.forEach((seriesKey) => { + allSeriesN[seriesKey] = convertFromShortUrl(allShortSeries[seriesKey]); + }); + + setAllSeries(allSeriesN); + setFirstSeriesId(allSeriesIds?.[0]); + (storage as IKbnUrlStateStorage).set(allSeriesKey, allShortSeries); + }, [allShortSeries, storage]); + + const setSeries = (seriesIdN: string, newValue: SeriesUrl) => { + setAllShortSeries((prevState) => { + prevState[seriesIdN] = convertToShortUrl(newValue); + return { ...prevState }; + }); + }; + + const removeSeries = (seriesIdN: string) => { + delete allShortSeries[seriesIdN]; + delete allSeries[seriesIdN]; + }; + + const allSeriesIds = Object.keys(allShortSeries); + + const getSeries = useCallback( + (seriesId?: string) => { + return seriesId ? allSeries?.[seriesId] ?? {} : ({} as SeriesUrl); + }, + [allSeries] + ); + + const value = { + storage, + getSeries, + setSeries, + removeSeries, + firstSeriesId, + allSeries, + allSeriesIds, + firstSeries: allSeries?.[firstSeriesId], + }; + return {children}; +} + +export function useSeriesStorage() { + return useContext(UrlStorageContext); } function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { @@ -64,47 +131,3 @@ export type AllShortSeries = Record; export type AllSeries = Record; export const NEW_SERIES_KEY = 'new-series-key'; - -export function useUrlStorage(seriesId?: string) { - const allSeriesKey = 'sr'; - const storage = useContext((UrlStorageContext as unknown) as Context); - let series: SeriesUrl = {} as SeriesUrl; - const allShortSeries = storage.get(allSeriesKey) ?? {}; - - const allSeriesIds = Object.keys(allShortSeries); - - const allSeries: AllSeries = {}; - - allSeriesIds.forEach((seriesKey) => { - allSeries[seriesKey] = convertFromShortUrl(allShortSeries[seriesKey]); - }); - - if (seriesId) { - series = allSeries?.[seriesId] ?? ({} as SeriesUrl); - } - - const setSeries = async (seriesIdN: string, newValue: SeriesUrl) => { - allShortSeries[seriesIdN] = convertToShortUrl(newValue); - allSeries[seriesIdN] = newValue; - return storage.set(allSeriesKey, allShortSeries); - }; - - const removeSeries = (seriesIdN: string) => { - delete allShortSeries[seriesIdN]; - delete allSeries[seriesIdN]; - storage.set(allSeriesKey, allShortSeries); - }; - - const firstSeriesId = allSeriesIds?.[0]; - - return { - storage, - setSeries, - removeSeries, - series, - firstSeriesId, - allSeries, - allSeriesIds, - firstSeries: allSeries?.[firstSeriesId], - }; -} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx index 80b6b29f88303..3de29b02853e8 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx @@ -17,11 +17,19 @@ import { IndexPatternContextProvider } from './hooks/use_app_index_pattern'; import { createKbnUrlStateStorage, withNotifyOnErrors, + createSessionStorageStateStorage, } from '../../../../../../../src/plugins/kibana_utils/public/'; -import { UrlStorageContextProvider } from './hooks/use_url_storage'; +import { UrlStorageContextProvider } from './hooks/use_series_storage'; import { useTrackPageview } from '../../..'; +import { TypedLensByValueInput } from '../../../../../lens/public'; -export function ExploratoryViewPage() { +export function ExploratoryViewPage({ + saveAttributes, + useSessionStorage = false, +}: { + useSessionStorage?: boolean; + saveAttributes?: (attr: TypedLensByValueInput['attributes'] | null) => void; +}) { useTrackPageview({ app: 'observability-overview', path: 'exploratory-view' }); useTrackPageview({ app: 'observability-overview', path: 'exploratory-view', delay: 15000 }); @@ -39,17 +47,19 @@ export function ExploratoryViewPage() { const history = useHistory(); - const kbnUrlStateStorage = createKbnUrlStateStorage({ - history, - useHash: uiSettings!.get('state:storeInSessionStorage'), - ...withNotifyOnErrors(notifications!.toasts), - }); + const kbnUrlStateStorage = useSessionStorage + ? createSessionStorageStateStorage() + : createKbnUrlStateStorage({ + history, + useHash: uiSettings!.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(notifications!.toasts), + }); return ( - + diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx index beb1daafbd55f..9118e49a42dfb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx @@ -16,29 +16,23 @@ import { CoreStart } from 'kibana/public'; import { I18nProvider } from '@kbn/i18n/react'; import { coreMock } from 'src/core/public/mocks'; import { - KibanaServices, KibanaContextProvider, + KibanaServices, } from '../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { lensPluginMock } from '../../../../../lens/public/mocks'; +import * as useAppIndexPatternHook from './hooks/use_app_index_pattern'; import { IndexPatternContextProvider } from './hooks/use_app_index_pattern'; -import { AllSeries, UrlStorageContextProvider } from './hooks/use_url_storage'; -import { - withNotifyOnErrors, - createKbnUrlStateStorage, -} from '../../../../../../../src/plugins/kibana_utils/public'; +import { AllSeries, UrlStorageContext } from './hooks/use_series_storage'; + import * as fetcherHook from '../../../hooks/use_fetcher'; -import * as useUrlHook from './hooks/use_url_storage'; import * as useSeriesFilterHook from './hooks/use_series_filters'; import * as useHasDataHook from '../../../hooks/use_has_data'; import * as useValuesListHook from '../../../hooks/use_values_list'; -import * as useAppIndexPatternHook from './hooks/use_app_index_pattern'; - // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { getStubIndexPattern } from '../../../../../../../src/plugins/data/public/index_patterns/index_pattern.stub'; import indexPatternData from './configurations/test_data/test_index_pattern.json'; - // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { setIndexPatterns } from '../../../../../../../src/plugins/data/public/services'; import { IndexPatternsContract } from '../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; @@ -73,6 +67,11 @@ interface RenderRouterOptions extends KibanaProviderOptions; url?: Url; + initSeries?: { + data?: AllSeries; + filters?: UrlFilter[]; + breakdown?: string; + }; } function getSetting(key: string): T { @@ -127,17 +126,8 @@ export const mockCore: () => Partial>({ children, core, - history, kibanaProps, }: MockKibanaProviderProps) { - const { notifications } = core!; - - const kbnUrlStateStorage = createKbnUrlStateStorage({ - history, - useHash: false, - ...withNotifyOnErrors(notifications!.toasts), - }); - const indexPattern = mockIndexPattern; setIndexPatterns(({ @@ -149,11 +139,7 @@ export function MockKibanaProvider>({ - - - {children} - - + {children} @@ -184,6 +170,7 @@ export function render( kibanaProps, renderOptions, url, + initSeries = {}, }: RenderRouterOptions = {} ) { if (url) { @@ -195,15 +182,20 @@ export function render( ...customCore, }; + const seriesContextValue = mockSeriesStorageContext(initSeries); + return { ...reactTestLibRender( - {ui} + + {ui} + , renderOptions ), history, core, + ...seriesContextValue, }; } @@ -256,7 +248,7 @@ export const mockUseValuesList = (values?: string[]) => { return { spy, onRefreshTimeRange }; }; -export const mockUrlStorage = ({ +function mockSeriesStorageContext({ data, filters, breakdown, @@ -264,7 +256,7 @@ export const mockUrlStorage = ({ data?: AllSeries; filters?: UrlFilter[]; breakdown?: string; -}) => { +}) { const mockDataSeries = data || { 'performance-distribution': { reportType: 'pld', @@ -282,18 +274,18 @@ export const mockUrlStorage = ({ const removeSeries = jest.fn(); const setSeries = jest.fn(); - const spy = jest.spyOn(useUrlHook, 'useUrlStorage').mockReturnValue({ + const getSeries = jest.fn().mockReturnValue(series); + + return { firstSeriesId, allSeriesIds, removeSeries, setSeries, - series, + getSeries, firstSeries: mockDataSeries[firstSeriesId], allSeries: mockDataSeries, - } as any); - - return { spy, removeSeries, setSeries }; -}; + }; +} export function mockUseSeriesFilter() { const removeFilter = jest.fn(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx index bac935dbecbe7..c054853d9c877 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx @@ -7,13 +7,11 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { mockUrlStorage, render } from '../../rtl_helpers'; +import { render } from '../../rtl_helpers'; import { SeriesChartTypesSelect, XYChartTypesSelect } from './chart_types'; describe.skip('SeriesChartTypesSelect', function () { it('should render properly', async function () { - mockUrlStorage({}); - render(); await waitFor(() => { @@ -22,9 +20,9 @@ describe.skip('SeriesChartTypesSelect', function () { }); it('should call set series on change', async function () { - const { setSeries } = mockUrlStorage({}); - - render(); + const { setSeries } = render( + + ); await waitFor(() => { screen.getByText(/chart type/i); @@ -44,8 +42,6 @@ describe.skip('SeriesChartTypesSelect', function () { describe('XYChartTypesSelect', function () { it('should render properly', async function () { - mockUrlStorage({}); - render(); await waitFor(() => { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx index a296d2520db34..9ae8b68bf3e8c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../../../plugin'; import { useFetcher } from '../../../../..'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { SeriesType } from '../../../../../../../lens/public'; const CHART_TYPE_LABEL = i18n.translate('xpack.observability.expView.chartTypes.label', { @@ -27,7 +27,9 @@ export function SeriesChartTypesSelect({ seriesTypes?: SeriesType[]; defaultChartType: SeriesType; }) { - const { series, setSeries, allSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries, allSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const seriesType = series?.seriesType ?? defaultChartType; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx index 9348fcbe15f6c..51529a3b1ac17 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { mockAppIndexPattern, mockUrlStorage, render } from '../../rtl_helpers'; +import { mockAppIndexPattern, render } from '../../rtl_helpers'; import { dataTypes, DataTypesCol } from './data_types_col'; describe('DataTypesCol', function () { @@ -24,9 +24,7 @@ describe('DataTypesCol', function () { }); it('should set series on change', function () { - const { setSeries } = mockUrlStorage({}); - - render(); + const { setSeries } = render(); fireEvent.click(screen.getByText(/user experience \(rum\)/i)); @@ -35,18 +33,18 @@ describe('DataTypesCol', function () { }); it('should set series on change on already selected', function () { - mockUrlStorage({ + const initSeries = { data: { [seriesId]: { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + render(, { initSeries }); const button = screen.getByRole('button', { name: /Synthetic Monitoring/i, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx index b64fad51e9778..08e7f4ddcd3d0 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx @@ -10,7 +10,7 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; import { AppDataType } from '../../types'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { ReportToDataTypeMap } from '../../configurations/constants'; export const dataTypes: Array<{ id: AppDataType; label: string }> = [ @@ -22,8 +22,9 @@ export const dataTypes: Array<{ id: AppDataType; label: string }> = [ ]; export function DataTypesCol({ seriesId }: { seriesId: string }) { - const { series, setSeries, removeSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries, removeSeries } = useSeriesStorage(); + const series = getSeries(seriesId); const { loading } = useAppIndexPatternContext(); const onDataTypeChange = (dataType?: AppDataType) => { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx index 9550b8e98103b..c262a94f968be 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { mockUrlStorage, render } from '../../rtl_helpers'; +import { render } from '../../rtl_helpers'; import { OperationTypeSelect } from './operation_type_select'; describe('OperationTypeSelect', function () { @@ -18,35 +18,35 @@ describe('OperationTypeSelect', function () { }); it('should display selected value', function () { - mockUrlStorage({ + const initSeries = { data: { 'performance-distribution': { - dataType: 'ux', - reportType: 'kpi', - operationType: 'median', + dataType: 'ux' as const, + reportType: 'kpi' as const, + operationType: 'median' as const, time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + render(, { initSeries }); screen.getByText('Median'); }); it('should call set series on change', function () { - const { setSeries } = mockUrlStorage({ + const initSeries = { data: { 'series-id': { - dataType: 'ux', - reportType: 'kpi', - operationType: 'median', + dataType: 'ux' as const, + reportType: 'kpi' as const, + operationType: 'median' as const, time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + const { setSeries } = render(, { initSeries }); fireEvent.click(screen.getByTestId('operationTypeSelect')); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx index 75203d7bae3a0..fa273f6180935 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx @@ -9,7 +9,7 @@ import React, { useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSuperSelect } from '@elastic/eui'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { OperationType } from '../../../../../../../lens/public'; export function OperationTypeSelect({ @@ -19,7 +19,9 @@ export function OperationTypeSelect({ seriesId: string; defaultOperationType?: OperationType; }) { - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const operationType = series?.operationType; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx index 3363d17d81eab..f576862f18e76 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx @@ -7,9 +7,8 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { render } from '../../../../../utils/test_helper'; import { getDefaultConfigs } from '../../configurations/default_configs'; -import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers'; +import { mockIndexPattern, render } from '../../rtl_helpers'; import { ReportBreakdowns } from './report_breakdowns'; import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames'; @@ -22,8 +21,6 @@ describe('Series Builder ReportBreakdowns', function () { }); it('should render properly', function () { - mockUrlStorage({}); - render(); screen.getByText('Select an option: , is selected'); @@ -31,9 +28,9 @@ describe('Series Builder ReportBreakdowns', function () { }); it('should set new series breakdown on change', function () { - const { setSeries } = mockUrlStorage({}); - - render(); + const { setSeries } = render( + + ); const btn = screen.getByRole('button', { name: /select an option: Browser family , is selected/i, @@ -53,9 +50,9 @@ describe('Series Builder ReportBreakdowns', function () { }); }); it('should set undefined on new series on no select breakdown', function () { - const { setSeries } = mockUrlStorage({}); - - render(); + const { setSeries } = render( + + ); const btn = screen.getByRole('button', { name: /select an option: Browser family , is selected/i, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx index 27adcf4682c02..fdf6633c0ddb5 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx @@ -11,7 +11,6 @@ import { getDefaultConfigs } from '../../configurations/default_configs'; import { mockAppIndexPattern, mockIndexPattern, - mockUrlStorage, mockUseValuesList, render, } from '../../rtl_helpers'; @@ -28,21 +27,23 @@ describe('Series Builder ReportDefinitionCol', function () { indexPattern: mockIndexPattern, }); - const { setSeries } = mockUrlStorage({ + const initSeries = { data: { [seriesId]: { - dataType: 'ux', - reportType: 'pld', + dataType: 'ux' as const, + reportType: 'pld' as const, time: { from: 'now-30d', to: 'now' }, reportDefinitions: { [SERVICE_NAME]: ['elastic-co'] }, }, }, - }); + }; mockUseValuesList(['elastic-co']); it('should render properly', async function () { - render(); + render(, { + initSeries, + }); screen.getByText('Web Application'); screen.getByText('Environment'); @@ -51,7 +52,9 @@ describe('Series Builder ReportDefinitionCol', function () { }); it('should render selected report definitions', async function () { - render(); + render(, { + initSeries, + }); expect(await screen.findByText('elastic-co')).toBeInTheDocument(); @@ -59,7 +62,10 @@ describe('Series Builder ReportDefinitionCol', function () { }); it('should be able to remove selected definition', async function () { - render(); + const { setSeries } = render( + , + { initSeries } + ); expect( await screen.findByLabelText('Remove elastic-co from selection in this group') diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx index ff8b0f7aa578b..338f5d52c26fa 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; import styled from 'styled-components'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { CustomReportField } from '../custom_report_field'; import { DataSeries, URLReportDefinition } from '../../types'; import { SeriesChartTypesSelect } from './chart_types'; @@ -38,9 +38,11 @@ export function ReportDefinitionCol({ }) { const { indexPattern } = useAppIndexPatternContext(); - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); - const { reportDefinitions: selectedReportDefinitions = {} } = series; + const series = getSeries(seriesId); + + const { reportDefinitions: selectedReportDefinitions = {} } = series ?? {}; const { reportDefinitions, defaultSeriesType, hasOperationType, yAxisColumns } = dataViewSeries; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx index 9f92bec4d1f9c..1a6d2af8f4d40 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx @@ -9,7 +9,7 @@ import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEmpty } from 'lodash'; import FieldValueSuggestions from '../../../field_value_suggestions'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; import { ESFilter } from '../../../../../../../../../typings/elasticsearch'; import { PersistableFilter } from '../../../../../../../lens/common'; @@ -25,7 +25,9 @@ interface Props { } export function ReportDefinitionField({ seriesId, field, dataSeries, onChange }: Props) { - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { indexPattern } = useAppIndexPatternContext(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx index 1467cb54d648a..dc2dc629cc121 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx @@ -7,10 +7,9 @@ import React from 'react'; import { screen } from '@testing-library/react'; -import { render } from '../../../../../utils/test_helper'; import { ReportFilters } from './report_filters'; import { getDefaultConfigs } from '../../configurations/default_configs'; -import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers'; +import { mockIndexPattern, render } from '../../rtl_helpers'; describe('Series Builder ReportFilters', function () { const seriesId = 'test-series-id'; @@ -20,7 +19,7 @@ describe('Series Builder ReportFilters', function () { reportType: 'pld', indexPattern: mockIndexPattern, }); - mockUrlStorage({}); + it('should render properly', function () { render(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx index 20c4ea98d482d..c721a2fa2fe77 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx @@ -7,11 +7,11 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { mockAppIndexPattern, mockUrlStorage, render } from '../../rtl_helpers'; +import { mockAppIndexPattern, render } from '../../rtl_helpers'; import { ReportTypesCol, SELECTED_DATA_TYPE_FOR_REPORT } from './report_types_col'; import { ReportTypes } from '../series_builder'; import { DEFAULT_TIME } from '../../configurations/constants'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; +import { NEW_SERIES_KEY } from '../../hooks/use_series_storage'; describe('ReportTypesCol', function () { const seriesId = 'test-series-id'; @@ -30,8 +30,9 @@ describe('ReportTypesCol', function () { }); it('should set series on change', function () { - const { setSeries } = mockUrlStorage({}); - render(); + const { setSeries } = render( + + ); fireEvent.click(screen.getByText(/monitor duration/i)); @@ -46,18 +47,21 @@ describe('ReportTypesCol', function () { }); it('should set selected as filled', function () { - const { setSeries } = mockUrlStorage({ + const initSeries = { data: { [NEW_SERIES_KEY]: { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + const { setSeries } = render( + , + { initSeries } + ); const button = screen.getByRole('button', { name: /pings histogram/i, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx index bd82d1d1bd500..9fff8dae14a47 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx @@ -11,7 +11,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import styled from 'styled-components'; import { ReportViewTypeId, SeriesUrl } from '../../types'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { DEFAULT_TIME } from '../../configurations/constants'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; @@ -21,10 +21,9 @@ interface Props { } export function ReportTypesCol({ seriesId, reportTypes }: Props) { - const { - series: { reportType: selectedReportType, ...restSeries }, - setSeries, - } = useUrlStorage(seriesId); + const { setSeries, getSeries } = useSeriesStorage(); + + const { reportType: selectedReportType, ...restSeries } = getSeries(seriesId); const { loading, hasData, selectedApp } = useAppIndexPatternContext(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx index b41f3a603e5da..201df9628e135 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiSuperSelect } from '@elastic/eui'; -import { useUrlStorage } from '../hooks/use_url_storage'; +import { useSeriesStorage } from '../hooks/use_series_storage'; import { ReportDefinition } from '../types'; interface Props { @@ -18,7 +18,9 @@ interface Props { } export function CustomReportField({ field, seriesId, options: opts }: Props) { - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { reportDefinitions: rtd = {} } = series; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx index 1944bb281598b..32f1fb7f7c43b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx @@ -15,7 +15,7 @@ import { ReportTypesCol } from './columns/report_types_col'; import { ReportDefinitionCol } from './columns/report_definition_col'; import { ReportFilters } from './columns/report_filters'; import { ReportBreakdowns } from './columns/report_breakdowns'; -import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_storage'; +import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; import { getDefaultConfigs } from '../configurations/default_configs'; @@ -53,7 +53,9 @@ export function SeriesBuilder({ seriesId: string; seriesBuilderRef: RefObject; }) { - const { series, setSeries, removeSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries, removeSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { dataType, @@ -156,9 +158,8 @@ export function SeriesBuilder({ reportDefinitions, }; - setSeries(newSeriesId, newSeriesN).then(() => { - removeSeries(NEW_SERIES_KEY); - }); + setSeries(newSeriesId, newSeriesN); + removeSeries(NEW_SERIES_KEY); } }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx index 960c2978287bc..d6a70532f4257 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx @@ -8,7 +8,7 @@ import { EuiSuperDatePicker } from '@elastic/eui'; import React, { useEffect } from 'react'; import { useHasData } from '../../../../hooks/use_has_data'; -import { useUrlStorage } from '../hooks/use_url_storage'; +import { useSeriesStorage } from '../hooks/use_series_storage'; import { useQuickTimeRanges } from '../../../../hooks/use_quick_time_ranges'; import { DEFAULT_TIME } from '../configurations/constants'; @@ -30,7 +30,9 @@ export function SeriesDatePicker({ seriesId }: Props) { const commonlyUsedRanges = useQuickTimeRanges(); - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); function onTimeChange({ start, end }: { start: string; end: string }) { onRefreshTimeRange(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx index e99b701f091fe..0edc4330ef97a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx @@ -6,62 +6,67 @@ */ import React from 'react'; -import { mockUrlStorage, mockUseHasData, render } from '../rtl_helpers'; +import { mockUseHasData, render } from '../rtl_helpers'; import { fireEvent, waitFor } from '@testing-library/react'; import { SeriesDatePicker } from './index'; import { DEFAULT_TIME } from '../configurations/constants'; describe('SeriesDatePicker', function () { it('should render properly', function () { - mockUrlStorage({ + const initSeries = { data: { 'uptime-pings-histogram': { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-30m', to: 'now' }, }, }, - }); - const { getByText } = render(); + }; + const { getByText } = render(, { initSeries }); getByText('Last 30 minutes'); }); it('should set defaults', async function () { - const { setSeries: setSeries1 } = mockUrlStorage({ + const initSeries = { data: { 'uptime-pings-histogram': { - reportType: 'upp', - dataType: 'synthetics', + reportType: 'upp' as const, + dataType: 'synthetics' as const, breakdown: 'monitor.status', }, }, - } as any); - render(); + }; + const { setSeries: setSeries1 } = render( + , + { initSeries: initSeries as any } + ); expect(setSeries1).toHaveBeenCalledTimes(1); expect(setSeries1).toHaveBeenCalledWith('uptime-pings-histogram', { breakdown: 'monitor.status', - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, time: DEFAULT_TIME, }); }); it('should set series data', async function () { - const { setSeries } = mockUrlStorage({ + const initSeries = { data: { 'uptime-pings-histogram': { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-30m', to: 'now' }, }, }, - }); + }; const { onRefreshTimeRange } = mockUseHasData(); - const { getByTestId } = render(); + const { getByTestId, setSeries } = render(, { + initSeries, + }); await waitFor(function () { fireEvent.click(getByTestId('superDatePickerToggleQuickMenuButton')); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx index 9d26ec79c31ad..0ce9db73f92b1 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { Breakdowns } from './breakdowns'; -import { mockIndexPattern, mockUrlStorage, render } from '../../rtl_helpers'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; +import { mockIndexPattern, render } from '../../rtl_helpers'; +import { NEW_SERIES_KEY } from '../../hooks/use_series_storage'; import { getDefaultConfigs } from '../../configurations/default_configs'; import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames'; @@ -21,8 +21,6 @@ describe('Breakdowns', function () { }); it('should render properly', async function () { - mockUrlStorage({}); - render( + />, + { initSeries } ); screen.getAllByText('Operating system'); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx index 5cf6ac47aa8c7..cf24cb31951b1 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { EuiSuperSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { USE_BREAK_DOWN_COLUMN } from '../../configurations/constants'; -import { useUrlStorage } from '../../hooks/use_url_storage'; import { DataSeries } from '../../types'; interface Props { @@ -19,7 +19,9 @@ interface Props { } export function Breakdowns({ reportViewConfig, seriesId, breakdowns = [] }: Props) { - const { setSeries, series } = useUrlStorage(seriesId); + const { setSeries, getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const selectedBreakdown = series.breakdown; const NO_BREAKDOWN = 'no_breakdown'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx index 8d3060792857e..1a8c5b335bc4f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx @@ -8,12 +8,12 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { FilterExpanded } from './filter_expanded'; -import { mockAppIndexPattern, mockUrlStorage, mockUseValuesList, render } from '../../rtl_helpers'; +import { mockAppIndexPattern, mockUseValuesList, render } from '../../rtl_helpers'; import { USER_AGENT_NAME } from '../../configurations/constants/elasticsearch_fieldnames'; describe('FilterExpanded', function () { it('should render properly', async function () { - mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; mockAppIndexPattern(); render( @@ -22,13 +22,14 @@ describe('FilterExpanded', function () { label={'Browser Family'} field={USER_AGENT_NAME} goBack={jest.fn()} - /> + />, + { initSeries } ); screen.getByText('Browser Family'); }); it('should call go back on click', async function () { - mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; const goBack = jest.fn(); render( @@ -37,7 +38,8 @@ describe('FilterExpanded', function () { label={'Browser Family'} field={USER_AGENT_NAME} goBack={goBack} - /> + />, + { initSeries } ); fireEvent.click(screen.getByText('Browser Family')); @@ -47,7 +49,7 @@ describe('FilterExpanded', function () { }); it('should call useValuesList on load', async function () { - mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; const { spy } = mockUseValuesList(['Chrome', 'Firefox']); @@ -59,7 +61,8 @@ describe('FilterExpanded', function () { label={'Browser Family'} field={USER_AGENT_NAME} goBack={goBack} - /> + />, + { initSeries } ); expect(spy).toHaveBeenCalledTimes(1); @@ -71,7 +74,7 @@ describe('FilterExpanded', function () { ); }); it('should filter display values', async function () { - mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; mockUseValuesList(['Chrome', 'Firefox']); @@ -81,7 +84,8 @@ describe('FilterExpanded', function () { label={'Browser Family'} field={USER_AGENT_NAME} goBack={jest.fn()} - /> + />, + { initSeries } ); expect(screen.queryByText('Firefox')).toBeTruthy(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx index 7a646c9035968..cc1769cfa8c95 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { rgba } from 'polished'; import { i18n } from '@kbn/i18n'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { UrlFilter } from '../../types'; import { FilterValueButton } from './filter_value_btn'; import { useValuesList } from '../../../../../hooks/use_values_list'; @@ -33,7 +33,9 @@ export function FilterExpanded({ seriesId, field, label, goBack, nestedField, is const [isOpen, setIsOpen] = useState({ value: '', negate: false }); - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { values, loading } = useValuesList({ query: value, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx index befbb3b74d6d7..79eb858b7624b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { FilterValueButton } from './filter_value_btn'; -import { mockUrlStorage, mockUseSeriesFilter, mockUseValuesList, render } from '../../rtl_helpers'; +import { mockUseSeriesFilter, mockUseValuesList, render } from '../../rtl_helpers'; import { USER_AGENT_NAME, USER_AGENT_VERSION, @@ -75,7 +75,6 @@ describe('FilterValueButton', function () { }); }); it('should remove filter on click if already selected', async function () { - mockUrlStorage({}); const { removeFilter } = mockUseSeriesFilter(); render( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx index ccb9c90a884bb..ea84ec6b6c212 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; import { EuiFilterButton, hexToRgb } from '@elastic/eui'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { useSeriesFilters } from '../../hooks/use_series_filters'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; import FieldValueSuggestions from '../../../field_value_suggestions'; @@ -37,7 +37,9 @@ export function FilterValueButton({ nestedField, allSelectedValues, }: Props) { - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { indexPattern } = useAppIndexPatternContext(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx index ba2cdc545fbef..dc84352ff3b3d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx @@ -8,14 +8,14 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { EuiButtonIcon } from '@elastic/eui'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; interface Props { seriesId: string; } export function RemoveSeries({ seriesId }: Props) { - const { removeSeries } = useUrlStorage(); + const { removeSeries } = useSeriesStorage(); const onClick = () => { removeSeries(seriesId); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx index cdc20e2d9ab6c..5374fc33093a1 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx @@ -9,14 +9,15 @@ import React from 'react'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { RemoveSeries } from './remove_series'; -import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_storage'; +import { NEW_SERIES_KEY, useSeriesStorage } from '../../hooks/use_series_storage'; import { ReportToDataTypeMap } from '../../configurations/constants'; interface Props { seriesId: string; } export function SeriesActions({ seriesId }: Props) { - const { series, removeSeries, setSeries } = useUrlStorage(seriesId); + const { getSeries, removeSeries, setSeries } = useSeriesStorage(); + const series = getSeries(seriesId); const onEdit = () => { removeSeries(seriesId); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx index 926852fda5cbc..9e5770c2de8f9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx @@ -19,7 +19,7 @@ import { FilterExpanded } from './filter_expanded'; import { DataSeries } from '../../types'; import { FieldLabels } from '../../configurations/constants/constants'; import { SelectedFilters } from '../selected_filters'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; interface Props { seriesId: string; @@ -53,7 +53,8 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P }; }); - const { setSeries, series: urlSeries } = useUrlStorage(seriesId); + const { setSeries, getSeries } = useSeriesStorage(); + const urlSeries = getSeries(seriesId); const button = ( ); + render(, { initSeries }); await waitFor(() => { screen.getByText('Chrome'); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx index aabb39f88507f..63abb581c9c72 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx @@ -7,7 +7,7 @@ import React, { Fragment } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { useUrlStorage } from '../hooks/use_url_storage'; +import { useSeriesStorage } from '../hooks/use_series_storage'; import { FilterLabel } from '../components/filter_label'; import { DataSeries, UrlFilter } from '../types'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; @@ -20,7 +20,9 @@ interface Props { isNew?: boolean; } export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props) { - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { reportDefinitions = {} } = series; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx index d883b854c88cb..6e513fcd2fec9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx @@ -11,7 +11,7 @@ import { EuiBasicTable, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { SeriesFilter } from './columns/series_filter'; import { DataSeries } from '../types'; -import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_storage'; +import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage'; import { getDefaultConfigs } from '../configurations/default_configs'; import { DatePickerCol } from './columns/date_picker_col'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; @@ -19,7 +19,7 @@ import { SeriesActions } from './columns/series_actions'; import { ChartEditOptions } from './chart_edit_options'; export function SeriesEditor() { - const { allSeries, firstSeriesId } = useUrlStorage(); + const { allSeries, firstSeriesId } = useSeriesStorage(); const columns = [ { From d4ecee6ba0ceb92fcf965b776139821abfb85975 Mon Sep 17 00:00:00 2001 From: Ashokaditya Date: Thu, 3 Jun 2021 09:22:49 +0200 Subject: [PATCH 12/35] [Security Solution] [Endpoint] Add endpoint details activity log (#99795) * WIP add tabs for endpoint details * fetch activity log for endpoint this is work in progress with dummy data * refactor to hold host details and activity log within endpointDetails * api for fetching actions log * add a selector for getting selected agent id * use the new api to show actions log * review changes * move util function to common/utils in order to use it in endpoint_hosts as well as in trusted _apps review suggestion * use util function to get API path review suggestion * sync url params with details active tab review suggestion * fix types due to merge commit refs 3722552f739f74d3c457e8ed6cf80444aa6dfd06 * use AsyncResourseState type review suggestions * sort entries chronologically with recent at the top * adjust icon sizes within entries to match mocks * remove endpoint list paging stuff (not for now) * fix import after sync with master * make the search bar work (sort of) this needs to be fleshed out in a later PR * add tests to middleware for now * use snake case for naming routes review changes * rename and use own relative time function review change * use euiTheme tokens review change * add a comment review changes * log errors to kibana log and unwind stack review changes * use FleetActionGenerator for mocking data review changes Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/endpoint/constants.ts | 3 + .../common/endpoint/schema/actions.ts | 8 ++ .../endpoint/formatted_date_time.tsx | 8 +- .../containers/detection_engine/alerts/api.ts | 2 +- .../public/management/common/utils.test.ts | 37 +++++- .../public/management/common/utils.ts | 5 + .../pages/endpoint_hosts/store/action.ts | 10 +- .../pages/endpoint_hosts/store/builders.ts | 53 ++++++++ .../pages/endpoint_hosts/store/index.test.ts | 13 +- .../endpoint_hosts/store/middleware.test.ts | 66 +++++++++- .../pages/endpoint_hosts/store/middleware.ts | 29 ++++- .../pages/endpoint_hosts/store/reducer.ts | 114 ++++++++++------- .../pages/endpoint_hosts/store/selectors.ts | 55 ++++++++- .../management/pages/endpoint_hosts/types.ts | 20 +-- .../components/endpoint_details_tabs.tsx | 78 ++++++++++++ .../view/details/components/log_entry.tsx | 57 +++++++++ .../view/details/endpoint_activity_log.tsx | 45 +++++++ .../view/details/endpoint_details.tsx | 1 + .../view/details/endpoints.stories.tsx | 111 +++++++++++++++++ .../endpoint_hosts/view/details/index.tsx | 115 +++++++++++++++--- .../pages/endpoint_hosts/view/translations.ts | 23 ++++ .../pages/trusted_apps/service/index.ts | 2 +- .../pages/trusted_apps/service/utils.test.ts | 45 ------- .../pages/trusted_apps/service/utils.ts | 11 -- .../view/trusted_apps_page.test.tsx | 2 +- .../public/management/store/reducer.ts | 8 +- .../endpoint/routes/actions/audit_log.ts | 30 +++++ .../routes/actions/audit_log_handler.ts | 59 +++++++++ .../server/endpoint/routes/actions/index.ts | 1 + .../security_solution/server/plugin.ts | 6 +- 30 files changed, 868 insertions(+), 149 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log_handler.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 1c0b09a4648e5..c85778f2f38fa 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -32,3 +32,6 @@ export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`; /** Host Isolation Routes */ export const ISOLATE_HOST_ROUTE = `/api/endpoint/isolate`; export const UNISOLATE_HOST_ROUTE = `/api/endpoint/unisolate`; + +/** Endpoint Actions Log Routes */ +export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts index e8997158cdfad..32affddf46294 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts @@ -20,3 +20,11 @@ export const HostIsolationRequestSchema = { comment: schema.maybe(schema.string()), }), }; + +export const EndpointActionLogRequestSchema = { + // TODO improve when using pagination with query params + query: schema.object({}), + params: schema.object({ + agent_id: schema.string(), + }), +}; diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx index 2fdb7e99d860e..740437646f61a 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx @@ -8,10 +8,14 @@ import React from 'react'; import { FormattedDate, FormattedTime, FormattedRelative } from '@kbn/i18n/react'; -export const FormattedDateAndTime: React.FC<{ date: Date }> = ({ date }) => { +export const FormattedDateAndTime: React.FC<{ date: Date; showRelativeTime?: boolean }> = ({ + date, + showRelativeTime = false, +}) => { // If date is greater than or equal to 1h (ago), then show it as a date + // and if showRelativeTime is false // else, show it as relative to "now" - return Date.now() - date.getTime() >= 3.6e6 ? ( + return Date.now() - date.getTime() >= 3.6e6 && !showRelativeTime ? ( <> {' @'} diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index 65185b4d05135..a7bd42c6af5ee 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -25,7 +25,7 @@ import { UpdateAlertStatusProps, CasesFromAlertsResponse, } from './types'; -import { resolvePathVariables } from '../../../../management/pages/trusted_apps/service/utils'; +import { resolvePathVariables } from '../../../../management/common/utils'; import { isolateHost, unIsolateHost } from '../../../../common/lib/host_isolation'; /** diff --git a/x-pack/plugins/security_solution/public/management/common/utils.test.ts b/x-pack/plugins/security_solution/public/management/common/utils.test.ts index 59455ccd6bb04..8918261b6a436 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.test.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { parseQueryFilterToKQL } from './utils'; +import { parseQueryFilterToKQL, resolvePathVariables } from './utils'; describe('utils', () => { const searchableFields = [`name`, `description`, `entries.value`, `entries.entries.value`]; @@ -39,4 +39,39 @@ describe('utils', () => { ); }); }); + + describe('resolvePathVariables', () => { + it('should resolve defined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( + '/segment1/value1/segment2' + ); + }); + + it('should not resolve undefined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should ignore unused variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should replace multiple variable occurences', () => { + expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( + '/value1/segment1/value1' + ); + }); + + it('should replace multiple variables', () => { + const path = resolvePathVariables('/{var1}/segment1/{var2}', { + var1: 'value1', + var2: 'value2', + }); + + expect(path).toBe('/value1/segment1/value2'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/common/utils.ts b/x-pack/plugins/security_solution/public/management/common/utils.ts index c8cf761ccaf86..78a95eb4d6f81 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.ts @@ -19,3 +19,8 @@ export const parseQueryFilterToKQL = (filter: string, fields: Readonly return kuery; }; + +export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => + Object.keys(variables).reduce((acc, paramName) => { + return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); + }, path); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts index d80a7d03903ac..25f2631ef46ff 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts @@ -37,7 +37,6 @@ export interface ServerFailedToReturnEndpointDetails { type: 'serverFailedToReturnEndpointDetails'; payload: ServerApiError; } - export interface ServerReturnedEndpointPolicyResponse { type: 'serverReturnedEndpointPolicyResponse'; payload: GetHostPolicyResponse; @@ -137,19 +136,24 @@ export interface ServerFailedToReturnEndpointsTotal { payload: ServerApiError; } -type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & { +export type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & { payload: HostIsolationRequestBody; }; -type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & { +export type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & { payload: EndpointState['isolationRequestState']; }; +export type EndpointDetailsActivityLogChanged = Action<'endpointDetailsActivityLogChanged'> & { + payload: EndpointState['endpointDetails']['activityLog']; +}; + export type EndpointAction = | ServerReturnedEndpointList | ServerFailedToReturnEndpointList | ServerReturnedEndpointDetails | ServerFailedToReturnEndpointDetails + | EndpointDetailsActivityLogChanged | ServerReturnedEndpointPolicyResponse | ServerFailedToReturnEndpointPolicyResponse | ServerReturnedPoliciesForOnboarding diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts new file mode 100644 index 0000000000000..d5416d9f8ec96 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.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 { Immutable } from '../../../../../common/endpoint/types'; +import { DEFAULT_POLL_INTERVAL } from '../../../common/constants'; +import { createUninitialisedResourceState } from '../../../state'; +import { EndpointState } from '../types'; + +export const initialEndpointPageState = (): Immutable => { + return { + hosts: [], + pageSize: 10, + pageIndex: 0, + total: 0, + loading: false, + error: undefined, + endpointDetails: { + activityLog: createUninitialisedResourceState(), + hostDetails: { + details: undefined, + detailsLoading: false, + detailsError: undefined, + }, + }, + policyResponse: undefined, + policyResponseLoading: false, + policyResponseError: undefined, + location: undefined, + policyItems: [], + selectedPolicyId: undefined, + policyItemsLoading: false, + endpointPackageInfo: undefined, + nonExistingPolicies: {}, + agentPolicies: {}, + endpointsExist: true, + patterns: [], + patternsError: undefined, + isAutoRefreshEnabled: true, + autoRefreshInterval: DEFAULT_POLL_INTERVAL, + agentsWithEndpointsTotal: 0, + agentsWithEndpointsTotalError: undefined, + endpointsTotal: 0, + endpointsTotalError: undefined, + queryStrategyVersion: undefined, + policyVersionInfo: undefined, + hostStatus: undefined, + isolationRequestState: createUninitialisedResourceState(), + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts index 79f0c5af9bbe3..5be67a3581c9e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts @@ -41,9 +41,16 @@ describe('EndpointList store concerns', () => { total: 0, loading: false, error: undefined, - details: undefined, - detailsLoading: false, - detailsError: undefined, + endpointDetails: { + activityLog: { + type: 'UninitialisedResourceState', + }, + hostDetails: { + details: undefined, + detailsLoading: false, + detailsError: undefined, + }, + }, policyResponse: undefined, policyResponseLoading: false, policyResponseError: undefined, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts index c52d922001887..04a04bc38996b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts @@ -18,6 +18,7 @@ import { Immutable, HostResultList, HostIsolationResponse, + EndpointAction, } from '../../../../../common/endpoint/types'; import { AppAction } from '../../../../common/store/actions'; import { mockEndpointResultList } from './mock_endpoint_result_list'; @@ -25,8 +26,9 @@ import { listData } from './selectors'; import { EndpointState } from '../types'; import { endpointListReducer } from './reducer'; import { endpointMiddlewareFactory } from './middleware'; -import { getEndpointListPath } from '../../../common/routing'; +import { getEndpointListPath, getEndpointDetailsPath } from '../../../common/routing'; import { + createLoadedResourceState, FailedResourceState, isFailedResourceState, isLoadedResourceState, @@ -39,6 +41,7 @@ import { hostIsolationRequestBodyMock, hostIsolationResponseMock, } from '../../../../common/lib/host_isolation/mocks'; +import { FleetActionGenerator } from '../../../../../common/endpoint/data_generators/fleet_action_generator'; jest.mock('../../policy/store/services/ingest', () => ({ sendGetAgentConfigList: () => Promise.resolve({ items: [] }), @@ -192,4 +195,65 @@ describe('endpoint list middleware', () => { expect(failedAction.error).toBe(apiError); }); }); + + describe('handle ActivityLog State Change actions', () => { + const endpointList = getEndpointListApiResponse(); + const search = getEndpointDetailsPath({ + name: 'endpointDetails', + selected_endpoint: endpointList.hosts[0].metadata.agent.id, + }); + const dispatchUserChangedUrl = () => { + dispatch({ + type: 'userChangedUrl', + payload: { + ...history.location, + pathname: '/endpoints', + search: `?${search.split('?').pop()}`, + }, + }); + }; + const fleetActionGenerator = new FleetActionGenerator(Math.random().toString()); + const activityLog = [ + fleetActionGenerator.generate({ + agents: [endpointList.hosts[0].metadata.agent.id], + }), + ]; + const dispatchGetActivityLog = () => { + dispatch({ + type: 'endpointDetailsActivityLogChanged', + payload: createLoadedResourceState(activityLog), + }); + }; + + it('should set ActivityLog state to loading', async () => { + dispatchUserChangedUrl(); + + const loadingDispatched = waitForAction('endpointDetailsActivityLogChanged', { + validate(action) { + return isLoadingResourceState(action.payload); + }, + }); + + const loadingDispatchedResponse = await loadingDispatched; + expect(loadingDispatchedResponse.payload.type).toEqual('LoadingResourceState'); + }); + + it('should set ActivityLog state to loaded when fetching activity log is successful', async () => { + dispatchUserChangedUrl(); + + const loadedDispatched = waitForAction('endpointDetailsActivityLogChanged', { + validate(action) { + return isLoadedResourceState(action.payload); + }, + }); + + dispatchGetActivityLog(); + const loadedDispatchedResponse = await loadedDispatched; + const activityLogData = (loadedDispatchedResponse.payload as LoadedResourceState< + EndpointAction[] + >).data; + + expect(activityLogData).toEqual(activityLog); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 9db9932dd4387..90427d5003384 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -7,6 +7,7 @@ import { HttpStart } from 'kibana/public'; import { + EndpointAction, HostInfo, HostIsolationRequestBody, HostIsolationResponse, @@ -18,6 +19,7 @@ import { ImmutableMiddlewareAPI, ImmutableMiddlewareFactory } from '../../../../ import { isOnEndpointPage, hasSelectedEndpoint, + selectedAgent, uiQueryParams, listData, endpointPackageInfo, @@ -27,6 +29,7 @@ import { isTransformEnabled, getIsIsolationRequestPending, getCurrentIsolationRequestState, + getActivityLogData, } from './selectors'; import { EndpointState, PolicyIds } from '../types'; import { @@ -37,12 +40,13 @@ import { } from '../../policy/store/services/ingest'; import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../fleet/common'; import { + ENDPOINT_ACTION_LOG_ROUTE, HOST_METADATA_GET_API, HOST_METADATA_LIST_API, metadataCurrentIndexPattern, } from '../../../../../common/endpoint/constants'; import { IIndexPattern, Query } from '../../../../../../../../src/plugins/data/public'; -import { resolvePathVariables } from '../../trusted_apps/service/utils'; +import { resolvePathVariables } from '../../../common/utils'; import { createFailedResourceState, createLoadedResourceState, @@ -336,6 +340,29 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory(getActivityLogData(getState())), + }); + + try { + const activityLog = await coreStart.http.get( + resolvePathVariables(ENDPOINT_ACTION_LOG_ROUTE, { agent_id: selectedAgent(getState()) }) + ); + dispatch({ + type: 'endpointDetailsActivityLogChanged', + payload: createLoadedResourceState(activityLog), + }); + } catch (error) { + dispatch({ + type: 'endpointDetailsActivityLogChanged', + payload: createFailedResourceState(error.body ?? error), + }); + } + // call the policy response api try { const policyResponse = await coreStart.http.get(`/api/endpoint/policy_response`, { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts index b2b46e6de9842..19235b792b270 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { EndpointDetailsActivityLogChanged } from './action'; import { isOnEndpointPage, hasSelectedEndpoint, @@ -12,52 +13,33 @@ import { getCurrentIsolationRequestState, } from './selectors'; import { EndpointState } from '../types'; +import { initialEndpointPageState } from './builders'; import { AppAction } from '../../../../common/store/actions'; import { ImmutableReducer } from '../../../../common/store'; import { Immutable } from '../../../../../common/endpoint/types'; -import { DEFAULT_POLL_INTERVAL } from '../../../common/constants'; import { createUninitialisedResourceState, isUninitialisedResourceState } from '../../../state'; -export const initialEndpointListState: Immutable = { - hosts: [], - pageSize: 10, - pageIndex: 0, - total: 0, - loading: false, - error: undefined, - details: undefined, - detailsLoading: false, - detailsError: undefined, - policyResponse: undefined, - policyResponseLoading: false, - policyResponseError: undefined, - location: undefined, - policyItems: [], - selectedPolicyId: undefined, - policyItemsLoading: false, - endpointPackageInfo: undefined, - nonExistingPolicies: {}, - agentPolicies: {}, - endpointsExist: true, - patterns: [], - patternsError: undefined, - isAutoRefreshEnabled: true, - autoRefreshInterval: DEFAULT_POLL_INTERVAL, - agentsWithEndpointsTotal: 0, - agentsWithEndpointsTotalError: undefined, - endpointsTotal: 0, - endpointsTotalError: undefined, - queryStrategyVersion: undefined, - policyVersionInfo: undefined, - hostStatus: undefined, - isolationRequestState: createUninitialisedResourceState(), -}; +type StateReducer = ImmutableReducer; +type CaseReducer = ( + state: Immutable, + action: Immutable +) => Immutable; -/* eslint-disable-next-line complexity */ -export const endpointListReducer: ImmutableReducer = ( - state = initialEndpointListState, +const handleEndpointDetailsActivityLogChanged: CaseReducer = ( + state, action ) => { + return { + ...state!, + endpointDetails: { + ...state.endpointDetails!, + activityLog: action.payload, + }, + }; +}; + +/* eslint-disable-next-line complexity */ +export const endpointListReducer: StateReducer = (state = initialEndpointPageState(), action) => { if (action.type === 'serverReturnedEndpointList') { const { hosts, @@ -115,18 +97,32 @@ export const endpointListReducer: ImmutableReducer = ( } else if (action.type === 'serverReturnedEndpointDetails') { return { ...state, - details: action.payload.metadata, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + details: action.payload.metadata, + detailsLoading: false, + detailsError: undefined, + }, + }, policyVersionInfo: action.payload.policy_info, hostStatus: action.payload.host_status, - detailsLoading: false, - detailsError: undefined, }; } else if (action.type === 'serverFailedToReturnEndpointDetails') { return { ...state, - detailsError: action.payload, - detailsLoading: false, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsError: action.payload, + detailsLoading: false, + }, + }, }; + } else if (action.type === 'endpointDetailsActivityLogChanged') { + return handleEndpointDetailsActivityLogChanged(state, action); } else if (action.type === 'serverReturnedPoliciesForOnboarding') { return { ...state, @@ -221,7 +217,6 @@ export const endpointListReducer: ImmutableReducer = ( const stateUpdates: Partial = { location: action.payload, error: undefined, - detailsError: undefined, policyResponseError: undefined, }; @@ -239,6 +234,13 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, ...stateUpdates, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsError: undefined, + }, + }, loading: true, policyItemsLoading: true, }; @@ -249,6 +251,14 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, ...stateUpdates, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsLoading: true, + detailsError: undefined, + }, + }, detailsLoading: true, policyResponseLoading: true, }; @@ -257,8 +267,15 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, ...stateUpdates, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsLoading: true, + detailsError: undefined, + }, + }, loading: true, - detailsLoading: true, policyResponseLoading: true, policyItemsLoading: true, }; @@ -268,6 +285,13 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, ...stateUpdates, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsError: undefined, + }, + }, endpointsExist: true, }; } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index af95d89fdc10b..8b6599611ffc4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -45,11 +45,16 @@ export const listLoading = (state: Immutable): boolean => state.l export const listError = (state: Immutable) => state.error; -export const detailsData = (state: Immutable) => state.details; +export const detailsData = (state: Immutable) => + state.endpointDetails.hostDetails.details; -export const detailsLoading = (state: Immutable): boolean => state.detailsLoading; +export const detailsLoading = (state: Immutable): boolean => + state.endpointDetails.hostDetails.detailsLoading; -export const detailsError = (state: Immutable) => state.detailsError; +export const detailsError = ( + state: Immutable +): EndpointState['endpointDetails']['hostDetails']['detailsError'] => + state.endpointDetails.hostDetails.detailsError; export const policyItems = (state: Immutable) => state.policyItems; @@ -209,7 +214,12 @@ export const uiQueryParams: ( if (value !== undefined) { if (key === 'show') { - if (value === 'policy_response' || value === 'details' || value === 'isolate') { + if ( + value === 'policy_response' || + value === 'details' || + value === 'activity_log' || + value === 'isolate' + ) { data[key] = value; } } else { @@ -240,6 +250,19 @@ export const showView: ( return searchParams.show ?? 'details'; }); +/** + * Returns the selected endpoint's elastic agent Id + * used for fetching endpoint actions log + */ +export const selectedAgent = (state: Immutable): string => { + const hostList = state.hosts; + const { selected_endpoint: selectedEndpoint } = uiQueryParams(state); + return ( + hostList.find((host) => host.metadata.agent.id === selectedEndpoint)?.metadata.elastic.agent + .id || '' + ); +}; + /** * Returns the Host Status which is connected the fleet agent */ @@ -331,3 +354,27 @@ export const getIsolationRequestError: ( return isolateHost.error; } }); + +export const getActivityLogData = ( + state: Immutable +): Immutable => state.endpointDetails.activityLog; + +export const getActivityLogRequestLoading: ( + state: Immutable +) => boolean = createSelector(getActivityLogData, (activityLog) => + isLoadingResourceState(activityLog) +); + +export const getActivityLogRequestLoaded: ( + state: Immutable +) => boolean = createSelector(getActivityLogData, (activityLog) => + isLoadedResourceState(activityLog) +); + +export const getActivityLogError: ( + state: Immutable +) => ServerApiError | undefined = createSelector(getActivityLogData, (activityLog) => { + if (isFailedResourceState(activityLog)) { + return activityLog.error; + } +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index 74eee0602722b..ac06f98004f59 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -14,6 +14,7 @@ import { PolicyData, MetadataQueryStrategyVersions, HostStatus, + EndpointAction, HostIsolationResponse, } from '../../../../common/endpoint/types'; import { ServerApiError } from '../../../common/types'; @@ -34,12 +35,17 @@ export interface EndpointState { loading: boolean; /** api error from retrieving host list */ error?: ServerApiError; - /** details data for a specific host */ - details?: Immutable; - /** details page is retrieving data */ - detailsLoading: boolean; - /** api error from retrieving host details */ - detailsError?: ServerApiError; + endpointDetails: { + activityLog: AsyncResourceState; + hostDetails: { + /** details data for a specific host */ + details?: Immutable; + /** details page is retrieving data */ + detailsLoading: boolean; + /** api error from retrieving host details */ + detailsError?: ServerApiError; + }; + }; /** Holds the Policy Response for the Host currently being displayed in the details */ policyResponse?: HostPolicyResponse; /** policyResponse is being retrieved */ @@ -108,7 +114,7 @@ export interface EndpointIndexUIQueryParams { /** Which page to show */ page_index?: string; /** show the policy response or host details */ - show?: 'policy_response' | 'details' | 'isolate'; + show?: 'policy_response' | 'activity_log' | 'details' | 'isolate'; /** Query text from search bar*/ admin_query?: string; } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx new file mode 100644 index 0000000000000..3e228be4565b1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; +import { EndpointIndexUIQueryParams } from '../../../types'; +export enum EndpointDetailsTabsTypes { + overview = 'overview', + activityLog = 'activity_log', +} + +export type EndpointDetailsTabsId = + | EndpointDetailsTabsTypes.overview + | EndpointDetailsTabsTypes.activityLog; + +interface EndpointDetailsTabs { + id: string; + name: string; + content: JSX.Element; +} + +const StyledEuiTabbedContent = styled(EuiTabbedContent)` + overflow: hidden; + padding-bottom: ${(props) => props.theme.eui.paddingSizes.xl}; + + > [role='tabpanel'] { + height: 100%; + padding-right: 12px; + overflow: hidden; + overflow-y: auto; + ::-webkit-scrollbar { + -webkit-appearance: none; + width: 4px; + } + ::-webkit-scrollbar-thumb { + border-radius: 2px; + background-color: rgba(0, 0, 0, 0.5); + -webkit-box-shadow: 0 0 1px rgba(255, 255, 255, 0.5); + } + } +`; + +export const EndpointDetailsFlyoutTabs = memo( + ({ show, tabs }: { show: EndpointIndexUIQueryParams['show']; tabs: EndpointDetailsTabs[] }) => { + const [selectedTabId, setSelectedTabId] = useState(() => { + return show === 'details' + ? EndpointDetailsTabsTypes.overview + : EndpointDetailsTabsTypes.activityLog; + }); + + const handleTabClick = useCallback( + (tab: EuiTabbedContentTab) => setSelectedTabId(tab.id as EndpointDetailsTabsId), + [setSelectedTabId] + ); + + const selectedTab = useMemo(() => tabs.find((tab) => tab.id === selectedTabId), [ + tabs, + selectedTabId, + ]); + + return ( + + ); + } +); + +EndpointDetailsFlyoutTabs.displayName = 'EndpointDetailsFlyoutTabs'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx new file mode 100644 index 0000000000000..de6d2ecf36ecc --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; + +import { EuiAvatar, EuiComment, EuiText } from '@elastic/eui'; +import { Immutable, EndpointAction } from '../../../../../../../common/endpoint/types'; +import { FormattedDateAndTime } from '../../../../../../common/components/endpoint/formatted_date_time'; +import { useEuiTheme } from '../../../../../../common/lib/theme/use_eui_theme'; + +export const LogEntry = memo( + ({ endpointAction }: { endpointAction: Immutable }) => { + const euiTheme = useEuiTheme(); + const isIsolated = endpointAction?.data.command === 'isolate'; + + // do this better when we can distinguish between endpoint events vs user events + const iconType = endpointAction.user_id === 'sys' ? 'dot' : isIsolated ? 'lock' : 'lockOpen'; + const commentType = endpointAction.user_id === 'sys' ? 'update' : 'regular'; + const timelineIcon = ( + + ); + const event = `${isIsolated ? 'isolated' : 'unisolated'} host`; + const hasComment = !!endpointAction.data.comment; + + return ( + + {hasComment ? ( + +

{endpointAction.data.comment}

+
+ ) : undefined} +
+ ); + } +); + +LogEntry.displayName = 'LogEntry'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx new file mode 100644 index 0000000000000..50c91730e332c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback } from 'react'; + +import { EuiEmptyPrompt, EuiSpacer } from '@elastic/eui'; +import { LogEntry } from './components/log_entry'; +import * as i18 from '../translations'; +import { SearchBar } from '../../../../components/search_bar'; +import { Immutable, EndpointAction } from '../../../../../../common/endpoint/types'; +import { AsyncResourceState } from '../../../../state'; + +export const EndpointActivityLog = memo( + ({ endpointActions }: { endpointActions: AsyncResourceState> }) => { + // TODO + const onSearch = useCallback(() => {}, []); + return ( + <> + + {endpointActions.type !== 'LoadedResourceState' || !endpointActions.data.length ? ( + {'No logged actions'}

} + body={

{'No actions have been logged for this endpoint.'}

} + /> + ) : ( + <> + + + {endpointActions.data.map((endpointAction) => ( + + ))} + + )} + + ); + } +); + +EndpointActivityLog.displayName = 'EndpointActivityLog'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx index c9db78f425afa..16cae79d42c0f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx @@ -258,6 +258,7 @@ export const EndpointDetails = memo( return ( <> + > => ({ + type: 'LoadedResourceState', + data: [ + { + action_id: '1', + '@timestamp': moment().subtract(1, 'hours').fromNow().toString(), + expiration: moment().add(3, 'day').fromNow().toString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: [`${selectedEndpoint}`], + user_id: 'sys', + data: { + command: 'isolate', + }, + }, + { + action_id: '2', + '@timestamp': moment().subtract(2, 'hours').fromNow().toString(), + expiration: moment().add(1, 'day').fromNow().toString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: [`${selectedEndpoint}`], + user_id: 'ash', + data: { + command: 'isolate', + comment: 'Sem et tortor consequat id porta nibh venenatis cras sed.', + }, + }, + { + action_id: '3', + '@timestamp': moment().subtract(4, 'hours').fromNow().toString(), + expiration: moment().add(1, 'day').fromNow().toString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: [`${selectedEndpoint}`], + user_id: 'someone', + data: { + command: 'unisolate', + comment: 'Turpis egestas pretium aenean pharetra.', + }, + }, + { + action_id: '4', + '@timestamp': moment().subtract(1, 'day').fromNow().toString(), + expiration: moment().add(3, 'day').fromNow().toString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: [`${selectedEndpoint}`], + user_id: 'ash', + data: { + command: 'isolate', + comment: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, \ + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }, + }, + ], +}); + +export default { + title: 'Endpoints/Endpoint Details', + component: EndpointDetailsFlyout, + decorators: [ + (Story: ComponentType) => ( + + + + ), + ], +}; + +export const Tabs = () => ( + {'Endpoint Details'}, + }, + { + id: 'activity_log', + name: 'Activity Log', + content: ActivityLog(), + }, + ]} + /> +); + +export const ActivityLog = () => ( + +); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index 09b1bbceef21d..8d985f3a4cfe2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -5,7 +5,8 @@ * 2.0. */ -import React, { useCallback, useEffect, memo } from 'react'; +import React, { useCallback, useEffect, useMemo, memo } from 'react'; +import styled from 'styled-components'; import { EuiFlyout, EuiFlyoutBody, @@ -16,6 +17,8 @@ import { EuiSpacer, EuiEmptyPrompt, EuiToolTip, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -26,8 +29,11 @@ import { uiQueryParams, detailsData, detailsError, - showView, detailsLoading, + getActivityLogData, + getActivityLogError, + getActivityLogRequestLoading, + showView, policyResponseConfigurations, policyResponseActions, policyResponseFailedOrWarningActionCount, @@ -39,14 +45,36 @@ import { policyResponseAppliedRevision, } from '../../store/selectors'; import { EndpointDetails } from './endpoint_details'; +import { EndpointActivityLog } from './endpoint_activity_log'; import { PolicyResponse } from './policy_response'; +import * as i18 from '../translations'; import { HostMetadata } from '../../../../../../common/endpoint/types'; +import { + EndpointDetailsFlyoutTabs, + EndpointDetailsTabsTypes, +} from './components/endpoint_details_tabs'; + import { PreferenceFormattedDateFromPrimitive } from '../../../../../common/components/formatted_date'; import { EndpointIsolateFlyoutPanel } from './components/endpoint_isolate_flyout_panel'; import { BackToEndpointDetailsFlyoutSubHeader } from './components/back_to_endpoint_details_flyout_subheader'; import { FlyoutBodyNoTopPadding } from './components/flyout_body_no_top_padding'; import { getEndpointListPath } from '../../../../common/routing'; +const DetailsFlyoutBody = styled(EuiFlyoutBody)` + overflow-y: hidden; + flex: 1; + + .euiFlyoutBody__overflow { + overflow: hidden; + mask-image: none; + } + + .euiFlyoutBody__overflowContent { + height: 100%; + display: flex; + } +`; + export const EndpointDetailsFlyout = memo(() => { const history = useHistory(); const toasts = useToasts(); @@ -55,13 +83,51 @@ export const EndpointDetailsFlyout = memo(() => { selected_endpoint: selectedEndpoint, ...queryParamsWithoutSelectedEndpoint } = queryParams; - const details = useEndpointSelector(detailsData); + + const activityLog = useEndpointSelector(getActivityLogData); + const activityLoading = useEndpointSelector(getActivityLogRequestLoading); + const activityError = useEndpointSelector(getActivityLogError); + const hostDetails = useEndpointSelector(detailsData); + const hostDetailsLoading = useEndpointSelector(detailsLoading); + const hostDetailsError = useEndpointSelector(detailsError); + const policyInfo = useEndpointSelector(policyVersionInfo); const hostStatus = useEndpointSelector(hostStatusInfo); - const loading = useEndpointSelector(detailsLoading); - const error = useEndpointSelector(detailsError); const show = useEndpointSelector(showView); + const ContentLoadingMarkup = useMemo( + () => ( + <> + + + + + ), + [] + ); + + const tabs = [ + { + id: EndpointDetailsTabsTypes.overview, + name: i18.OVERVIEW, + content: + hostDetails === undefined ? ( + ContentLoadingMarkup + ) : ( + + ), + }, + { + id: EndpointDetailsTabsTypes.activityLog, + name: i18.ACTIVITY_LOG, + content: activityLoading ? ( + ContentLoadingMarkup + ) : ( + + ), + }, + ]; + const handleFlyoutClose = useCallback(() => { const { show: _show, ...urlSearchParams } = queryParamsWithoutSelectedEndpoint; history.push( @@ -73,7 +139,7 @@ export const EndpointDetailsFlyout = memo(() => { }, [history, queryParamsWithoutSelectedEndpoint]); useEffect(() => { - if (error !== undefined) { + if (hostDetailsError !== undefined) { toasts.addDanger({ title: i18n.translate('xpack.securitySolution.endpoint.details.errorTitle', { defaultMessage: 'Could not find host', @@ -83,7 +149,17 @@ export const EndpointDetailsFlyout = memo(() => { }), }); } - }, [error, toasts]); + if (activityError !== undefined) { + toasts.addDanger({ + title: i18n.translate('xpack.securitySolution.endpoint.activityLog.errorTitle', { + defaultMessage: 'Could not find activity log for host', + }), + text: i18n.translate('xpack.securitySolution.endpoint.activityLog.errorBody', { + defaultMessage: 'Please exit the flyout and select another host with actions.', + }), + }); + } + }, [hostDetailsError, activityError, toasts]); return ( { style={{ zIndex: 4001 }} data-test-subj="endpointDetailsFlyout" size="m" + paddingSize="m" > - {loading ? ( + {hostDetailsLoading || activityLoading ? ( ) : ( - +

- {details?.host?.hostname} + {hostDetails?.host?.hostname}

)}
- {details === undefined ? ( + {hostDetails === undefined ? ( ) : ( <> - {show === 'details' && ( - - - + {(show === 'details' || show === 'activity_log') && ( + + + + + + + )} - {show === 'policy_response' && } + {show === 'policy_response' && } - {show === 'isolate' && } + {show === 'isolate' && } )}
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts new file mode 100644 index 0000000000000..fd2806713183b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const OVERVIEW = i18n.translate('xpack.securitySolution.endpointDetails.overview', { + defaultMessage: 'Overview', +}); + +export const ACTIVITY_LOG = i18n.translate('xpack.securitySolution.endpointDetails.activityLog', { + defaultMessage: 'Activity Log', +}); + +export const SEARCH_ACTIVITY_LOG = i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.search', + { + defaultMessage: 'Search activity log', + } +); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts index 5f572251daeda..01bccc81b5063 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts @@ -30,7 +30,7 @@ import { GetOneTrustedAppResponse, } from '../../../../../common/endpoint/types/trusted_apps'; -import { resolvePathVariables } from './utils'; +import { resolvePathVariables } from '../../../common/utils'; import { sendGetEndpointSpecificPackagePolicies } from '../../policy/store/services/ingest'; export interface TrustedAppsService { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts deleted file mode 100644 index c2067f9d0848f..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts +++ /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 { resolvePathVariables } from './utils'; - -describe('utils', () => { - describe('resolvePathVariables', () => { - it('should resolve defined variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( - '/segment1/value1/segment2' - ); - }); - - it('should not resolve undefined variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( - '/segment1/{var1}/segment2' - ); - }); - - it('should ignore unused variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( - '/segment1/{var1}/segment2' - ); - }); - - it('should replace multiple variable occurences', () => { - expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( - '/value1/segment1/value1' - ); - }); - - it('should replace multiple variables', () => { - const path = resolvePathVariables('/{var1}/segment1/{var2}', { - var1: 'value1', - var2: 'value2', - }); - - expect(path).toBe('/value1/segment1/value2'); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts deleted file mode 100644 index 89067e575665d..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => - Object.keys(variables).reduce((acc, paramName) => { - return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); - }, path); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index 3f02d505daea1..dc0032243312f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -31,7 +31,7 @@ import { import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { isFailedResourceState, isLoadedResourceState } from '../state'; import { forceHTMLElementOffsetWidth } from './components/effected_policy_select/test_utils'; -import { resolvePathVariables } from '../service/utils'; +import { resolvePathVariables } from '../../../common/utils'; import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; diff --git a/x-pack/plugins/security_solution/public/management/store/reducer.ts b/x-pack/plugins/security_solution/public/management/store/reducer.ts index bf8cd416a3e39..25c7c87c6f5c9 100644 --- a/x-pack/plugins/security_solution/public/management/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/store/reducer.ts @@ -19,14 +19,12 @@ import { import { ImmutableCombineReducers } from '../../common/store'; import { Immutable } from '../../../common/endpoint/types'; import { ManagementState } from '../types'; -import { - endpointListReducer, - initialEndpointListState, -} from '../pages/endpoint_hosts/store/reducer'; +import { endpointListReducer } from '../pages/endpoint_hosts/store/reducer'; import { initialTrustedAppsPageState } from '../pages/trusted_apps/store/builders'; import { trustedAppsPageReducer } from '../pages/trusted_apps/store/reducer'; import { initialEventFiltersPageState } from '../pages/event_filters/store/builders'; import { eventFiltersPageReducer } from '../pages/event_filters/store/reducer'; +import { initialEndpointPageState } from '../pages/endpoint_hosts/store/builders'; const immutableCombineReducers: ImmutableCombineReducers = combineReducers; @@ -35,7 +33,7 @@ const immutableCombineReducers: ImmutableCombineReducers = combineReducers; */ export const mockManagementState: Immutable = { [MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]: initialPolicyDetailsState(), - [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: initialEndpointListState, + [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: initialEndpointPageState(), [MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE]: initialTrustedAppsPageState(), [MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE]: initialEventFiltersPageState(), }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.ts new file mode 100644 index 0000000000000..487ee16558fec --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ENDPOINT_ACTION_LOG_ROUTE } from '../../../../common/endpoint/constants'; +import { EndpointActionLogRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { actionsLogRequestHandler } from './audit_log_handler'; + +import { SecuritySolutionPluginRouter } from '../../../types'; +import { EndpointAppContext } from '../../types'; + +/** + * Registers the endpoint activity_log route + */ +export function registerActionAuditLogRoutes( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext +) { + router.get( + { + path: ENDPOINT_ACTION_LOG_ROUTE, + validate: EndpointActionLogRequestSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + actionsLogRequestHandler(endpointContext) + ); +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log_handler.ts new file mode 100644 index 0000000000000..fdbb9608463e9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log_handler.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TypeOf } from '@kbn/config-schema'; +import { RequestHandler } from 'kibana/server'; +import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common'; +import { EndpointActionLogRequestSchema } from '../../../../common/endpoint/schema/actions'; + +import { SecuritySolutionRequestHandlerContext } from '../../../types'; +import { EndpointAppContext } from '../../types'; + +export const actionsLogRequestHandler = ( + endpointContext: EndpointAppContext +): RequestHandler< + TypeOf, + unknown, + unknown, + SecuritySolutionRequestHandlerContext +> => { + const logger = endpointContext.logFactory.get('audit_log'); + return async (context, req, res) => { + const esClient = context.core.elasticsearch.client.asCurrentUser; + let result; + try { + result = await esClient.search({ + index: AGENT_ACTIONS_INDEX, + body: { + query: { + match: { + agents: req.params.agent_id, + }, + }, + sort: [ + { + '@timestamp': { + order: 'desc', + }, + }, + ], + }, + }); + } catch (error) { + logger.error(error); + throw error; + } + if (result?.statusCode !== 200) { + logger.error(`Error fetching actions log for agent_id ${req.params.agent_id}`); + throw new Error(`Error fetching actions log for agent_id ${req.params.agent_id}`); + } + + return res.ok({ + body: result.body.hits.hits.map((e) => e._source), + }); + }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts index 9dec4fb2cbb79..e95a33253034d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts @@ -6,3 +6,4 @@ */ export * from './isolation'; +export * from './audit_log'; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 2507475592e88..732ae48223421 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -75,7 +75,10 @@ import { registerEndpointRoutes } from './endpoint/routes/metadata'; import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency'; import { registerResolverRoutes } from './endpoint/routes/resolver'; import { registerPolicyRoutes } from './endpoint/routes/policy'; -import { registerHostIsolationRoutes } from './endpoint/routes/actions'; +import { + registerHostIsolationRoutes, + registerActionAuditLogRoutes, +} from './endpoint/routes/actions'; import { EndpointArtifactClient, ManifestManager } from './endpoint/services'; import { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; import { EndpointAppContext } from './endpoint/types'; @@ -291,6 +294,7 @@ export class Plugin implements IPlugin Date: Thu, 3 Jun 2021 10:03:08 +0200 Subject: [PATCH 13/35] Link and formatting fix (#101243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: ✏️ make "Risk Matrix" link absolute The page can be rendered in different environments, so we should have this link absolute so that it always works, for example, it would not work in PRs themselves. * docs: ✏️ remove formatting breaks In PRs Markdown formatting breaks are rendered incorrectly, so we remove them. --- .github/PULL_REQUEST_TEMPLATE.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 336f7e5165d07..726e4257a5aac 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -21,18 +21,16 @@ Delete any items that are not applicable to this PR. Delete this section if it is not applicable to this PR. -Before closing this PR, invite QA, stakeholders, and other developers to -identify risks that should be tested prior to the change/feature release. +Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. -When forming the risk matrix, consider some of the following examples and how -they may potentially impact the change: +When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | -| [See more potential risk examples](../RISK_MATRIX.mdx) | +| [See more potential risk examples](https://github.com/elastic/kibana/blob/master/RISK_MATRIX.mdx) | ### For maintainers From 6f106e468747f03ba8f5b94e615dc65342251b49 Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Thu, 3 Jun 2021 11:49:00 +0300 Subject: [PATCH 14/35] Gauge/goal: Tooltip always includes "_all" (#101064) * Don't show _all for goal and gauge in tooltip * add unit test --- .../tooltip/_pointseries_tooltip_formatter.js | 4 +++- .../_pointseries_tooltip_formatter.test.js | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.js b/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.js index cb8a8f72c5172..5e1f0bfbb4464 100644 --- a/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.js +++ b/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.js @@ -31,6 +31,7 @@ export function pointSeriesTooltipFormatter() { const details = []; const isGauge = config.get('gauge', false); + const chartType = config.get('type', undefined); const isPercentageMode = config.get(isGauge ? 'gauge.percentageMode' : 'percentageMode', false); const isSetColorRange = config.get('setColorRange', false); @@ -44,7 +45,8 @@ export function pointSeriesTooltipFormatter() { }); } - if (datum.x !== null && datum.x !== undefined) { + // For goal and gauge we have only one value for x - '_all'. It doesn't have sense to show it + if (datum.x !== null && datum.x !== undefined && !['goal', 'gauge'].includes(chartType)) { addDetail(data.xAxisLabel, data.xAxisFormatter(datum.x)); } diff --git a/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.test.js b/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.test.js index 5c0548ea399b7..a207b1f4360b6 100644 --- a/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.test.js +++ b/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.test.js @@ -96,4 +96,27 @@ describe('tooltipFormatter', function () { const $rows = $el.find('tr'); expect($rows.length).toBe(3); }); + + it('renders correctly for gauge/goal visualizations', function () { + const event = _.cloneDeep(baseEvent); + let type = 'gauge'; + event.config.get = (name) => { + const config = { + setColorRange: false, + gauge: false, + percentageMode: false, + type, + }; + return config[name]; + }; + + let $el = $(tooltipFormatter(event, uiSettings)); + let $rows = $el.find('tr'); + expect($rows.length).toBe(2); + + type = 'goal'; + $el = $(tooltipFormatter(event, uiSettings)); + $rows = $el.find('tr'); + expect($rows.length).toBe(2); + }); }); From 8097f3646586dbd49028b75f0ffa31ffe557726a Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Thu, 3 Jun 2021 12:28:09 +0300 Subject: [PATCH 15/35] attempt at tree shaking (#101147) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-ui-shared-deps/entry.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kbn-ui-shared-deps/entry.js b/packages/kbn-ui-shared-deps/entry.js index d3755ed7c5f29..b8d21a473c65f 100644 --- a/packages/kbn-ui-shared-deps/entry.js +++ b/packages/kbn-ui-shared-deps/entry.js @@ -44,7 +44,8 @@ export const Theme = require('./theme.ts'); export const Lodash = require('lodash'); export const LodashFp = require('lodash/fp'); -export const Fflate = require('fflate/esm/browser'); +import { unzlibSync, strFromU8 } from 'fflate'; +export const Fflate = { unzlibSync, strFromU8 }; // runtime deps which don't need to be copied across all bundles export const TsLib = require('tslib'); From 83b47c1bc81e9dd5f49b2f093a902f49455a6134 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Thu, 3 Jun 2021 12:37:45 +0300 Subject: [PATCH 16/35] [TSVB] Math params._interval is incorrect when using entire timerange mode (#100775) * [TSVB] Math params._interval is incorrect when using entire timerange mode Closes: #100615 * fix jest * rename get -> overwrite * apply fix for "bucket script" * Update date_histogram.js Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../lib/vis_data/helpers/bucket_transform.js | 19 ++++++--- .../series/date_histogram.js | 35 +++++++++------- .../series/date_histogram.test.js | 42 +++++++++++++++---- .../series/metric_buckets.js | 21 ++-------- .../series/sibling_buckets.js | 23 +++------- .../table/date_histogram.js | 25 ++++++----- .../table/metric_buckets.js | 12 ++---- .../table/sibling_buckets.js | 14 +++---- .../response_processors/series/math.js | 6 ++- .../response_processors/series/math.test.js | 21 +++++++++- .../series/build_request_body.test.ts | 1 - 11 files changed, 124 insertions(+), 95 deletions(-) diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/bucket_transform.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/bucket_transform.js index 2877373ffba9a..16e7b9d6072cb 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/bucket_transform.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/bucket_transform.js @@ -7,11 +7,11 @@ */ import { getBucketsPath } from './get_buckets_path'; -import { parseInterval } from './parse_interval'; import { set } from '@elastic/safer-lodash-set'; import { isEmpty } from 'lodash'; import { i18n } from '@kbn/i18n'; import { MODEL_SCRIPTS } from './moving_fn_scripts'; +import { convertIntervalToUnit } from './unit_to_seconds'; function checkMetric(metric, fields) { fields.forEach((field) => { @@ -161,19 +161,24 @@ export const bucketTransform = { }; }, - derivative: (bucket, metrics, bucketSize) => { + derivative: (bucket, metrics, intervalString) => { checkMetric(bucket, ['type', 'field']); + const body = { derivative: { buckets_path: getBucketsPath(bucket.field, metrics), gap_policy: 'skip', // seems sane - unit: bucketSize, + unit: intervalString, }, }; + if (bucket.gap_policy) body.derivative.gap_policy = bucket.gap_policy; if (bucket.unit) { - body.derivative.unit = /^([\d]+)([shmdwMy]|ms)$/.test(bucket.unit) ? bucket.unit : bucketSize; + body.derivative.unit = /^([\d]+)([shmdwMy]|ms)$/.test(bucket.unit) + ? bucket.unit + : intervalString; } + return body; }, @@ -214,8 +219,10 @@ export const bucketTransform = { }; }, - calculation: (bucket, metrics, bucketSize) => { + calculation: (bucket, metrics, intervalString) => { checkMetric(bucket, ['variables', 'script']); + const inMsInterval = convertIntervalToUnit(intervalString, 'ms'); + const body = { bucket_script: { buckets_path: bucket.variables.reduce((acc, row) => { @@ -226,7 +233,7 @@ export const bucketTransform = { source: bucket.script, lang: 'painless', params: { - _interval: parseInterval(bucketSize).asMilliseconds(), + _interval: inMsInterval?.value, }, }, gap_policy: 'skip', // seems sane diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js index f82f332df19fd..253612c0274ad 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js @@ -29,17 +29,20 @@ export function dateHistogram( const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); const { timeField, interval, maxBars } = getIntervalAndTimefield(panel, series, seriesIndex); - const { bucketSize, intervalString } = getBucketSize( - req, - interval, - capabilities, - maxBars ? Math.min(maxBarsUiSettings, maxBars) : barTargetUiSettings - ); + const { from, to } = offsetTime(req, series.offset_time); - const getDateHistogramForLastBucketMode = () => { - const { from, to } = offsetTime(req, series.offset_time); + let bucketInterval; + + const overwriteDateHistogramForLastBucketMode = () => { const { timezone } = capabilities; + const { intervalString } = getBucketSize( + req, + interval, + capabilities, + maxBars ? Math.min(maxBarsUiSettings, maxBars) : barTargetUiSettings + ); + overwrite(doc, `aggs.${series.id}.aggs.timeseries.date_histogram`, { field: timeField, min_doc_count: 0, @@ -50,25 +53,29 @@ export function dateHistogram( }, ...dateHistogramInterval(intervalString), }); + + bucketInterval = intervalString; }; - const getDateHistogramForEntireTimerangeMode = () => + const overwriteDateHistogramForEntireTimerangeMode = () => { overwrite(doc, `aggs.${series.id}.aggs.timeseries.auto_date_histogram`, { field: timeField, buckets: 1, }); + bucketInterval = `${to.valueOf() - from.valueOf()}ms`; + }; + isLastValueTimerangeMode(panel, series) - ? getDateHistogramForLastBucketMode() - : getDateHistogramForEntireTimerangeMode(); + ? overwriteDateHistogramForLastBucketMode() + : overwriteDateHistogramForEntireTimerangeMode(); overwrite(doc, `aggs.${series.id}.meta`, { timeField, - intervalString, - bucketSize, + panelId: panel.id, seriesId: series.id, + intervalString: bucketInterval, index: panel.use_kibana_indexes ? seriesIndex.indexPattern?.id : undefined, - panelId: panel.id, }); return next(doc); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js index 741eb93267f4c..2cd7a213b273e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js @@ -86,7 +86,6 @@ describe('dateHistogram(req, panel, series)', () => { }, }, meta: { - bucketSize: 10, intervalString: '10s', timeField: '@timestamp', seriesId: 'test', @@ -128,7 +127,6 @@ describe('dateHistogram(req, panel, series)', () => { }, }, meta: { - bucketSize: 10, intervalString: '10s', timeField: '@timestamp', seriesId: 'test', @@ -173,7 +171,6 @@ describe('dateHistogram(req, panel, series)', () => { }, }, meta: { - bucketSize: 20, intervalString: '20s', timeField: 'timestamp', seriesId: 'test', @@ -185,8 +182,11 @@ describe('dateHistogram(req, panel, series)', () => { }); describe('dateHistogram for entire time range mode', () => { - test('should ignore entire range mode for timeseries', async () => { + beforeEach(() => { panel.time_range_mode = 'entire_time_range'; + }); + + test('should ignore entire range mode for timeseries', async () => { panel.type = 'timeseries'; const next = (doc) => doc; @@ -204,9 +204,36 @@ describe('dateHistogram(req, panel, series)', () => { expect(doc.aggs.test.aggs.timeseries.date_histogram).toBeDefined(); }); - test('should returns valid date histogram for entire range mode', async () => { - panel.time_range_mode = 'entire_time_range'; + test('should set meta values', async () => { + // set 15 minutes (=== 900000ms) interval; + req.body.timerange = { + min: '2021-01-01T00:00:00Z', + max: '2021-01-01T00:15:00Z', + }; + + const next = (doc) => doc; + const doc = await dateHistogram( + req, + panel, + series, + config, + indexPattern, + capabilities, + uiSettings + )(next)({}); + expect(doc.aggs.test.meta).toMatchInlineSnapshot(` + Object { + "index": undefined, + "intervalString": "900000ms", + "panelId": "panelId", + "seriesId": "test", + "timeField": "@timestamp", + } + `); + }); + + test('should returns valid date histogram for entire range mode', async () => { const next = (doc) => doc; const doc = await dateHistogram( req, @@ -232,8 +259,7 @@ describe('dateHistogram(req, panel, series)', () => { meta: { timeField: '@timestamp', seriesId: 'test', - bucketSize: 10, - intervalString: '10s', + intervalString: '3600000ms', panelId: 'panelId', }, }, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js index 29a11bf163e0b..33c6622f73941 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js @@ -7,30 +7,17 @@ */ import { overwrite } from '../../helpers'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; -import { UI_SETTINGS } from '../../../../../../data/common'; +import { get } from 'lodash'; -export function metricBuckets( - req, - panel, - series, - esQueryConfig, - seriesIndex, - capabilities, - uiSettings -) { +export function metricBuckets(req, panel, series) { return (next) => async (doc) => { - const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - - const { interval } = getIntervalAndTimefield(panel, series, seriesIndex); - const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); - series.metrics .filter((row) => !/_bucket$/.test(row.type) && !/^series/.test(row.type)) .forEach((metric) => { const fn = bucketTransform[metric.type]; + const intervalString = get(doc, `aggs.${series.id}.meta.intervalString`); + if (fn) { try { const bucket = fn(metric, series.metrics, intervalString); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js index dbeb3b1393bd5..c3075dd6dcac0 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js @@ -6,39 +6,28 @@ * Side Public License, v 1. */ +import { get } from 'lodash'; import { overwrite } from '../../helpers'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; -import { UI_SETTINGS } from '../../../../../../data/common'; -export function siblingBuckets( - req, - panel, - series, - esQueryConfig, - seriesIndex, - capabilities, - uiSettings -) { +export function siblingBuckets(req, panel, series) { return (next) => async (doc) => { - const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, series, seriesIndex); - const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); - series.metrics .filter((row) => /_bucket$/.test(row.type)) .forEach((metric) => { const fn = bucketTransform[metric.type]; + const intervalString = get(doc, `aggs.${series.id}.meta.intervalString`); + if (fn) { try { - const bucket = fn(metric, series.metrics, bucketSize); + const bucket = fn(metric, series.metrics, intervalString); overwrite(doc, `aggs.${series.id}.aggs.${metric.id}`, bucket); } catch (e) { // meh } } }); + return next(doc); }; } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js index 3e883abc9e5e0..92ac4078a3835 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js @@ -20,6 +20,7 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); const { timeField, interval } = getIntervalAndTimefield(panel, {}, seriesIndex); + const { from, to } = getTimerange(req); const meta = { timeField, @@ -27,14 +28,8 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti panelId: panel.id, }; - const getDateHistogramForLastBucketMode = () => { - const { bucketSize, intervalString } = getBucketSize( - req, - interval, - capabilities, - barTargetUiSettings - ); - const { from, to } = getTimerange(req); + const overwriteDateHistogramForLastBucketMode = () => { + const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); const { timezone } = capabilities; panel.series.forEach((column) => { @@ -54,12 +49,13 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti overwrite(doc, aggRoot.replace(/\.aggs$/, '.meta'), { ...meta, intervalString, - bucketSize, }); }); }; - const getDateHistogramForEntireTimerangeMode = () => { + const overwriteDateHistogramForEntireTimerangeMode = () => { + const intervalString = `${to.valueOf() - from.valueOf()}ms`; + panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); @@ -68,13 +64,16 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti buckets: 1, }); - overwrite(doc, aggRoot.replace(/\.aggs$/, '.meta'), meta); + overwrite(doc, aggRoot.replace(/\.aggs$/, '.meta'), { + ...meta, + intervalString, + }); }); }; isLastValueTimerangeMode(panel) - ? getDateHistogramForLastBucketMode() - : getDateHistogramForEntireTimerangeMode(); + ? overwriteDateHistogramForLastBucketMode() + : overwriteDateHistogramForEntireTimerangeMode(); return next(doc); }; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js index 421f9d2d75f0c..8e0d0060225ff 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js @@ -6,19 +6,13 @@ * Side Public License, v 1. */ +import { get } from 'lodash'; import { overwrite } from '../../helpers'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; -import { UI_SETTINGS } from '../../../../../../data/common'; -export function metricBuckets(req, panel, esQueryConfig, seriesIndex, capabilities, uiSettings) { +export function metricBuckets(req, panel) { return (next) => async (doc) => { - const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, seriesIndex); - const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); - panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); column.metrics @@ -27,7 +21,9 @@ export function metricBuckets(req, panel, esQueryConfig, seriesIndex, capabiliti const fn = bucketTransform[metric.type]; if (fn) { try { + const intervalString = get(doc, aggRoot.replace(/\.aggs$/, '.meta.intervalString')); const bucket = fn(metric, column.metrics, intervalString); + overwrite(doc, `${aggRoot}.timeseries.aggs.${metric.id}`, bucket); } catch (e) { // meh diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js index 9b4b0f244fc2c..6ce956c490900 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js @@ -7,18 +7,12 @@ */ import { overwrite } from '../../helpers'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; -import { UI_SETTINGS } from '../../../../../../data/common'; +import { get } from 'lodash'; -export function siblingBuckets(req, panel, esQueryConfig, seriesIndex, capabilities, uiSettings) { +export function siblingBuckets(req, panel) { return (next) => async (doc) => { - const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, seriesIndex); - const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); - panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); column.metrics @@ -27,7 +21,9 @@ export function siblingBuckets(req, panel, esQueryConfig, seriesIndex, capabilit const fn = bucketTransform[metric.type]; if (fn) { try { - const bucket = fn(metric, column.metrics, bucketSize); + const intervalString = get(doc, aggRoot.replace(/\.aggs$/, '.meta.intervalString')); + const bucket = fn(metric, column.metrics, intervalString); + overwrite(doc, `${aggRoot}.${metric.id}`, bucket); } catch (e) { // meh diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js index 403b486cc4d09..d3cff76524ee3 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import { convertIntervalToUnit } from '../../helpers/unit_to_seconds'; + const percentileValueMatch = /\[([0-9\.]+)\]$/; import { startsWith, flatten, values, first, last } from 'lodash'; import { getDefaultDecoration } from '../../helpers/get_default_decoration'; @@ -82,13 +84,15 @@ export function mathAgg(resp, panel, series, meta, extractFields) { if (someNull) return [ts, null]; try { // calculate the result based on the user's script and return the value + const inMsInterval = convertIntervalToUnit(split.meta?.intervalString || 0, 'ms'); + const result = evaluate(mathMetric.script, { params: { ...params, _index: index, _timestamp: ts, _all: all, - _interval: split.meta.bucketSize * 1000, + _interval: inMsInterval?.value, }, }); // if the result is an object (usually when the user is working with maps and functions) flatten the results and return the last value. diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.test.js index 1e30720d6e5b2..7b5eb1e029069 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.test.js @@ -54,7 +54,7 @@ describe('math(resp, panel, series)', () => { aggregations: { test: { meta: { - bucketSize: 5, + intervalString: '5s', }, buckets: [ { @@ -124,6 +124,25 @@ describe('math(resp, panel, series)', () => { ); }); + test('should works with predefined variables (params._interval)', async () => { + const expectedInterval = 5000; + + series.metrics[2].script = 'params._interval'; + + const next = await mathAgg(resp, panel, series)((results) => results); + const results = await stdMetric(resp, panel, series)(next)([]); + + expect(results).toHaveLength(1); + expect(results[0]).toEqual( + expect.objectContaining({ + data: [ + [1, expectedInterval], + [2, expectedInterval], + ], + }) + ); + }); + test('throws on actual tinymath expression errors #1', async () => { series.metrics[2].script = 'notExistingFn(params.a)'; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts index 5b865d451003a..46acbb27e15e1 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts @@ -153,7 +153,6 @@ describe('buildRequestBody(req)', () => { time_zone: 'UTC', }, meta: { - bucketSize: 10, intervalString: '10s', seriesId: 'c9b5f9c0-e403-11e6-be91-6f7688e9fac7', timeField: '@timestamp', From 4e5652d05a0f2b3c0589276bb8ad79ebc7f5ccc4 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Thu, 3 Jun 2021 12:54:43 +0200 Subject: [PATCH 17/35] [Lens] setFocusTrap after animation is ended and not with timeout (#101148) --- .../config_panel/dimension_container.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx index a8d610f2740de..b14d391c2c969 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx @@ -44,15 +44,6 @@ export function DimensionContainer({ setFocusTrapIsEnabled(false); }, [handleClose]); - useEffect(() => { - if (isOpen) { - // without setTimeout here the flyout pushes content when animating - setTimeout(() => { - setFocusTrapIsEnabled(true); - }, 255); - } - }, [isOpen]); - const closeOnEscape = useCallback( (event: KeyboardEvent) => { if (event.key === keys.ESCAPE) { @@ -83,6 +74,13 @@ export function DimensionContainer({ role="dialog" aria-labelledby="lnsDimensionContainerTitle" className="lnsDimensionContainer euiFlyout" + onAnimationEnd={() => { + if (isOpen) { + // EuiFocusTrap interferes with animating elements with absolute position: + // running this onAnimationEnd, otherwise the flyout pushes content when animating + setFocusTrapIsEnabled(true); + } + }} > Date: Thu, 3 Jun 2021 14:06:57 +0200 Subject: [PATCH 18/35] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20connect=20dasdhboa?= =?UTF-8?q?rd=20telemetry=20to=20persistable=20state=20(#99498)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 connect dasdhboard telemetry to persistable state * fix: 🐛 do not mutate .telemetry() stats objects * feat: 🎸 populate stats object with embeddable telemetry * feat: 🎸 embeddable telemetry schema * feat: 🎸 update telemetry schema * feat: 🎸 add descriptions to dashboard collector * chore: 🤖 update telemetry schema Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/usage/dashboard_telemetry.ts | 22 +++++++++++++++++++ .../server/usage/register_collector.ts | 17 ++++++++++++++ src/plugins/embeddable/public/plugin.tsx | 2 +- src/plugins/embeddable/server/plugin.ts | 4 ++-- src/plugins/telemetry/schema/oss_plugins.json | 20 +++++++++++++++-- .../public/dynamic_actions/action_factory.ts | 2 +- .../ui_actions_enhanced/server/plugin.ts | 2 +- .../telemetry/dynamic_actions_collector.ts | 3 ++- 8 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/plugins/dashboard/server/usage/dashboard_telemetry.ts b/src/plugins/dashboard/server/usage/dashboard_telemetry.ts index 02d492de4fe66..912dc04d16d09 100644 --- a/src/plugins/dashboard/server/usage/dashboard_telemetry.ts +++ b/src/plugins/dashboard/server/usage/dashboard_telemetry.ts @@ -41,6 +41,9 @@ export interface DashboardCollectorData { visualizationByValue: { [key: string]: number; }; + embeddable: { + [key: string]: number; + }; } export const getEmptyTelemetryData = (): DashboardCollectorData => ({ @@ -48,6 +51,7 @@ export const getEmptyTelemetryData = (): DashboardCollectorData => ({ panelsByValue: 0, lensByValue: {}, visualizationByValue: {}, + embeddable: {}, }); type DashboardCollectorFunction = ( @@ -115,6 +119,23 @@ export const collectForPanels: DashboardCollectorFunction = (panels, collectorDa collectByValueLensInfo(panels, collectorData); }; +export const collectEmbeddableData = ( + panels: SavedDashboardPanel730ToLatest[], + collectorData: DashboardCollectorData, + embeddableService: EmbeddablePersistableStateService +) => { + for (const panel of panels) { + collectorData.embeddable = embeddableService.telemetry( + { + ...panel.embeddableConfig, + id: panel.id || '', + type: panel.type, + }, + collectorData.embeddable + ); + } +}; + export async function collectDashboardTelemetry( savedObjectClient: Pick, embeddableService: EmbeddablePersistableStateService @@ -134,6 +155,7 @@ export async function collectDashboardTelemetry( ) as unknown) as SavedDashboardPanel730ToLatest[]; collectForPanels(panels, collectorData); + collectEmbeddableData(panels, collectorData, embeddableService); } return collectorData; diff --git a/src/plugins/dashboard/server/usage/register_collector.ts b/src/plugins/dashboard/server/usage/register_collector.ts index 780dd716c0f78..a911fc9b81666 100644 --- a/src/plugins/dashboard/server/usage/register_collector.ts +++ b/src/plugins/dashboard/server/usage/register_collector.ts @@ -27,11 +27,28 @@ export function registerDashboardUsageCollector( lensByValue: { DYNAMIC_KEY: { type: 'long', + _meta: { + description: + 'Collection of telemetry metrics for Lens visualizations, which are added to dashboard by "value".', + }, }, }, visualizationByValue: { DYNAMIC_KEY: { type: 'long', + _meta: { + description: + 'Collection of telemetry metrics for visualizations, which are added to dashboard by "value".', + }, + }, + }, + embeddable: { + DYNAMIC_KEY: { + type: 'long', + _meta: { + description: + 'Collection of telemetry metrics that embeddable service reports. Embeddable service internally calls each embeddable, which in turn calls its dynamic actions, which calls each drill down attached to that embeddable.', + }, }, }, }, diff --git a/src/plugins/embeddable/public/plugin.tsx b/src/plugins/embeddable/public/plugin.tsx index 3e1a19711d0ff..4ddef89727ef1 100644 --- a/src/plugins/embeddable/public/plugin.tsx +++ b/src/plugins/embeddable/public/plugin.tsx @@ -236,7 +236,7 @@ export class EmbeddablePublicPlugin implements Plugin ({}), + telemetry: (state, stats) => stats, inject: identity, extract: (state: SerializableState) => { return { state, references: [] }; diff --git a/src/plugins/embeddable/server/plugin.ts b/src/plugins/embeddable/server/plugin.ts index f4728bf575a06..788f51adc327b 100644 --- a/src/plugins/embeddable/server/plugin.ts +++ b/src/plugins/embeddable/server/plugin.ts @@ -90,7 +90,7 @@ export class EmbeddableServerPlugin implements Plugin ({}), + telemetry: (state, stats) => stats, inject: identity, extract: (state: SerializableState) => { return { state, references: [] }; @@ -119,7 +119,7 @@ export class EmbeddableServerPlugin implements Plugin ({}), + telemetry: (state, stats) => stats, inject: (state: EmbeddableStateWithType) => state, extract: (state: EmbeddableStateWithType) => { return { state, references: [] }; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 0ca1b863f91a7..1d37c25f52fd4 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -11,14 +11,30 @@ "lensByValue": { "properties": { "DYNAMIC_KEY": { - "type": "long" + "type": "long", + "_meta": { + "description": "Collection of telemetry metrics for Lens visualizations, which are added to dashboard by \"value\"." + } } } }, "visualizationByValue": { "properties": { "DYNAMIC_KEY": { - "type": "long" + "type": "long", + "_meta": { + "description": "Collection of telemetry metrics for visualizations, which are added to dashboard by \"value\"." + } + } + } + }, + "embeddable": { + "properties": { + "DYNAMIC_KEY": { + "type": "long", + "_meta": { + "description": "Collection of telemetry metrics that embeddable service reports. Embeddable service internally calls each embeddable, which in turn calls its dynamic actions, which calls each drill down attached to that embeddable." + } } } } diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts index bd5dc5794cb59..93c1b33268bf4 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts @@ -123,7 +123,7 @@ export class ActionFactory< } public telemetry(state: SerializedEvent, telemetryData: Record) { - return this.def.telemetry ? this.def.telemetry(state, telemetryData) : {}; + return this.def.telemetry ? this.def.telemetry(state, telemetryData) : telemetryData; } public extract(state: SerializedEvent) { diff --git a/x-pack/plugins/ui_actions_enhanced/server/plugin.ts b/x-pack/plugins/ui_actions_enhanced/server/plugin.ts index 245892e854df2..3faa5ce6aa3ef 100644 --- a/x-pack/plugins/ui_actions_enhanced/server/plugin.ts +++ b/x-pack/plugins/ui_actions_enhanced/server/plugin.ts @@ -52,7 +52,7 @@ export class AdvancedUiActionsServerPlugin this.actionFactories.set(definition.id, { id: definition.id, - telemetry: definition.telemetry || (() => ({})), + telemetry: definition.telemetry || ((state, stats) => stats), inject: definition.inject || identity, extract: definition.extract || diff --git a/x-pack/plugins/ui_actions_enhanced/server/telemetry/dynamic_actions_collector.ts b/x-pack/plugins/ui_actions_enhanced/server/telemetry/dynamic_actions_collector.ts index 15cb40ee62068..c89d93f5f5e28 100644 --- a/x-pack/plugins/ui_actions_enhanced/server/telemetry/dynamic_actions_collector.ts +++ b/x-pack/plugins/ui_actions_enhanced/server/telemetry/dynamic_actions_collector.ts @@ -10,8 +10,9 @@ import { getMetricKey } from './get_metric_key'; export const dynamicActionsCollector = ( state: DynamicActionsState, - stats: Record + currentStats: Record ): Record => { + const stats: Record = { ...currentStats }; const countMetricKey = getMetricKey('count'); stats[countMetricKey] = state.events.length + (stats[countMetricKey] || 0); From c30dc1e08f43299f0d516fb0cbd321abad2743dd Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Thu, 3 Jun 2021 15:15:29 +0300 Subject: [PATCH 19/35] use fake timers to avoid flakiness (#101254) --- .../create_streaming_batched_function.test.ts | 78 ++++++++++--------- 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts index 458b691573e56..719bddc4080d0 100644 --- a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts +++ b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts @@ -48,8 +48,14 @@ const setup = () => { }; }; -// FLAKY: https://github.com/elastic/kibana/issues/101126 -describe.skip('createStreamingBatchedFunction()', () => { +describe('createStreamingBatchedFunction()', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); test('returns a function', () => { const { fetchStreaming } = setup(); const fn = createStreamingBatchedFunction({ @@ -87,8 +93,8 @@ describe.skip('createStreamingBatchedFunction()', () => { expect(fetchStreaming).toHaveBeenCalledTimes(0); fn({ baz: 'quix' }); expect(fetchStreaming).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(6); - await new Promise((r) => setTimeout(r, 6)); expect(fetchStreaming).toHaveBeenCalledTimes(1); }); @@ -103,7 +109,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }); expect(fetchStreaming).toHaveBeenCalledTimes(0); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(fetchStreaming).toHaveBeenCalledTimes(0); }); @@ -118,7 +124,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }); fn({ foo: 'bar' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(fetchStreaming.mock.calls[0][0]).toMatchObject({ url: '/test', @@ -139,7 +145,7 @@ describe.skip('createStreamingBatchedFunction()', () => { fn({ foo: 'bar' }); fn({ baz: 'quix' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); const { body } = fetchStreaming.mock.calls[0][0]; expect(JSON.parse(body)).toEqual({ batch: [{ foo: 'bar' }, { baz: 'quix' }], @@ -160,13 +166,10 @@ describe.skip('createStreamingBatchedFunction()', () => { expect(fetchStreaming).toHaveBeenCalledTimes(0); fn({ foo: 'bar' }); - await flushPromises(); expect(fetchStreaming).toHaveBeenCalledTimes(0); fn({ baz: 'quix' }); - await flushPromises(); expect(fetchStreaming).toHaveBeenCalledTimes(0); fn({ full: 'yep' }); - await flushPromises(); expect(fetchStreaming).toHaveBeenCalledTimes(1); }); @@ -186,7 +189,7 @@ describe.skip('createStreamingBatchedFunction()', () => { of(fn({ foo: 'bar' }, abortController.signal)); fn({ baz: 'quix' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); const { body } = fetchStreaming.mock.calls[0][0]; expect(JSON.parse(body)).toEqual({ batch: [{ baz: 'quix' }], @@ -206,7 +209,6 @@ describe.skip('createStreamingBatchedFunction()', () => { fn({ a: '1' }); fn({ b: '2' }); fn({ c: '3' }); - await flushPromises(); expect(fetchStreaming.mock.calls[0][0]).toMatchObject({ url: '/test', @@ -231,11 +233,9 @@ describe.skip('createStreamingBatchedFunction()', () => { fn({ a: '1' }); fn({ b: '2' }); fn({ c: '3' }); - await flushPromises(); expect(fetchStreaming).toHaveBeenCalledTimes(1); fn({ d: '4' }); - await flushPromises(); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(fetchStreaming).toHaveBeenCalledTimes(2); }); }); @@ -253,7 +253,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = fn({ a: '1' }); const promise2 = fn({ b: '2' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(await isPending(promise1)).toBe(true); expect(await isPending(promise2)).toBe(true); @@ -274,7 +274,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = fn({ a: '1' }); const promise2 = fn({ b: '2' }); const promise3 = fn({ c: '3' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(await isPending(promise1)).toBe(true); expect(await isPending(promise2)).toBe(true); @@ -316,7 +316,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = fn({ a: '1' }); const promise2 = fn({ b: '2' }); const promise3 = fn({ c: '3' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -365,7 +365,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = fn({ a: '1' }); const promise2 = fn({ b: '2' }); const promise3 = fn({ c: '3' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -405,7 +405,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }); const promise = fn({ a: '1' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(await isPending(promise)).toBe(true); @@ -437,7 +437,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise2 = of(fn({ a: '2' })); const promise3 = of(fn({ a: '3' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -446,7 +446,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }) + '\n' ); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); stream.next( JSON.stringify({ @@ -455,7 +455,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }) + '\n' ); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); stream.next( JSON.stringify({ @@ -464,7 +464,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }) + '\n' ); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [result1] = await promise1; const [, error2] = await promise2; @@ -489,13 +489,14 @@ describe.skip('createStreamingBatchedFunction()', () => { const abortController = new AbortController(); const promise = fn({ a: '1' }, abortController.signal); const promise2 = fn({ a: '2' }, abortController.signal); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(await isPending(promise)).toBe(true); expect(await isPending(promise2)).toBe(true); abortController.abort(); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); + await flushPromises(); expect(await isPending(promise)).toBe(false); expect(await isPending(promise2)).toBe(false); @@ -519,12 +520,13 @@ describe.skip('createStreamingBatchedFunction()', () => { const abortController = new AbortController(); const promise = fn({ a: '1' }, abortController.signal); const promise2 = fn({ a: '2' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(await isPending(promise)).toBe(true); abortController.abort(); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); + await flushPromises(); expect(await isPending(promise)).toBe(false); const [, error] = await of(promise); @@ -537,7 +539,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }) + '\n' ); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [result2] = await of(promise2); expect(result2).toEqual({ b: '2' }); @@ -558,11 +560,11 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = of(fn({ a: '1' })); const promise2 = of(fn({ a: '2' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.complete(); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [, error1] = await promise1; const [, error2] = await promise2; @@ -589,7 +591,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = of(fn({ a: '1' })); const promise2 = of(fn({ a: '2' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -599,7 +601,7 @@ describe.skip('createStreamingBatchedFunction()', () => { ); stream.complete(); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [, error1] = await promise1; const [result1] = await promise2; @@ -627,13 +629,13 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = of(fn({ a: '1' })); const promise2 = of(fn({ a: '2' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.error({ message: 'something went wrong', }); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [, error1] = await promise1; const [, error2] = await promise2; @@ -660,7 +662,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = of(fn({ a: '1' })); const promise2 = of(fn({ a: '2' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -670,7 +672,7 @@ describe.skip('createStreamingBatchedFunction()', () => { ); stream.error('oops'); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [, error1] = await promise1; const [result1] = await promise2; @@ -698,7 +700,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = of(fn({ a: '1' })); const promise2 = of(fn({ a: '2' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -709,7 +711,7 @@ describe.skip('createStreamingBatchedFunction()', () => { stream.next('Not a JSON\n'); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [, error1] = await promise1; const [result1] = await promise2; From 2ea4d5713c3ed6324779788dae1a470dcbcc403d Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 3 Jun 2021 15:19:35 +0200 Subject: [PATCH 20/35] [Uptime] Move uptime to new solution nav (#100905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Expose options to customize the route matching * Add more comments * move uptime to new solution nav * push * update test * add an extra breadcrumb Co-authored-by: Felix Stürmer --- x-pack/plugins/uptime/kibana.json | 6 +- x-pack/plugins/uptime/public/apps/plugin.ts | 62 ++++++-- .../plugins/uptime/public/apps/uptime_app.tsx | 1 + .../certificates/certificate_title.tsx | 25 +++ .../header/action_menu_content.test.tsx | 2 +- .../common/header/page_header.test.tsx | 69 --------- .../components/common/header/page_header.tsx | 64 -------- .../components/monitor/monitor_title.test.tsx | 65 -------- .../components/monitor/monitor_title.tsx | 36 +++-- .../synthetics/step_detail/step_detail.tsx | 144 ------------------ .../step_detail/step_detail_container.tsx | 59 ++++--- .../synthetics/step_detail/step_page_nav.tsx | 71 +++++++++ .../step_detail/step_page_title.tsx | 69 +++++++++ .../use_monitor_breadcrumbs.test.tsx | 8 + .../public/hooks/use_breadcrumbs.test.tsx | 16 +- .../uptime/public/hooks/use_breadcrumbs.ts | 40 +++-- .../uptime/public/lib/helper/rtl_helpers.tsx | 8 +- .../uptime/public/pages/certificates.tsx | 25 +-- .../plugins/uptime/public/pages/overview.tsx | 3 +- .../plugins/uptime/public/pages/settings.tsx | 134 ++++++++-------- .../pages/synthetics/synthetics_checks.tsx | 30 ++-- x-pack/plugins/uptime/public/routes.tsx | 82 +++++----- .../services/uptime/certificates.ts | 3 +- .../functional/services/uptime/navigation.ts | 5 +- 24 files changed, 471 insertions(+), 556 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/certificates/certificate_title.tsx delete mode 100644 x-pack/plugins/uptime/public/components/common/header/page_header.test.tsx delete mode 100644 x-pack/plugins/uptime/public/components/common/header/page_header.tsx delete mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_nav.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_title.tsx diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index 0d2346f59b0a1..4d5ab531af7c4 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -8,7 +8,6 @@ "optionalPlugins": [ "data", "home", - "observability", "ml", "fleet" ], @@ -18,7 +17,8 @@ "features", "licensing", "triggersActionsUi", - "usageCollection" + "usageCollection", + "observability" ], "server": true, "ui": true, @@ -31,4 +31,4 @@ "data", "ml" ] -} \ No newline at end of file +} diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index 80a131676951e..e02cf44b0856e 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -12,6 +12,8 @@ import { PluginInitializerContext, AppMountParameters, } from 'kibana/public'; +import { of } from 'rxjs'; +import { i18n } from '@kbn/i18n'; import { DEFAULT_APP_CATEGORIES } from '../../../../../src/core/public'; import { FeatureCatalogueCategory, @@ -28,7 +30,11 @@ import { } from '../../../../../src/plugins/data/public'; import { alertTypeInitializers } from '../lib/alert_types'; import { FleetStart } from '../../../fleet/public'; -import { FetchDataParams, ObservabilityPublicSetup } from '../../../observability/public'; +import { + FetchDataParams, + ObservabilityPublicSetup, + ObservabilityPublicStart, +} from '../../../observability/public'; import { PLUGIN } from '../../common/constants/plugin'; import { IStorageWrapper } from '../../../../../src/plugins/kibana_utils/public'; import { @@ -48,6 +54,7 @@ export interface ClientPluginsStart { data: DataPublicPluginStart; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; fleet?: FleetStart; + observability: ObservabilityPublicStart; } export interface UptimePluginServices extends Partial { @@ -83,21 +90,46 @@ export class UptimePlugin return UptimeDataHelper(coreStart); }; - if (plugins.observability) { - plugins.observability.dashboard.register({ - appName: 'synthetics', - hasData: async () => { - const dataHelper = await getUptimeDataHelper(); - const status = await dataHelper.indexStatus(); - return { hasData: status.docCount > 0, indices: status.indices }; - }, - fetchData: async (params: FetchDataParams) => { - const dataHelper = await getUptimeDataHelper(); - return await dataHelper.overviewData(params); - }, - }); - } + plugins.observability.dashboard.register({ + appName: 'synthetics', + hasData: async () => { + const dataHelper = await getUptimeDataHelper(); + const status = await dataHelper.indexStatus(); + return { hasData: status.docCount > 0, indices: status.indices }; + }, + fetchData: async (params: FetchDataParams) => { + const dataHelper = await getUptimeDataHelper(); + return await dataHelper.overviewData(params); + }, + }); + plugins.observability.navigation.registerSections( + of([ + { + label: 'Uptime', + sortKey: 200, + entries: [ + { + label: i18n.translate('xpack.uptime.overview.heading', { + defaultMessage: 'Monitoring overview', + }), + app: 'uptime', + path: '/', + matchFullPath: true, + ignoreTrailingSlash: true, + }, + { + label: i18n.translate('xpack.uptime.certificatesPage.heading', { + defaultMessage: 'TLS Certificates', + }), + app: 'uptime', + path: '/certificates', + matchFullPath: true, + }, + ], + }, + ]) + ); core.application.register({ id: PLUGIN.ID, euiIconType: 'logoObservability', diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index 758d40a95a86a..4d99e877291b5 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -122,6 +122,7 @@ const Application = (props: UptimeAppProps) => { storage, data: startPlugins.data, triggersActionsUi: startPlugins.triggersActionsUi, + observability: startPlugins.observability, }} > diff --git a/x-pack/plugins/uptime/public/components/certificates/certificate_title.tsx b/x-pack/plugins/uptime/public/components/certificates/certificate_title.tsx new file mode 100644 index 0000000000000..5056a3d1c1957 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/certificates/certificate_title.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useSelector } from 'react-redux'; +import { certificatesSelector } from '../../state/certificates/certificates'; + +export const CertificateTitle = () => { + const { data: certificates } = useSelector(certificatesSelector); + + return ( + {certificates?.total ?? 0}, + }} + /> + ); +}; diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx index bc5eab6f92111..89d8f38b1e3b3 100644 --- a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx @@ -49,7 +49,7 @@ describe('ActionMenuContent', () => { // this href value is mocked, so it doesn't correspond to the real link // that Kibana core services will provide - expect(addDataAnchor.getAttribute('href')).toBe('/app/uptime'); + expect(addDataAnchor.getAttribute('href')).toBe('/home#/tutorial/uptimeMonitors'); expect(getByText('Add data')); }); }); diff --git a/x-pack/plugins/uptime/public/components/common/header/page_header.test.tsx b/x-pack/plugins/uptime/public/components/common/header/page_header.test.tsx deleted file mode 100644 index 6e04648a817f0..0000000000000 --- a/x-pack/plugins/uptime/public/components/common/header/page_header.test.tsx +++ /dev/null @@ -1,69 +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 moment from 'moment'; -import { PageHeader } from './page_header'; -import { Ping } from '../../../../common/runtime_types'; -import { renderWithRouter } from '../../../lib'; -import { mockReduxHooks } from '../../../lib/helper/test_helpers'; - -describe('PageHeader', () => { - const monitorName = 'sample monitor'; - const defaultMonitorId = 'always-down'; - - const defaultMonitorStatus: Ping = { - docId: 'few213kl', - timestamp: moment(new Date()).subtract(15, 'm').toString(), - monitor: { - duration: { - us: 1234567, - }, - id: defaultMonitorId, - status: 'up', - type: 'http', - name: monitorName, - }, - url: { - full: 'https://www.elastic.co/', - }, - }; - - beforeEach(() => { - mockReduxHooks(defaultMonitorStatus); - }); - - it('does not render dynamic elements by default', () => { - const component = renderWithRouter(); - - expect(component.find('[data-test-subj="superDatePickerShowDatesButton"]').length).toBe(0); - expect(component.find('[data-test-subj="certificatesRefreshButton"]').length).toBe(0); - expect(component.find('[data-test-subj="monitorTitle"]').length).toBe(0); - expect(component.find('[data-test-subj="uptimeTabs"]').length).toBe(0); - }); - - it('shallow renders with the date picker', () => { - const component = renderWithRouter(); - expect(component.find('[data-test-subj="superDatePickerShowDatesButton"]').length).toBe(1); - }); - - it('shallow renders with certificate refresh button', () => { - const component = renderWithRouter(); - expect(component.find('[data-test-subj="certificatesRefreshButton"]').length).toBe(1); - }); - - it('renders monitor title when showMonitorTitle', () => { - const component = renderWithRouter(); - expect(component.find('[data-test-subj="monitorTitle"]').length).toBe(1); - expect(component.find('h1').text()).toBe(monitorName); - }); - - it('renders tabs when showTabs is true', () => { - const component = renderWithRouter(); - expect(component.find('[data-test-subj="uptimeTabs"]').length).toBe(1); - }); -}); diff --git a/x-pack/plugins/uptime/public/components/common/header/page_header.tsx b/x-pack/plugins/uptime/public/components/common/header/page_header.tsx deleted file mode 100644 index 28a133698ae8b..0000000000000 --- a/x-pack/plugins/uptime/public/components/common/header/page_header.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import styled from 'styled-components'; -import { UptimeDatePicker } from '../uptime_date_picker'; -import { SyntheticsCallout } from '../../overview/synthetics_callout'; -import { PageTabs } from './page_tabs'; -import { CertRefreshBtn } from '../../certificates/cert_refresh_btn'; -import { MonitorPageTitle } from '../../monitor/monitor_title'; - -export interface Props { - showCertificateRefreshBtn?: boolean; - showDatePicker?: boolean; - showMonitorTitle?: boolean; - showTabs?: boolean; -} - -const StyledPicker = styled(EuiFlexItem)` - &&& { - @media only screen and (max-width: 1024px) and (min-width: 868px) { - .euiSuperDatePicker__flexWrapper { - width: 500px; - } - } - @media only screen and (max-width: 880px) { - flex-grow: 1; - .euiSuperDatePicker__flexWrapper { - width: calc(100% + 8px); - } - } - } -`; - -export const PageHeader = ({ - showCertificateRefreshBtn = false, - showDatePicker = false, - showMonitorTitle = false, - showTabs = false, -}: Props) => { - return ( - <> - - - - {showMonitorTitle && } - {showTabs && } - - {showCertificateRefreshBtn && } - {showDatePicker && ( - - - - )} - - - - ); -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_title.test.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_title.test.tsx index 5e77e68720c52..4fd6335c3d3ca 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_title.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/monitor_title.test.tsx @@ -48,38 +48,6 @@ describe('MonitorTitle component', () => { }, }; - const defaultTCPMonitorStatus: Ping = { - docId: 'few213kl', - timestamp: moment(new Date()).subtract(15, 'm').toString(), - monitor: { - duration: { - us: 1234567, - }, - id: 'tcp', - status: 'up', - type: 'tcp', - }, - url: { - full: 'https://www.elastic.co/', - }, - }; - - const defaultICMPMonitorStatus: Ping = { - docId: 'few213kl', - timestamp: moment(new Date()).subtract(15, 'm').toString(), - monitor: { - duration: { - us: 1234567, - }, - id: 'icmp', - status: 'up', - type: 'icmp', - }, - url: { - full: 'https://www.elastic.co/', - }, - }; - const defaultBrowserMonitorStatus: Ping = { docId: 'few213kl', timestamp: moment(new Date()).subtract(15, 'm').toString(), @@ -145,37 +113,4 @@ describe('MonitorTitle component', () => { expect(betaLink.href).toBe('https://www.elastic.co/what-is/synthetic-monitoring'); expect(screen.getByText('Browser (BETA)')).toBeInTheDocument(); }); - - it('does not render beta disclaimer for http', () => { - render(, { - state: { monitorStatus: { status: defaultMonitorStatus, loading: false } }, - }); - expect(screen.getByText('HTTP ping')).toBeInTheDocument(); - expect(screen.queryByText(/BETA/)).not.toBeInTheDocument(); - expect( - screen.queryByRole('link', { name: 'See more External link (opens in a new tab or window)' }) - ).not.toBeInTheDocument(); - }); - - it('does not render beta disclaimer for tcp', () => { - render(, { - state: { monitorStatus: { status: defaultTCPMonitorStatus, loading: false } }, - }); - expect(screen.getByText('TCP ping')).toBeInTheDocument(); - expect(screen.queryByText(/BETA/)).not.toBeInTheDocument(); - expect( - screen.queryByRole('link', { name: 'See more External link (opens in a new tab or window)' }) - ).not.toBeInTheDocument(); - }); - - it('renders badge and does not render beta disclaimer for icmp', () => { - render(, { - state: { monitorStatus: { status: defaultICMPMonitorStatus, loading: false } }, - }); - expect(screen.getByText('ICMP ping')).toBeInTheDocument(); - expect(screen.queryByText(/BETA/)).not.toBeInTheDocument(); - expect( - screen.queryByRole('link', { name: 'See more External link (opens in a new tab or window)' }) - ).not.toBeInTheDocument(); - }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_title.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_title.tsx index eebd3d8aeb14d..8cb1c49cbd974 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_title.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/monitor_title.tsx @@ -5,7 +5,15 @@ * 2.0. */ -import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, EuiLink } from '@elastic/eui'; +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + EuiLink, + EuiText, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { useSelector } from 'react-redux'; @@ -95,26 +103,26 @@ export const MonitorPageTitle: React.FC = () => { - {type && ( + {isBrowser && type && ( {renderMonitorType(type)}{' '} - {isBrowser && ( - - )} + )} {isBrowser && ( - - - + + + + + )} diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx deleted file mode 100644 index befe53219a449..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiTitle, - EuiButtonEmpty, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; -import moment from 'moment'; -import { WaterfallChartContainer } from './waterfall/waterfall_chart_container'; - -export const PREVIOUS_CHECK_BUTTON_TEXT = i18n.translate( - 'xpack.uptime.synthetics.stepDetail.previousCheckButtonText', - { - defaultMessage: 'Previous check', - } -); - -export const NEXT_CHECK_BUTTON_TEXT = i18n.translate( - 'xpack.uptime.synthetics.stepDetail.nextCheckButtonText', - { - defaultMessage: 'Next check', - } -); - -interface Props { - checkGroup: string; - stepName?: string; - stepIndex: number; - totalSteps: number; - hasPreviousStep: boolean; - hasNextStep: boolean; - handlePreviousStep: () => void; - handleNextStep: () => void; - handleNextRun: () => void; - handlePreviousRun: () => void; - previousCheckGroup?: string; - nextCheckGroup?: string; - checkTimestamp?: string; - dateFormat: string; -} - -export const StepDetail: React.FC = ({ - dateFormat, - stepName, - checkGroup, - stepIndex, - totalSteps, - hasPreviousStep, - hasNextStep, - handlePreviousStep, - handleNextStep, - handlePreviousRun, - handleNextRun, - previousCheckGroup, - nextCheckGroup, - checkTimestamp, -}) => { - return ( - <> - - - - - -

{stepName}

-
-
- - - - - - - - - - - - - - - -
-
- - - - - {PREVIOUS_CHECK_BUTTON_TEXT} - - - - {moment(checkTimestamp).format(dateFormat)} - - - - {NEXT_CHECK_BUTTON_TEXT} - - - - -
- - - - ); -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx index ef0d001ac905e..df8f5dff59dc2 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx @@ -13,8 +13,12 @@ import { useHistory } from 'react-router-dom'; import { getJourneySteps } from '../../../../state/actions/journey'; import { journeySelector } from '../../../../state/selectors'; import { useUiSetting$ } from '../../../../../../../../src/plugins/kibana_react/public'; -import { StepDetail } from './step_detail'; import { useMonitorBreadcrumb } from './use_monitor_breadcrumb'; +import { ClientPluginsStart } from '../../../../apps/plugin'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { StepPageTitle } from './step_page_title'; +import { StepPageNavigation } from './step_page_nav'; +import { WaterfallChartContainer } from './waterfall/waterfall_chart_container'; export const NO_STEP_DATA = i18n.translate('xpack.uptime.synthetics.stepDetail.noData', { defaultMessage: 'No data could be found for this step', @@ -66,8 +70,40 @@ export const StepDetailContainer: React.FC = ({ checkGroup, stepIndex }) history.push(`/journey/${journey?.details?.previous?.checkGroup}/step/1`); }, [history, journey?.details?.previous?.checkGroup]); + const { + services: { observability }, + } = useKibana(); + const PageTemplateComponent = observability.navigation.PageTemplate; + return ( - <> + + ) : null, + rightSideItems: journey + ? [ + , + ] + : [], + }} + > {(!journey || journey.loading) && ( @@ -86,24 +122,9 @@ export const StepDetailContainer: React.FC = ({ checkGroup, stepIndex }) )} {journey && activeStep && !journey.loading && ( - + )} - + ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_nav.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_nav.tsx new file mode 100644 index 0000000000000..81c72b74c18e8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_nav.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import moment from 'moment'; +import { i18n } from '@kbn/i18n'; + +export const PREVIOUS_CHECK_BUTTON_TEXT = i18n.translate( + 'xpack.uptime.synthetics.stepDetail.previousCheckButtonText', + { + defaultMessage: 'Previous check', + } +); + +export const NEXT_CHECK_BUTTON_TEXT = i18n.translate( + 'xpack.uptime.synthetics.stepDetail.nextCheckButtonText', + { + defaultMessage: 'Next check', + } +); + +interface Props { + previousCheckGroup?: string; + dateFormat: string; + checkTimestamp?: string; + nextCheckGroup?: string; + handlePreviousRun: () => void; + handleNextRun: () => void; +} +export const StepPageNavigation = ({ + previousCheckGroup, + dateFormat, + handleNextRun, + handlePreviousRun, + checkTimestamp, + nextCheckGroup, +}: Props) => { + return ( + + + + {PREVIOUS_CHECK_BUTTON_TEXT} + + + + {moment(checkTimestamp).format(dateFormat)} + + + + {NEXT_CHECK_BUTTON_TEXT} + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_title.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_title.tsx new file mode 100644 index 0000000000000..083f2f1533e2e --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_title.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 React from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface Props { + stepName?: string; + stepIndex: number; + totalSteps: number; + hasPreviousStep: boolean; + hasNextStep: boolean; + handlePreviousStep: () => void; + handleNextStep: () => void; +} +export const StepPageTitle = ({ + stepName, + stepIndex, + totalSteps, + handleNextStep, + handlePreviousStep, + hasNextStep, + hasPreviousStep, +}: Props) => { + return ( + + + +

{stepName}

+
+
+ + + + + + + + + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumbs.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumbs.test.tsx index 4aed073424788..4521d9f82f92e 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumbs.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumbs.test.tsx @@ -63,6 +63,10 @@ describe('useMonitorBreadcrumbs', () => { expect(getBreadcrumbs()).toMatchInlineSnapshot(` Array [ + Object { + "href": "", + "text": "Observability", + }, Object { "href": "/app/uptime", "onClick": [Function], @@ -129,6 +133,10 @@ describe('useMonitorBreadcrumbs', () => { expect(getBreadcrumbs()).toMatchInlineSnapshot(` Array [ + Object { + "href": "", + "text": "Observability", + }, Object { "href": "/app/uptime", "onClick": [Function], diff --git a/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.test.tsx b/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.test.tsx index 6fc98fbaf1f5b..9d7318a45f76e 100644 --- a/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.test.tsx +++ b/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.test.tsx @@ -19,14 +19,8 @@ describe('useBreadcrumbs', () => { const [getBreadcrumbs, core] = mockCore(); const expectedCrumbs: ChromeBreadcrumb[] = [ - { - text: 'Crumb: ', - href: 'http://href.example.net', - }, - { - text: 'Crumb II: Son of Crumb', - href: 'http://href2.example.net', - }, + { text: 'Crumb: ', href: 'http://href.example.net' }, + { text: 'Crumb II: Son of Crumb', href: 'http://href2.example.net' }, ]; const Component = () => { @@ -46,7 +40,9 @@ describe('useBreadcrumbs', () => { const urlParams: UptimeUrlParams = getSupportedUrlParams({}); expect(JSON.stringify(getBreadcrumbs())).toEqual( - JSON.stringify([makeBaseBreadcrumb('/app/uptime', urlParams)].concat(expectedCrumbs)) + JSON.stringify( + makeBaseBreadcrumb('/app/uptime', '/app/observability', urlParams).concat(expectedCrumbs) + ) ); }); }); @@ -58,7 +54,7 @@ const mockCore: () => [() => ChromeBreadcrumb[], any] = () => { }; const core = { application: { - getUrlForApp: () => '/app/uptime', + getUrlForApp: (app: string) => (app === 'uptime' ? '/app/uptime' : '/app/observability'), navigateToUrl: jest.fn(), }, chrome: { diff --git a/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.ts b/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.ts index f2ec25b50332b..5ea81e579ff92 100644 --- a/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.ts +++ b/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.ts @@ -36,34 +36,52 @@ function handleBreadcrumbClick( })); } -export const makeBaseBreadcrumb = (href: string, params?: UptimeUrlParams): EuiBreadcrumb => { +export const makeBaseBreadcrumb = ( + uptimePath: string, + observabilityPath: string, + params?: UptimeUrlParams +): [EuiBreadcrumb, EuiBreadcrumb] => { if (params) { const crumbParams: Partial = { ...params }; delete crumbParams.statusFilter; const query = stringifyUrlParams(crumbParams, true); - href += query === EMPTY_QUERY ? '' : query; + uptimePath += query === EMPTY_QUERY ? '' : query; } - return { - text: i18n.translate('xpack.uptime.breadcrumbs.overviewBreadcrumbText', { - defaultMessage: 'Uptime', - }), - href, - }; + + return [ + { + text: i18n.translate('xpack.uptime.breadcrumbs.observabilityText', { + defaultMessage: 'Observability', + }), + href: observabilityPath, + }, + { + text: i18n.translate('xpack.uptime.breadcrumbs.overviewBreadcrumbText', { + defaultMessage: 'Uptime', + }), + href: uptimePath, + }, + ]; }; export const useBreadcrumbs = (extraCrumbs: ChromeBreadcrumb[]) => { const params = useUrlParams()[0](); const kibana = useKibana(); const setBreadcrumbs = kibana.services.chrome?.setBreadcrumbs; - const appPath = kibana.services.application?.getUrlForApp(PLUGIN.ID) ?? ''; + const uptimePath = kibana.services.application?.getUrlForApp(PLUGIN.ID) ?? ''; + const observabilityPath = + kibana.services.application?.getUrlForApp('observability-overview') ?? ''; const navigate = kibana.services.application?.navigateToUrl; useEffect(() => { if (setBreadcrumbs) { setBreadcrumbs( - handleBreadcrumbClick([makeBaseBreadcrumb(appPath, params)].concat(extraCrumbs), navigate) + handleBreadcrumbClick( + makeBaseBreadcrumb(uptimePath, observabilityPath, params).concat(extraCrumbs), + navigate + ) ); } - }, [appPath, extraCrumbs, navigate, params, setBreadcrumbs]); + }, [uptimePath, observabilityPath, extraCrumbs, navigate, params, setBreadcrumbs]); }; diff --git a/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx b/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx index a84209a23449a..0c2e31589bb10 100644 --- a/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx +++ b/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx @@ -79,6 +79,12 @@ const createMockStore = () => { }; }; +const mockAppUrls: Record = { + uptime: '/app/uptime', + observability: '/app/observability', + '/home#/tutorial/uptimeMonitors': '/home#/tutorial/uptimeMonitors', +}; + /* default mock core */ const defaultCore = coreMock.createStart(); const mockCore: () => Partial = () => { @@ -86,7 +92,7 @@ const mockCore: () => Partial = () => { ...defaultCore, application: { ...defaultCore.application, - getUrlForApp: () => '/app/uptime', + getUrlForApp: (app: string) => mockAppUrls[app], navigateToUrl: jest.fn(), capabilities: { ...defaultCore.application.capabilities, diff --git a/x-pack/plugins/uptime/public/pages/certificates.tsx b/x-pack/plugins/uptime/public/pages/certificates.tsx index 7c21493dbde06..4b8617d594547 100644 --- a/x-pack/plugins/uptime/public/pages/certificates.tsx +++ b/x-pack/plugins/uptime/public/pages/certificates.tsx @@ -5,15 +5,14 @@ * 2.0. */ -import { useDispatch, useSelector } from 'react-redux'; -import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { useDispatch } from 'react-redux'; +import { EuiSpacer } from '@elastic/eui'; import React, { useContext, useEffect, useState } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; import { useTrackPageview } from '../../../observability/public'; import { useBreadcrumbs } from '../hooks/use_breadcrumbs'; import { getDynamicSettings } from '../state/actions/dynamic_settings'; import { UptimeRefreshContext } from '../contexts'; -import { certificatesSelector, getCertificatesAction } from '../state/certificates/certificates'; +import { getCertificatesAction } from '../state/certificates/certificates'; import { CertificateList, CertificateSearch, CertSort } from '../components/certificates'; const DEFAULT_PAGE_SIZE = 10; @@ -58,22 +57,8 @@ export const CertificatesPage: React.FC = () => { ); }, [dispatch, page, search, sort.direction, sort.field, lastRefresh]); - const { data: certificates } = useSelector(certificatesSelector); - return ( - - -

- {certificates?.total ?? 0}, - }} - /> -

-
- + <> @@ -86,6 +71,6 @@ export const CertificatesPage: React.FC = () => { }} sort={sort} /> -
+ ); }; diff --git a/x-pack/plugins/uptime/public/pages/overview.tsx b/x-pack/plugins/uptime/public/pages/overview.tsx index 846698bc390db..626e797bd9fd1 100644 --- a/x-pack/plugins/uptime/public/pages/overview.tsx +++ b/x-pack/plugins/uptime/public/pages/overview.tsx @@ -15,6 +15,7 @@ import { MonitorList } from '../components/overview/monitor_list/monitor_list_co import { EmptyState, FilterGroup } from '../components/overview'; import { StatusPanel } from '../components/overview/status_panel'; import { QueryBar } from '../components/overview/query_bar/query_bar'; +import { MONITORING_OVERVIEW_LABEL } from '../routes'; const EuiFlexItemStyled = styled(EuiFlexItem)` && { @@ -32,7 +33,7 @@ export const OverviewPageComponent = () => { useTrackPageview({ app: 'uptime', path: 'overview' }); useTrackPageview({ app: 'uptime', path: 'overview', delay: 15000 }); - useBreadcrumbs([]); // No extra breadcrumbs on overview + useBreadcrumbs([{ text: MONITORING_OVERVIEW_LABEL }]); // No extra breadcrumbs on overview return ( diff --git a/x-pack/plugins/uptime/public/pages/settings.tsx b/x-pack/plugins/uptime/public/pages/settings.tsx index f806ebbd09cc3..5f2699240425a 100644 --- a/x-pack/plugins/uptime/public/pages/settings.tsx +++ b/x-pack/plugins/uptime/public/pages/settings.tsx @@ -148,73 +148,71 @@ export const SettingsPage: React.FC = () => { ); return ( - <> - - - {cannotEditNotice} - - - -
- - - - - - - - - { - resetForm(); - }} - > - - - - - - - - - - -
-
-
-
- + + + {cannotEditNotice} + + + +
+ + + + + + + + + { + resetForm(); + }} + > + + + + + + + + + + +
+
+
+
); }; diff --git a/x-pack/plugins/uptime/public/pages/synthetics/synthetics_checks.tsx b/x-pack/plugins/uptime/public/pages/synthetics/synthetics_checks.tsx index edfd7ae24f91b..fe41e72fa4c48 100644 --- a/x-pack/plugins/uptime/public/pages/synthetics/synthetics_checks.tsx +++ b/x-pack/plugins/uptime/public/pages/synthetics/synthetics_checks.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { useTrackPageview } from '../../../../observability/public'; import { useInitApp } from '../../hooks/use_init_app'; import { StepsList } from '../../components/synthetics/check_steps/steps_list'; @@ -14,6 +14,7 @@ import { useCheckSteps } from '../../components/synthetics/check_steps/use_check import { ChecksNavigation } from './checks_navigation'; import { useMonitorBreadcrumb } from '../../components/monitor/synthetics/step_detail/use_monitor_breadcrumb'; import { EmptyJourney } from '../../components/synthetics/empty_journey'; +import { ClientPluginsStart } from '../../apps/plugin'; export const SyntheticsCheckSteps: React.FC = () => { useInitApp(); @@ -24,21 +25,22 @@ export const SyntheticsCheckSteps: React.FC = () => { useMonitorBreadcrumb({ details, activeStep: details?.journey }); + const { + services: { observability }, + } = useKibana(); + const PageTemplateComponent = observability.navigation.PageTemplate; + return ( - <> - - - -

{details?.journey?.monitor.name || details?.journey?.monitor.id}

-
-
- - {details && } - -
- + : null, + ], + }} + > {(!steps || steps.length === 0) && !loading && } - + ); }; diff --git a/x-pack/plugins/uptime/public/routes.tsx b/x-pack/plugins/uptime/public/routes.tsx index 1c025edd0a73d..192b5552fea40 100644 --- a/x-pack/plugins/uptime/public/routes.tsx +++ b/x-pack/plugins/uptime/public/routes.tsx @@ -6,8 +6,9 @@ */ import React, { FC, useEffect } from 'react'; -import { Route, RouteComponentProps, Switch } from 'react-router-dom'; -import { Props as PageHeaderProps, PageHeader } from './components/common/header/page_header'; +import { Route, Switch } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { CERTIFICATES_ROUTE, MONITOR_ROUTE, @@ -21,6 +22,13 @@ import { CertificatesPage } from './pages/certificates'; import { UptimePage, useUptimeTelemetry } from './hooks'; import { OverviewPageComponent } from './pages/overview'; import { SyntheticsCheckSteps } from './pages/synthetics/synthetics_checks'; +import { ClientPluginsStart } from './apps/plugin'; +import { MonitorPageTitle } from './components/monitor/monitor_title'; +import { UptimeDatePicker } from './components/common/uptime_date_picker'; +import { useKibana } from '../../../../src/plugins/kibana_react/public'; +import { CertRefreshBtn } from './components/certificates/cert_refresh_btn'; +import { CertificateTitle } from './components/certificates/certificate_title'; +import { SyntheticsCallout } from './components/overview/synthetics_callout'; interface RouteProps { path: string; @@ -28,11 +36,15 @@ interface RouteProps { dataTestSubj: string; title: string; telemetryId: UptimePage; - headerProps?: PageHeaderProps; + pageHeader?: { pageTitle: string | JSX.Element; rightSideItems?: JSX.Element[] }; } const baseTitle = 'Uptime - Kibana'; +export const MONITORING_OVERVIEW_LABEL = i18n.translate('xpack.uptime.overview.heading', { + defaultMessage: 'Monitoring overview', +}); + const Routes: RouteProps[] = [ { title: `Monitor | ${baseTitle}`, @@ -40,9 +52,9 @@ const Routes: RouteProps[] = [ component: MonitorPage, dataTestSubj: 'uptimeMonitorPage', telemetryId: UptimePage.Monitor, - headerProps: { - showDatePicker: true, - showMonitorTitle: true, + pageHeader: { + pageTitle: , + rightSideItems: [], }, }, { @@ -51,8 +63,10 @@ const Routes: RouteProps[] = [ component: SettingsPage, dataTestSubj: 'uptimeSettingsPage', telemetryId: UptimePage.Settings, - headerProps: { - showTabs: true, + pageHeader: { + pageTitle: ( + + ), }, }, { @@ -61,9 +75,9 @@ const Routes: RouteProps[] = [ component: CertificatesPage, dataTestSubj: 'uptimeCertificatesPage', telemetryId: UptimePage.Certificates, - headerProps: { - showCertificateRefreshBtn: true, - showTabs: true, + pageHeader: { + pageTitle: , + rightSideItems: [], }, }, { @@ -86,9 +100,9 @@ const Routes: RouteProps[] = [ component: OverviewPageComponent, dataTestSubj: 'uptimeOverviewPage', telemetryId: UptimePage.Overview, - headerProps: { - showDatePicker: true, - showTabs: true, + pageHeader: { + pageTitle: MONITORING_OVERVIEW_LABEL, + rightSideItems: [], }, }, ]; @@ -106,31 +120,31 @@ const RouteInit: React.FC> = }; export const PageRouter: FC = () => { + const { + services: { observability }, + } = useKibana(); + const PageTemplateComponent = observability.navigation.PageTemplate; + return ( - <> - {/* Independent page header route that matches all paths and passes appropriate header props */} - {/* Prevents the header from being remounted on route changes */} - route.path)]} - exact={true} - render={({ match }: RouteComponentProps) => { - const routeProps: RouteProps | undefined = Routes.find( - (route: RouteProps) => route?.path === match?.path - ); - return routeProps?.headerProps && ; - }} - /> - - {Routes.map(({ title, path, component: RouteComponent, dataTestSubj, telemetryId }) => ( + + {Routes.map( + ({ title, path, component: RouteComponent, dataTestSubj, telemetryId, pageHeader }) => (
+ - + {pageHeader ? ( + + + + ) : ( + + )}
- ))} - -
- + ) + )} + +
); }; diff --git a/x-pack/test/functional/services/uptime/certificates.ts b/x-pack/test/functional/services/uptime/certificates.ts index 498e18de8e281..3a560acee52d8 100644 --- a/x-pack/test/functional/services/uptime/certificates.ts +++ b/x-pack/test/functional/services/uptime/certificates.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export function UptimeCertProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const retry = getService('retry'); + const find = getService('find'); const PageObjects = getPageObjects(['common', 'timePicker', 'header']); @@ -27,7 +28,7 @@ export function UptimeCertProvider({ getService, getPageObjects }: FtrProviderCo return { async hasViewCertButton() { return retry.tryForTime(15000, async () => { - await testSubjects.existOrFail('uptimeCertificatesLink'); + await find.existsByCssSelector('[href="/app/uptime/certificates"]'); }); }, async certificateExists(cert: { certId: string; monitorId: string }) { diff --git a/x-pack/test/functional/services/uptime/navigation.ts b/x-pack/test/functional/services/uptime/navigation.ts index b76d68e1eb454..51806d1006ab4 100644 --- a/x-pack/test/functional/services/uptime/navigation.ts +++ b/x-pack/test/functional/services/uptime/navigation.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export function UptimeNavigationProvider({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const testSubjects = getService('testSubjects'); + const find = getService('find'); const PageObjects = getPageObjects(['common', 'timePicker', 'header']); const goToUptimeRoot = async () => { @@ -70,8 +71,8 @@ export function UptimeNavigationProvider({ getService, getPageObjects }: FtrProv goToCertificates: async () => { if (!(await testSubjects.exists('uptimeCertificatesPage', { timeout: 0 }))) { return retry.try(async () => { - if (await testSubjects.exists('uptimeCertificatesLink', { timeout: 0 })) { - await testSubjects.click('uptimeCertificatesLink', 10000); + if (await find.existsByCssSelector('[href="/app/uptime/certificates"]', 0)) { + await find.clickByCssSelector('[href="/app/uptime/certificates"]'); } await testSubjects.existOrFail('uptimeCertificatesPage'); }); From f5df40a5a1fa4b744ee35c2a0ae44e2ffa6ebb16 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 19 May 2021 09:34:14 -0700 Subject: [PATCH 21/35] skip flaky suite (#99581) --- x-pack/test/functional/apps/spaces/spaces_selection.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/spaces/spaces_selection.ts b/x-pack/test/functional/apps/spaces/spaces_selection.ts index 99efdf29eecb9..f3d3665bf9f61 100644 --- a/x-pack/test/functional/apps/spaces/spaces_selection.ts +++ b/x-pack/test/functional/apps/spaces/spaces_selection.ts @@ -22,7 +22,8 @@ export default function spaceSelectorFunctionalTests({ 'spaceSelector', ]); - describe('Spaces', function () { + // FLAKY: https://github.com/elastic/kibana/issues/99581 + describe.skip('Spaces', function () { this.tags('includeFirefox'); describe('Space Selector', () => { before(async () => { From b8c127c18fbf70ba0c6b73f9f86bcef5d712beee Mon Sep 17 00:00:00 2001 From: ymao1 Date: Thu, 3 Jun 2021 09:35:27 -0400 Subject: [PATCH 22/35] Fixing pagerduty server side functionality (#101091) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../builtin_action_types/pagerduty.test.ts | 206 +++++++++++++++++- .../server/builtin_action_types/pagerduty.ts | 17 +- 2 files changed, 213 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts index 93c5dd4a44db0..7540785714bcd 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts @@ -152,14 +152,15 @@ describe('validateParams()', () => { `); }); - test('should validate and throw error when timestamp has spaces', () => { + test('should validate and pass when valid timestamp has spaces', () => { const randoDate = new Date('1963-09-23T01:23:45Z').toISOString(); const timestamp = ` ${randoDate}`; - expect(() => { - validateParams(actionType, { - timestamp, - }); - }).toThrowError(`error validating action params: error parsing timestamp "${timestamp}"`); + expect(validateParams(actionType, { timestamp })).toEqual({ timestamp }); + }); + + test('should validate and pass when timestamp is empty string', () => { + const timestamp = ''; + expect(validateParams(actionType, { timestamp })).toEqual({ timestamp }); }); test('should validate and throw error when timestamp is invalid', () => { @@ -409,7 +410,7 @@ describe('execute()', () => { `); }); - test('should fail when sendPagerdury throws', async () => { + test('should fail when sendPagerduty throws', async () => { const secrets = { routingKey: 'super-secret' }; const config = { apiUrl: null }; const params = {}; @@ -576,4 +577,195 @@ describe('execute()', () => { } `); }); + + test('should succeed when timestamp contains valid date and extraneous spaces', async () => { + const randoDate = new Date('1963-09-23T01:23:45Z').toISOString(); + const secrets = { + routingKey: 'super-secret', + }; + const config = { + apiUrl: 'the-api-url', + }; + const params: ActionParamsType = { + eventAction: 'trigger', + dedupKey: 'a-dedup-key', + summary: 'the summary', + source: 'the-source', + severity: 'critical', + timestamp: ` ${randoDate} `, + component: 'the-component', + group: 'the-group', + class: 'the-class', + }; + + postPagerdutyMock.mockImplementation(() => { + return { status: 202, data: 'data-here' }; + }); + + const actionId = 'some-action-id'; + const executorOptions: PagerDutyActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; + const actionResponse = await actionType.executor(executorOptions); + const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; + expect({ apiUrl, data, headers }).toMatchInlineSnapshot(` + Object { + "apiUrl": "the-api-url", + "data": Object { + "dedup_key": "a-dedup-key", + "event_action": "trigger", + "payload": Object { + "class": "the-class", + "component": "the-component", + "group": "the-group", + "severity": "critical", + "source": "the-source", + "summary": "the summary", + "timestamp": "1963-09-23T01:23:45.000Z", + }, + }, + "headers": Object { + "Content-Type": "application/json", + "X-Routing-Key": "super-secret", + }, + } + `); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "actionId": "some-action-id", + "data": "data-here", + "status": "ok", + } + `); + }); + + test('should not pass timestamp field when timestamp is empty string', async () => { + const secrets = { + routingKey: 'super-secret', + }; + const config = { + apiUrl: 'the-api-url', + }; + const params: ActionParamsType = { + eventAction: 'trigger', + dedupKey: 'a-dedup-key', + summary: 'the summary', + source: 'the-source', + severity: 'critical', + timestamp: '', + component: 'the-component', + group: 'the-group', + class: 'the-class', + }; + + postPagerdutyMock.mockImplementation(() => { + return { status: 202, data: 'data-here' }; + }); + + const actionId = 'some-action-id'; + const executorOptions: PagerDutyActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; + const actionResponse = await actionType.executor(executorOptions); + const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; + expect({ apiUrl, data, headers }).toMatchInlineSnapshot(` + Object { + "apiUrl": "the-api-url", + "data": Object { + "dedup_key": "a-dedup-key", + "event_action": "trigger", + "payload": Object { + "class": "the-class", + "component": "the-component", + "group": "the-group", + "severity": "critical", + "source": "the-source", + "summary": "the summary", + }, + }, + "headers": Object { + "Content-Type": "application/json", + "X-Routing-Key": "super-secret", + }, + } + `); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "actionId": "some-action-id", + "data": "data-here", + "status": "ok", + } + `); + }); + + test('should not pass timestamp field when timestamp is string of spaces', async () => { + const secrets = { + routingKey: 'super-secret', + }; + const config = { + apiUrl: 'the-api-url', + }; + const params: ActionParamsType = { + eventAction: 'trigger', + dedupKey: 'a-dedup-key', + summary: 'the summary', + source: 'the-source', + severity: 'critical', + timestamp: ' ', + component: 'the-component', + group: 'the-group', + class: 'the-class', + }; + + postPagerdutyMock.mockImplementation(() => { + return { status: 202, data: 'data-here' }; + }); + + const actionId = 'some-action-id'; + const executorOptions: PagerDutyActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; + const actionResponse = await actionType.executor(executorOptions); + const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; + expect({ apiUrl, data, headers }).toMatchInlineSnapshot(` + Object { + "apiUrl": "the-api-url", + "data": Object { + "dedup_key": "a-dedup-key", + "event_action": "trigger", + "payload": Object { + "class": "the-class", + "component": "the-component", + "group": "the-group", + "severity": "critical", + "source": "the-source", + "summary": "the summary", + }, + }, + "headers": Object { + "Content-Type": "application/json", + "X-Routing-Key": "super-secret", + }, + } + `); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "actionId": "some-action-id", + "data": "data-here", + "status": "ok", + } + `); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts index b64cf6ec346d5..5d83b658111e4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -85,11 +85,19 @@ const ParamsSchema = schema.object( { validate: validateParams } ); +function validateTimestamp(timestamp?: string): string | null { + if (timestamp) { + return timestamp.trim().length > 0 ? timestamp.trim() : null; + } + return null; +} + function validateParams(paramsObject: unknown): string | void { const { timestamp, eventAction, dedupKey } = paramsObject as ActionParamsType; - if (timestamp != null) { + const validatedTimestamp = validateTimestamp(timestamp); + if (validatedTimestamp != null) { try { - const date = Date.parse(timestamp); + const date = Date.parse(validatedTimestamp); if (isNaN(date)) { return i18n.translate('xpack.actions.builtin.pagerduty.invalidTimestampErrorMessage', { defaultMessage: `error parsing timestamp "{timestamp}"`, @@ -279,11 +287,14 @@ function getBodyForEventAction(actionId: string, params: ActionParamsType): Page return data; } + const validatedTimestamp = validateTimestamp(params.timestamp); + data.payload = { summary: params.summary || 'No summary provided.', source: params.source || `Kibana Action ${actionId}`, severity: params.severity || 'info', - ...omitBy(pick(params, ['timestamp', 'component', 'group', 'class']), isUndefined), + ...(validatedTimestamp ? { timestamp: validatedTimestamp } : {}), + ...omitBy(pick(params, ['component', 'group', 'class']), isUndefined), }; return data; From de85036fc75a5dcab3d4074f7db65e88e91aee9c Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Thu, 3 Jun 2021 17:47:54 +0300 Subject: [PATCH 23/35] [Usage] Fix flaky UI Counters test (#100979) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apis/ui_counters/ui_counters.ts | 87 ++++++++++--------- 1 file changed, 44 insertions(+), 43 deletions(-) diff --git a/test/api_integration/apis/ui_counters/ui_counters.ts b/test/api_integration/apis/ui_counters/ui_counters.ts index ab3ca2e8dd3a7..2be6ea4341fb0 100644 --- a/test/api_integration/apis/ui_counters/ui_counters.ts +++ b/test/api_integration/apis/ui_counters/ui_counters.ts @@ -15,6 +15,7 @@ import { UsageCountersSavedObject } from '../../../../src/plugins/usage_collecti export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); + const retry = getService('retry'); const createUiCounterEvent = (eventName: string, type: UiCounterMetricType, count = 1) => ({ eventName, @@ -23,16 +24,24 @@ export default function ({ getService }: FtrProviderContext) { count, }); - const sendReport = async (report: Report) => { + const fetchUsageCountersObjects = async (): Promise => { + const { + body: { saved_objects: savedObjects }, + } = await supertest + .get('/api/saved_objects/_find?type=usage-counters') + .set('kbn-xsrf', 'kibana') + .expect(200); + + return savedObjects; + }; + + const sendReport = async (report: Report): Promise => { await supertest .post('/api/ui_counters/_report') .set('kbn-xsrf', 'kibana') .set('content-type', 'application/json') .send({ report }) .expect(200); - - // wait for SO to index data into ES - await new Promise((res) => setTimeout(res, 5 * 1000)); }; const getCounterById = ( @@ -47,8 +56,7 @@ export default function ({ getService }: FtrProviderContext) { return savedObject; }; - // FLAKY: https://github.com/elastic/kibana/issues/98240 - describe.skip('UI Counters API', () => { + describe('UI Counters API', () => { const dayDate = moment().format('DDMMYYYY'); before(async () => await esArchiver.emptyKibanaIndex()); @@ -61,18 +69,15 @@ export default function ({ getService }: FtrProviderContext) { await sendReport(report); - const { - body: { saved_objects: savedObjects }, - } = await supertest - .get('/api/saved_objects/_find?type=usage-counters') - .set('kbn-xsrf', 'kibana') - .expect(200); - - const countTypeEvent = getCounterById( - savedObjects, - `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:my_event` - ); - expect(countTypeEvent.attributes.count).to.eql(1); + await retry.waitForWithTimeout('reported events to be stored into ES', 8000, async () => { + const savedObjects = await fetchUsageCountersObjects(); + const countTypeEvent = getCounterById( + savedObjects, + `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:my_event` + ); + expect(countTypeEvent.attributes.count).to.eql(1); + return true; + }); }); it('supports multiple events', async () => { @@ -87,31 +92,27 @@ export default function ({ getService }: FtrProviderContext) { ]); await sendReport(report); - - const { - body: { saved_objects: savedObjects }, - } = await supertest - .get('/api/saved_objects/_find?type=usage-counters&fields=count') - .set('kbn-xsrf', 'kibana') - .expect(200); - - const countTypeEvent = getCounterById( - savedObjects, - `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}` - ); - expect(countTypeEvent.attributes.count).to.eql(1); - - const clickTypeEvent = getCounterById( - savedObjects, - `uiCounter:${dayDate}:${METRIC_TYPE.CLICK}:myApp:${uniqueEventName}` - ); - expect(clickTypeEvent.attributes.count).to.eql(2); - - const secondEvent = getCounterById( - savedObjects, - `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}_2` - ); - expect(secondEvent.attributes.count).to.eql(1); + await retry.waitForWithTimeout('reported events to be stored into ES', 8000, async () => { + const savedObjects = await fetchUsageCountersObjects(); + const countTypeEvent = getCounterById( + savedObjects, + `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}` + ); + expect(countTypeEvent.attributes.count).to.eql(1); + + const clickTypeEvent = getCounterById( + savedObjects, + `uiCounter:${dayDate}:${METRIC_TYPE.CLICK}:myApp:${uniqueEventName}` + ); + expect(clickTypeEvent.attributes.count).to.eql(2); + + const secondEvent = getCounterById( + savedObjects, + `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}_2` + ); + expect(secondEvent.attributes.count).to.eql(1); + return true; + }); }); }); } From 58b1416f845cdcdfa615533d8c09bbc30a4624a1 Mon Sep 17 00:00:00 2001 From: Oleksiy Kovyrin Date: Thu, 3 Jun 2021 10:58:11 -0400 Subject: [PATCH 24/35] Enterpise Search SSL Settings Support (#100946) Introduce a new set of SSL configuration settings for Enterprise Search plugin, allowing users to configure a set of custom certificate authorities and to control TLS validation mode used for all requests to Enterprise Search. Co-authored-by: Byron Hulcher Co-authored-by: Constance Chen --- .../server/__mocks__/http_agent.mock.ts | 14 +++ .../server/__mocks__/index.ts | 2 + .../__mocks__/routerDependencies.mock.ts | 1 + .../plugins/enterprise_search/server/index.ts | 9 ++ .../lib/enterprise_search_config_api.test.ts | 1 + .../lib/enterprise_search_config_api.ts | 9 +- .../lib/enterprise_search_http_agent.test.ts | 118 ++++++++++++++++++ .../lib/enterprise_search_http_agent.ts | 85 +++++++++++++ .../enterprise_search_request_handler.test.ts | 3 +- .../lib/enterprise_search_request_handler.ts | 15 ++- .../enterprise_search/server/plugin.ts | 6 + 11 files changed, 255 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/server/__mocks__/http_agent.mock.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.ts diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/http_agent.mock.ts b/x-pack/plugins/enterprise_search/server/__mocks__/http_agent.mock.ts new file mode 100644 index 0000000000000..1e9b04674b582 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/__mocks__/http_agent.mock.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const mockHttpAgent = jest.fn(); + +jest.mock('../lib/enterprise_search_http_agent', () => ({ + entSearchHttpAgent: { + getHttpAgent: () => mockHttpAgent, + }, +})); diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/index.ts b/x-pack/plugins/enterprise_search/server/__mocks__/index.ts index c36acd2b57647..c59a5a8f67e32 100644 --- a/x-pack/plugins/enterprise_search/server/__mocks__/index.ts +++ b/x-pack/plugins/enterprise_search/server/__mocks__/index.ts @@ -12,3 +12,5 @@ export { mockRequestHandler, mockDependencies, } from './routerDependencies.mock'; + +export { mockHttpAgent } from './http_agent.mock'; diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts b/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts index 50ff082858fc8..08be1a134ae02 100644 --- a/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts +++ b/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts @@ -23,6 +23,7 @@ export const mockConfig = { host: 'http://localhost:3002', accessCheckTimeout: 5000, accessCheckTimeoutWarning: 300, + ssl: {}, } as ConfigType; /** diff --git a/x-pack/plugins/enterprise_search/server/index.ts b/x-pack/plugins/enterprise_search/server/index.ts index c4552b9134eae..ecd068c8bdbd9 100644 --- a/x-pack/plugins/enterprise_search/server/index.ts +++ b/x-pack/plugins/enterprise_search/server/index.ts @@ -19,6 +19,15 @@ export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), accessCheckTimeout: schema.number({ defaultValue: 5000 }), accessCheckTimeoutWarning: schema.number({ defaultValue: 300 }), + ssl: schema.object({ + certificateAuthorities: schema.maybe( + schema.oneOf([schema.arrayOf(schema.string(), { minSize: 1 }), schema.string()]) + ), + verificationMode: schema.oneOf( + [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], + { defaultValue: 'full' } + ), + }), }); export type ConfigType = TypeOf; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts index 66f2bf78e0c9c..50bac793ee696 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts @@ -6,6 +6,7 @@ */ import { DEFAULT_INITIAL_APP_DATA } from '../../common/__mocks__'; +import '../__mocks__/http_agent.mock.ts'; jest.mock('node-fetch'); import fetch from 'node-fetch'; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts index 0f2faf1fd8a3a..8cce01d1932ee 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts @@ -16,6 +16,8 @@ import { stripTrailingSlash } from '../../common/strip_slashes'; import { InitialAppData } from '../../common/types'; import { ConfigType } from '../index'; +import { entSearchHttpAgent } from './enterprise_search_http_agent'; + interface Params { request: KibanaRequest; config: ConfigType; @@ -54,10 +56,13 @@ export const callEnterpriseSearchConfigAPI = async ({ try { const enterpriseSearchUrl = encodeURI(`${config.host}${ENDPOINT}`); - const response = await fetch(enterpriseSearchUrl, { + const options = { headers: { Authorization: request.headers.authorization as string }, signal: controller.signal, - }); + agent: entSearchHttpAgent.getHttpAgent(), + }; + + const response = await fetch(enterpriseSearchUrl, options); const data = await response.json(); warnMismatchedVersions(data?.version?.number, log); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.test.ts new file mode 100644 index 0000000000000..f4bdfd8d2cb0f --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('fs', () => ({ readFileSync: jest.fn() })); +import { readFileSync } from 'fs'; + +import http from 'http'; +import https from 'https'; + +import { ConfigType } from '../'; + +import { entSearchHttpAgent } from './enterprise_search_http_agent'; + +describe('entSearchHttpAgent', () => { + describe('initializeHttpAgent', () => { + it('creates an https.Agent when host URL is using HTTPS', () => { + entSearchHttpAgent.initializeHttpAgent({ + host: 'https://example.org', + ssl: {}, + } as ConfigType); + expect(entSearchHttpAgent.getHttpAgent()).toBeInstanceOf(https.Agent); + }); + + it('creates an http.Agent when host URL is using HTTP', () => { + entSearchHttpAgent.initializeHttpAgent({ + host: 'http://example.org', + ssl: {}, + } as ConfigType); + expect(entSearchHttpAgent.getHttpAgent()).toBeInstanceOf(http.Agent); + }); + + describe('fallbacks', () => { + it('initializes a http.Agent when host URL is invalid', () => { + entSearchHttpAgent.initializeHttpAgent({ + host: '##!notarealurl#$', + ssl: {}, + } as ConfigType); + expect(entSearchHttpAgent.getHttpAgent()).toBeInstanceOf(http.Agent); + }); + + it('should be an http.Agent when host URL is empty', () => { + entSearchHttpAgent.initializeHttpAgent({ + host: undefined, + ssl: {}, + } as ConfigType); + expect(entSearchHttpAgent.getHttpAgent()).toBeInstanceOf(http.Agent); + }); + }); + }); + + describe('loadCertificateAuthorities', () => { + describe('happy path', () => { + beforeEach(() => { + jest.clearAllMocks(); + (readFileSync as jest.Mock).mockImplementation((path: string) => `content-of-${path}`); + }); + + it('reads certificate authorities when ssl.certificateAuthorities is a string', () => { + const certs = entSearchHttpAgent.loadCertificateAuthorities('some-path'); + expect(readFileSync).toHaveBeenCalledTimes(1); + expect(certs).toEqual(['content-of-some-path']); + }); + + it('reads certificate authorities when ssl.certificateAuthorities is an array', () => { + const certs = entSearchHttpAgent.loadCertificateAuthorities(['some-path', 'another-path']); + expect(readFileSync).toHaveBeenCalledTimes(2); + expect(certs).toEqual(['content-of-some-path', 'content-of-another-path']); + }); + + it('does not read anything when ssl.certificateAuthorities is empty', () => { + const certs = entSearchHttpAgent.loadCertificateAuthorities(undefined); + expect(readFileSync).toHaveBeenCalledTimes(0); + expect(certs).toEqual([]); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + const realFs = jest.requireActual('fs'); + (readFileSync as jest.Mock).mockImplementation((path: string) => realFs.readFileSync(path)); + }); + + it('throws if certificateAuthorities is invalid', () => { + expect(() => entSearchHttpAgent.loadCertificateAuthorities('/invalid/ca')).toThrow( + "ENOENT: no such file or directory, open '/invalid/ca'" + ); + }); + }); + }); + + describe('getAgentOptions', () => { + it('verificationMode: none', () => { + expect(entSearchHttpAgent.getAgentOptions('none')).toEqual({ + rejectUnauthorized: false, + }); + }); + + it('verificationMode: certificate', () => { + expect(entSearchHttpAgent.getAgentOptions('certificate')).toEqual({ + rejectUnauthorized: true, + checkServerIdentity: expect.any(Function), + }); + + const { checkServerIdentity } = entSearchHttpAgent.getAgentOptions('certificate') as any; + expect(checkServerIdentity()).toEqual(undefined); + }); + + it('verificationMode: full', () => { + expect(entSearchHttpAgent.getAgentOptions('full')).toEqual({ + rejectUnauthorized: true, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.ts new file mode 100644 index 0000000000000..89210def248b3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { readFileSync } from 'fs'; +import http from 'http'; +import https from 'https'; +import { PeerCertificate } from 'tls'; + +import { ConfigType } from '../'; + +export type HttpAgent = http.Agent | https.Agent; +interface AgentOptions { + rejectUnauthorized?: boolean; + checkServerIdentity?: ((host: string, cert: PeerCertificate) => Error | undefined) | undefined; +} + +/* + * Returns an HTTP agent to be used for requests to Enterprise Search APIs + */ +class EnterpriseSearchHttpAgent { + public httpAgent: HttpAgent = new http.Agent(); + + getHttpAgent() { + return this.httpAgent; + } + + initializeHttpAgent(config: ConfigType) { + if (!config.host) return; + + try { + const parsedHost = new URL(config.host); + if (parsedHost.protocol === 'https:') { + this.httpAgent = new https.Agent({ + ca: this.loadCertificateAuthorities(config.ssl.certificateAuthorities), + ...this.getAgentOptions(config.ssl.verificationMode), + }); + } + } catch { + // Ignore URL parsing errors and fall back to the HTTP agent + } + } + + /* + * Loads custom CA certificate files and returns all certificates as an array + * This is a potentially expensive operation & why this helper is a class + * initialized once on plugin init + */ + loadCertificateAuthorities(certificates: string | string[] | undefined): string[] { + if (!certificates) return []; + + const paths = Array.isArray(certificates) ? certificates : [certificates]; + return paths.map((path) => readFileSync(path, 'utf8')); + } + + /* + * Convert verificationMode to rejectUnauthorized for more consistent config settings + * with the rest of Kibana + * @see https://github.com/elastic/kibana/blob/master/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.ts + */ + getAgentOptions(verificationMode: 'full' | 'certificate' | 'none') { + const agentOptions: AgentOptions = {}; + + switch (verificationMode) { + case 'none': + agentOptions.rejectUnauthorized = false; + break; + case 'certificate': + agentOptions.rejectUnauthorized = true; + agentOptions.checkServerIdentity = () => undefined; + break; + case 'full': + default: + agentOptions.rejectUnauthorized = true; + break; + } + + return agentOptions; + } +} + +export const entSearchHttpAgent = new EnterpriseSearchHttpAgent(); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts index 3223471e4fc1a..6ebf46abd39d3 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { mockConfig, mockLogger } from '../__mocks__'; +import { mockConfig, mockLogger, mockHttpAgent } from '../__mocks__'; import { ENTERPRISE_SEARCH_KIBANA_COOKIE, @@ -476,6 +476,7 @@ const EnterpriseSearchAPI = { headers: { Authorization: 'Basic 123', ...JSON_HEADER }, method: 'GET', body: undefined, + agent: mockHttpAgent, ...expectedParams, }); }, diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts index 2fc0a13f2ff72..597f7524808e9 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts @@ -16,13 +16,15 @@ import { Logger, } from 'src/core/server'; +import { ConfigType } from '../'; + import { ENTERPRISE_SEARCH_KIBANA_COOKIE, JSON_HEADER, READ_ONLY_MODE_HEADER, } from '../../common/constants'; -import { ConfigType } from '../index'; +import { entSearchHttpAgent } from './enterprise_search_http_agent'; interface ConstructorDependencies { config: ConfigType; @@ -77,12 +79,15 @@ export class EnterpriseSearchRequestHandler { const url = encodeURI(this.enterpriseSearchUrl) + encodedPath + queryString; // Set up API options - const { method } = request.route; - const headers = { Authorization: request.headers.authorization as string, ...JSON_HEADER }; - const body = this.getBodyAsString(request.body as object | Buffer); + const options = { + method: request.route.method as string, + headers: { Authorization: request.headers.authorization as string, ...JSON_HEADER }, + body: this.getBodyAsString(request.body as object | Buffer), + agent: entSearchHttpAgent.getHttpAgent(), + }; // Call the Enterprise Search API - const apiResponse = await fetch(url, { method, headers, body }); + const apiResponse = await fetch(url, options); // Handle response headers this.setResponseHeaders(apiResponse); diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 1b9659899097d..04bd304ee679f 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -31,6 +31,7 @@ import { registerTelemetryUsageCollector as registerESTelemetryUsageCollector } import { registerTelemetryUsageCollector as registerWSTelemetryUsageCollector } from './collectors/workplace_search/telemetry'; import { checkAccess } from './lib/check_access'; +import { entSearchHttpAgent } from './lib/enterprise_search_http_agent'; import { EnterpriseSearchRequestHandler, IEnterpriseSearchRequestHandler, @@ -81,6 +82,11 @@ export class EnterpriseSearchPlugin implements Plugin { const config = this.config; const log = this.logger; + /* + * Initialize config.ssl.certificateAuthorities file(s) - required for all API calls (+ access checks) + */ + entSearchHttpAgent.initializeHttpAgent(config); + /** * Register space/feature control */ From 9618fd7dfedf7c1b8ee869e8eb7f00fd4c2875bf Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Thu, 3 Jun 2021 11:00:12 -0400 Subject: [PATCH 25/35] [App Search] Added a persistent query tester flyout (#101071) --- .../results/add_result_flyout.test.tsx | 4 +- .../curation/results/add_result_flyout.tsx | 9 +- .../curation/results/add_result_logic.test.ts | 80 +------------ .../curation/results/add_result_logic.ts | 50 -------- .../layout/kibana_header_actions.test.tsx | 6 +- .../layout/kibana_header_actions.tsx | 10 +- .../components/query_tester/i18n.ts | 15 +++ .../components/query_tester/index.ts | 9 ++ .../query_tester/query_tester.test.tsx | 66 +++++++++++ .../components/query_tester/query_tester.tsx | 66 +++++++++++ .../query_tester/query_tester_button.test.tsx | 35 ++++++ .../query_tester/query_tester_button.tsx | 30 +++++ .../query_tester/query_tester_flyout.test.tsx | 25 ++++ .../query_tester/query_tester_flyout.tsx | 32 ++++++ .../app_search/components/search/index.ts | 8 ++ .../components/search/search_logic.test.ts | 108 ++++++++++++++++++ .../components/search/search_logic.ts | 73 ++++++++++++ .../routes/app_search/curations.test.ts | 35 ------ .../server/routes/app_search/index.ts | 2 + .../server/routes/app_search/search.test.ts | 35 ++++++ .../server/routes/app_search/search.ts | 39 +++++++ 21 files changed, 558 insertions(+), 179 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/i18n.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/search/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/search.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/search.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.test.tsx index e12267d0eb136..a0f178aca32b2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.test.tsx @@ -17,7 +17,7 @@ import { CurationResult, AddResultFlyout } from './'; describe('AddResultFlyout', () => { const values = { - dataLoading: false, + searchDataLoading: false, searchQuery: '', searchResults: [], promotedIds: [], @@ -48,7 +48,7 @@ describe('AddResultFlyout', () => { describe('search input', () => { it('renders isLoading state correctly', () => { - setMockValues({ ...values, dataLoading: true }); + setMockValues({ ...values, searchDataLoading: true }); const wrapper = shallow(); expect(wrapper.find(EuiFieldSearch).prop('isLoading')).toEqual(true); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.tsx index 6363919e32cc9..a20e4e137f899 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.tsx @@ -24,6 +24,7 @@ import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../../../shared/flash_messages'; +import { SearchLogic } from '../../../search'; import { RESULT_ACTIONS_DIRECTIONS, PROMOTE_DOCUMENT_ACTION, @@ -36,8 +37,10 @@ import { CurationLogic } from '../curation_logic'; import { AddResultLogic, CurationResult } from './'; export const AddResultFlyout: React.FC = () => { - const { searchQuery, searchResults, dataLoading } = useValues(AddResultLogic); - const { search, closeFlyout } = useActions(AddResultLogic); + const searchLogic = SearchLogic({ id: 'add-results-flyout' }); + const { searchQuery, searchResults, searchDataLoading } = useValues(searchLogic); + const { closeFlyout } = useActions(AddResultLogic); + const { search } = useActions(searchLogic); const { promotedIds, hiddenIds } = useValues(CurationLogic); const { addPromotedId, removePromotedId, addHiddenId, removeHiddenId } = useActions( @@ -63,7 +66,7 @@ export const AddResultFlyout: React.FC = () => { search(e.target.value)} - isLoading={dataLoading} + isLoading={searchDataLoading} placeholder={i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.curations.addResult.searchPlaceholder', { defaultMessage: 'Search engine documents' } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.test.ts index a722ab96fc574..e7007cdc093cb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.test.ts @@ -5,31 +5,16 @@ * 2.0. */ -import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../../../__mocks__'; +import { LogicMounter } from '../../../../../__mocks__'; import '../../../../__mocks__/engine_logic.mock'; -import { nextTick } from '@kbn/test/jest'; - import { AddResultLogic } from './'; describe('AddResultLogic', () => { const { mount } = new LogicMounter(AddResultLogic); - const { http } = mockHttpValues; - const { flashAPIErrors } = mockFlashMessageHelpers; - - const MOCK_SEARCH_RESPONSE = { - results: [ - { id: { raw: 'document-1' }, _meta: { id: 'document-1', engine: 'some-engine' } }, - { id: { raw: 'document-2' }, _meta: { id: 'document-2', engine: 'some-engine' } }, - { id: { raw: 'document-3' }, _meta: { id: 'document-3', engine: 'some-engine' } }, - ], - }; const DEFAULT_VALUES = { isFlyoutOpen: false, - dataLoading: false, - searchQuery: '', - searchResults: [], }; beforeEach(() => { @@ -51,7 +36,6 @@ describe('AddResultLogic', () => { expect(AddResultLogic.values).toEqual({ ...DEFAULT_VALUES, isFlyoutOpen: true, - searchQuery: '', }); }); }); @@ -68,67 +52,5 @@ describe('AddResultLogic', () => { }); }); }); - - describe('search', () => { - it('sets searchQuery & dataLoading to true', () => { - mount({ searchQuery: '', dataLoading: false }); - - AddResultLogic.actions.search('hello world'); - - expect(AddResultLogic.values).toEqual({ - ...DEFAULT_VALUES, - searchQuery: 'hello world', - dataLoading: true, - }); - }); - }); - - describe('onSearch', () => { - it('sets searchResults & dataLoading to false', () => { - mount({ searchResults: [], dataLoading: true }); - - AddResultLogic.actions.onSearch(MOCK_SEARCH_RESPONSE); - - expect(AddResultLogic.values).toEqual({ - ...DEFAULT_VALUES, - searchResults: MOCK_SEARCH_RESPONSE.results, - dataLoading: false, - }); - }); - }); - }); - - describe('listeners', () => { - describe('search', () => { - beforeAll(() => jest.useFakeTimers()); - afterAll(() => jest.useRealTimers()); - - it('should make a GET API call with a search query', async () => { - http.get.mockReturnValueOnce(Promise.resolve(MOCK_SEARCH_RESPONSE)); - mount(); - jest.spyOn(AddResultLogic.actions, 'onSearch'); - - AddResultLogic.actions.search('hello world'); - jest.runAllTimers(); - await nextTick(); - - expect(http.get).toHaveBeenCalledWith( - '/api/app_search/engines/some-engine/curation_search', - { query: { query: 'hello world' } } - ); - expect(AddResultLogic.actions.onSearch).toHaveBeenCalledWith(MOCK_SEARCH_RESPONSE); - }); - - it('handles errors', async () => { - http.get.mockReturnValueOnce(Promise.reject('error')); - mount(); - - AddResultLogic.actions.search('test'); - jest.runAllTimers(); - await nextTick(); - - expect(flashAPIErrors).toHaveBeenCalledWith('error'); - }); - }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.ts index 808f4c86971ee..bcf18aa9625d7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.ts @@ -7,24 +7,13 @@ import { kea, MakeLogicType } from 'kea'; -import { flashAPIErrors } from '../../../../../shared/flash_messages'; -import { HttpLogic } from '../../../../../shared/http'; - -import { EngineLogic } from '../../../engine'; -import { Result } from '../../../result/types'; - interface AddResultValues { isFlyoutOpen: boolean; - dataLoading: boolean; - searchQuery: string; - searchResults: Result[]; } interface AddResultActions { openFlyout(): void; closeFlyout(): void; - search(query: string): { query: string }; - onSearch({ results }: { results: Result[] }): { results: Result[] }; } export const AddResultLogic = kea>({ @@ -32,8 +21,6 @@ export const AddResultLogic = kea ({ openFlyout: true, closeFlyout: true, - search: (query) => ({ query }), - onSearch: ({ results }) => ({ results }), }), reducers: () => ({ isFlyoutOpen: [ @@ -43,42 +30,5 @@ export const AddResultLogic = kea false, }, ], - dataLoading: [ - false, - { - search: () => true, - onSearch: () => false, - }, - ], - searchQuery: [ - '', - { - search: (_, { query }) => query, - openFlyout: () => '', - }, - ], - searchResults: [ - [], - { - onSearch: (_, { results }) => results, - }, - ], - }), - listeners: ({ actions }) => ({ - search: async ({ query }, breakpoint) => { - await breakpoint(250); - - const { http } = HttpLogic.values; - const { engineName } = EngineLogic.values; - - try { - const response = await http.get(`/api/app_search/engines/${engineName}/curation_search`, { - query: { query }, - }); - actions.onSearch(response); - } catch (e) { - flashAPIErrors(e); - } - }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.test.tsx index 21fc2b235d83c..096d858cd1191 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiButtonEmpty } from '@elastic/eui'; +import { QueryTesterButton } from '../query_tester'; import { KibanaHeaderActions } from './kibana_header_actions'; @@ -27,7 +27,7 @@ describe('KibanaHeaderActions', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiButtonEmpty).exists()).toBe(true); + expect(wrapper.find(QueryTesterButton).exists()).toBe(true); }); it('does not render a "Query Tester" button if there is no engine available', () => { @@ -35,6 +35,6 @@ describe('KibanaHeaderActions', () => { engineName: '', }); const wrapper = shallow(); - expect(wrapper.find(EuiButtonEmpty).exists()).toBe(false); + expect(wrapper.find(QueryTesterButton).exists()).toBe(false); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.tsx index b2e810962df02..e23c8ff8f0f0c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.tsx @@ -9,10 +9,10 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { EngineLogic } from '../engine'; +import { QueryTesterButton } from '../query_tester'; export const KibanaHeaderActions: React.FC = () => { const { engineName } = useValues(EngineLogic); @@ -21,11 +21,7 @@ export const KibanaHeaderActions: React.FC = () => { {engineName && ( - - {i18n.translate('xpack.enterpriseSearch.appSearch.engine.queryTesterButtonLabel', { - defaultMessage: 'Query tester', - })} - + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/i18n.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/i18n.ts new file mode 100644 index 0000000000000..a1b1f6769beaf --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/i18n.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const QUERY_TESTER_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.queryTesterTitle', + { + defaultMessage: 'Query tester', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/index.ts new file mode 100644 index 0000000000000..b2b8ad0dd1255 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { QueryTesterFlyout } from './query_tester_flyout'; +export { QueryTesterButton } from './query_tester_button'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.test.tsx new file mode 100644 index 0000000000000..160be70cbbfc9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockValues, setMockActions } from '../../../__mocks__/kea.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiFieldSearch } from '@elastic/eui'; + +import { SchemaType } from '../../../shared/schema/types'; +import { Result } from '../result'; + +import { QueryTester } from './query_tester'; + +describe('QueryTester', () => { + const values = { + searchQuery: 'foo', + searchResults: [{ id: { raw: '1' } }, { id: { raw: '2' } }, { id: { raw: '3' } }], + searchDataLoading: false, + engine: { + schema: { + foo: SchemaType.Text, + }, + }, + }; + + const actions = { + search: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + + it('renders with a search box and results', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiFieldSearch).prop('value')).toBe('foo'); + expect(wrapper.find(EuiFieldSearch).prop('isLoading')).toBe(false); + expect(wrapper.find(Result)).toHaveLength(3); + }); + + it('will update the search term in state when the user updates the search box', () => { + const wrapper = shallow(); + wrapper.find(EuiFieldSearch).simulate('change', { target: { value: 'bar' } }); + expect(actions.search).toHaveBeenCalledWith('bar'); + }); + + it('will render an empty prompt when there are no results', () => { + setMockValues({ + ...values, + searchResults: [], + }); + const wrapper = shallow(); + wrapper.find(EuiFieldSearch).simulate('change', { target: { value: 'bar' } }); + expect(wrapper.find(Result)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.tsx new file mode 100644 index 0000000000000..374b6bd1a77b3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiEmptyPrompt, EuiFieldSearch, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { EngineLogic } from '../engine'; +import { Result } from '../result'; +import { SearchLogic } from '../search'; + +export const QueryTester: React.FC = () => { + const logic = SearchLogic({ id: 'query-tester' }); + const { searchQuery, searchResults, searchDataLoading } = useValues(logic); + const { search } = useActions(logic); + const { engine } = useValues(EngineLogic); + + return ( + <> + search(e.target.value)} + isLoading={searchDataLoading} + placeholder={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.queryTester.searchPlaceholder', + { defaultMessage: 'Search engine documents' } + )} + fullWidth + autoFocus + /> + + {searchResults.length > 0 ? ( + searchResults.map((result) => { + const id = result.id.raw; + + return ( + + + + + ); + }) + ) : ( + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.test.tsx new file mode 100644 index 0000000000000..4d2c3286ff516 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButtonEmpty } from '@elastic/eui'; + +import { QueryTesterFlyout, QueryTesterButton } from '.'; + +describe('QueryTesterButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiButtonEmpty).exists()).toBe(true); + expect(wrapper.find(QueryTesterFlyout).exists()).toBe(false); + }); + + it('will render a QueryTesterFlyout when pressed and close on QueryTesterFlyout close', () => { + const wrapper = shallow(); + wrapper.find(EuiButtonEmpty).simulate('click'); + expect(wrapper.find(QueryTesterFlyout).exists()).toBe(true); + + wrapper.find(QueryTesterFlyout).simulate('close'); + expect(wrapper.find(QueryTesterFlyout).exists()).toBe(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.tsx new file mode 100644 index 0000000000000..89381914b6db6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; + +import { EuiButtonEmpty } from '@elastic/eui'; + +import { QUERY_TESTER_TITLE } from './i18n'; + +import { QueryTesterFlyout } from '.'; + +export const QueryTesterButton: React.FC = () => { + const [isQueryTesterOpen, setIsQueryTesterOpen] = useState(false); + return ( + <> + setIsQueryTesterOpen(!isQueryTesterOpen)} + > + {QUERY_TESTER_TITLE} + + {isQueryTesterOpen && setIsQueryTesterOpen(false)} />} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.test.tsx new file mode 100644 index 0000000000000..8c25589f04639 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.test.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiFlyout } from '@elastic/eui'; + +import { QueryTester } from './query_tester'; +import { QueryTesterFlyout } from './query_tester_flyout'; + +describe('QueryTesterFlyout', () => { + const onClose = jest.fn(); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(QueryTester).exists()).toBe(true); + expect(wrapper.find(EuiFlyout).prop('onClose')).toEqual(onClose); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.tsx new file mode 100644 index 0000000000000..d419bef472de3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; + +import { QUERY_TESTER_TITLE } from './i18n'; +import { QueryTester } from './query_tester'; + +interface Props { + onClose: () => void; +} + +export const QueryTesterFlyout: React.FC = ({ onClose }) => { + return ( + + + +

{QUERY_TESTER_TITLE}

+
+
+ + + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/index.ts new file mode 100644 index 0000000000000..68cad7b0a0c77 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SearchLogic } from './search_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.test.ts new file mode 100644 index 0000000000000..784ebd0aad0cb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../__mocks__/engine_logic.mock'; + +import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; + +import { SearchLogic } from './search_logic'; + +describe('SearchLogic', () => { + const { mount } = new LogicMounter(SearchLogic); + const { http } = mockHttpValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + + const MOCK_SEARCH_RESPONSE = { + results: [ + { id: { raw: 'document-1' }, _meta: { id: 'document-1', engine: 'some-engine' } }, + { id: { raw: 'document-2' }, _meta: { id: 'document-2', engine: 'some-engine' } }, + { id: { raw: 'document-3' }, _meta: { id: 'document-3', engine: 'some-engine' } }, + ], + }; + + const DEFAULT_VALUES = { + searchDataLoading: false, + searchQuery: '', + searchResults: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mountLogic = (values: object = {}) => mount(values, { id: '1' }); + + it('has expected default values', () => { + const logic = mountLogic(); + expect(logic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('search', () => { + it('sets searchQuery & searchDataLoading to true', () => { + const logic = mountLogic({ searchQuery: '', searchDataLoading: false }); + + logic.actions.search('hello world'); + + expect(logic.values).toEqual({ + ...DEFAULT_VALUES, + searchQuery: 'hello world', + searchDataLoading: true, + }); + }); + }); + + describe('onSearch', () => { + it('sets searchResults & searchDataLoading to false', () => { + const logic = mountLogic({ searchResults: [], searchDataLoading: true }); + + logic.actions.onSearch(MOCK_SEARCH_RESPONSE); + + expect(logic.values).toEqual({ + ...DEFAULT_VALUES, + searchResults: MOCK_SEARCH_RESPONSE.results, + searchDataLoading: false, + }); + }); + }); + }); + + describe('listeners', () => { + describe('search', () => { + beforeAll(() => jest.useFakeTimers()); + afterAll(() => jest.useRealTimers()); + + it('should make a GET API call with a search query', async () => { + http.get.mockReturnValueOnce(Promise.resolve(MOCK_SEARCH_RESPONSE)); + const logic = mountLogic(); + jest.spyOn(logic.actions, 'onSearch'); + + logic.actions.search('hello world'); + jest.runAllTimers(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/search', { + query: { query: 'hello world' }, + }); + expect(logic.actions.onSearch).toHaveBeenCalledWith(MOCK_SEARCH_RESPONSE); + }); + + it('handles errors', async () => { + http.get.mockReturnValueOnce(Promise.reject('error')); + const logic = mountLogic(); + + logic.actions.search('test'); + jest.runAllTimers(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.ts new file mode 100644 index 0000000000000..d9b7d575ae0e1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors } from '../../../shared/flash_messages'; + +import { HttpLogic } from '../../../shared/http'; +import { EngineLogic } from '../engine'; + +import { Result } from '../result/types'; + +interface SearchValues { + searchDataLoading: boolean; + searchQuery: string; + searchResults: Result[]; +} + +interface SearchActions { + search(query: string): { query: string }; + onSearch({ results }: { results: Result[] }): { results: Result[] }; +} + +export const SearchLogic = kea>({ + key: (props) => props.id, + path: (key: string) => ['enterprise_search', 'app_search', 'search_logic', key], + actions: () => ({ + search: (query) => ({ query }), + onSearch: ({ results }) => ({ results }), + }), + reducers: () => ({ + searchDataLoading: [ + false, + { + search: () => true, + onSearch: () => false, + }, + ], + searchQuery: [ + '', + { + search: (_, { query }) => query, + }, + ], + searchResults: [ + [], + { + onSearch: (_, { results }) => results, + }, + ], + }), + listeners: ({ actions }) => ({ + search: async ({ query }, breakpoint) => { + await breakpoint(250); + + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const response = await http.get(`/api/app_search/engines/${engineName}/search`, { + query: { query }, + }); + actions.onSearch(response); + } catch (e) { + flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts index 045d3d12e8bcf..08e123a98cd31 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts @@ -229,39 +229,4 @@ describe('curations routes', () => { }); }); }); - - describe('GET /api/app_search/engines/{engineName}/curation_search', () => { - let mockRouter: MockRouter; - - beforeEach(() => { - jest.clearAllMocks(); - mockRouter = new MockRouter({ - method: 'get', - path: '/api/app_search/engines/{engineName}/curation_search', - }); - - registerCurationsRoutes({ - ...mockDependencies, - router: mockRouter.router, - }); - }); - - it('creates a request handler', () => { - expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v1/engines/:engineName/search.json', - }); - }); - - describe('validates', () => { - it('required query param', () => { - const request = { query: { query: 'some query' } }; - mockRouter.shouldValidate(request); - }); - - it('missing query', () => { - const request = { query: {} }; - mockRouter.shouldThrow(request); - }); - }); - }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index 18de4580318a2..6ccdce0935d93 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -17,6 +17,7 @@ import { registerOnboardingRoutes } from './onboarding'; import { registerResultSettingsRoutes } from './result_settings'; import { registerRoleMappingsRoutes } from './role_mappings'; import { registerSchemaRoutes } from './schema'; +import { registerSearchRoutes } from './search'; import { registerSearchSettingsRoutes } from './search_settings'; import { registerSearchUIRoutes } from './search_ui'; import { registerSettingsRoutes } from './settings'; @@ -31,6 +32,7 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerDocumentsRoutes(dependencies); registerDocumentRoutes(dependencies); registerSchemaRoutes(dependencies); + registerSearchRoutes(dependencies); registerSourceEnginesRoutes(dependencies); registerCurationsRoutes(dependencies); registerSynonymsRoutes(dependencies); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search.test.ts new file mode 100644 index 0000000000000..9262dd9e574ad --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; + +import { registerSearchRoutes } from './search'; + +describe('search routes', () => { + describe('GET /api/app_search/engines/{engineName}/search', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{engineName}/schema', + }); + + registerSearchRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v1/engines/:engineName/search.json', + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search.ts new file mode 100644 index 0000000000000..016f71e7e65b8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerSearchRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/app_search/engines/{engineName}/search', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + query: schema.object({ + query: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v1/engines/:engineName/search.json', + }) + ); +} From c260407640865a60035102b6a913de016b8f1dec Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 3 Jun 2021 17:16:42 +0200 Subject: [PATCH 26/35] added screenshot_mode to app services ownership (#101257) --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b071e06f1bc54..9ccf660946dd5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -54,6 +54,7 @@ /src/plugins/share/ @elastic/kibana-app-services /src/plugins/ui_actions/ @elastic/kibana-app-services /src/plugins/index_pattern_field_editor @elastic/kibana-app-services +/src/plugins/screenshot_mode @elastic/kibana-app-services /x-pack/examples/ui_actions_enhanced_examples/ @elastic/kibana-app-services /x-pack/plugins/data_enhanced/ @elastic/kibana-app-services /x-pack/plugins/embeddable_enhanced/ @elastic/kibana-app-services From a9a90131208604af79e5476ecc10e73433af934a Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 3 Jun 2021 18:17:14 +0300 Subject: [PATCH 27/35] [Pie] New implementation of the vislib pie chart with es-charts (#83929) * es lint fix * Add formatter on the buckets labels * Config the new plugin, toggle tooltip * Aff filtering on slice click * minor fixes * fix eslint error * use legacy palette for now * Add color picker to legend colors * Fix ts error * Add legend actions * Fix bug on Color Picker and remove local state as it is unecessary * Fix some bugs on colorPicker * Add setting for the user to select between the legacy palette or the eui ones * small enhancements, treat empty labels with (empty) * Fix color picker bugs with multiple layers * fixes on internationalization * Create migration script for pie chart and legacy palette * Add unit tests (wip) and a small refactoring * Add unit tests and move some things to utils, useMemo and useCallback where it should * Add jest config file * Fix jest test * fix api integration failure * Fix to_ast_esaggs for new pie plugin * Close legendColorPicker popover when user clicks outside * Fix warning * Remove getter/setters and refactor * Remove kibanaUtils from pie plugin as it is not needed * Add new values to the migration script * Fix bug on not changing color for expty string * remove from migration script as they don't need it * Fix editor settings for old and new implementation * fix uistate type * Disable split chart for the new plugin for now * Remove temp folder * Move translations to the pie plugin * Fix CI failures * Add unit test for the editor config * Types cleanup * Fix types vol2 * Minor improvements * Display data on the inspector * Cleanup translations * Add telemetry for new editor pie options * Fix missing translation * Use Eui component to detect click outside the color picker popover * Retrieve color picker from editor and syncColors on dashboard * Lazy load palette service * Add the new plugin to ts references, fix tests, refactor * Fix ci failure * Move charts library switch to vislib plugin * Remove cyclic dependencies * Modify license headers * Move charts library switch to visualizations plugin * Fix i18n on the switch moved to visualizations plugin * Update license * Fix tests * Fix bugs created by new charts version * Fix the i18n switch problem * Update the migration script * Identify if colorIsOverwritten or not * Small multiples, missing the click event * Fixes the UX for small multiples part1 * Distinct colors per slice implementation * Fix ts references problem * Fix some small multiples bugs * Add unit tests * Fix ts ref problem * Fix TS problems caused by es-charts new version * Update the sample pie visualizations with the new eui palette * Allows filtering by the small multiples value * Apply sortPredicate on partition layers * Fix vilib test * Enable functional tests for new plugin * Fix some functional tests * Minor fix * Fix functional tests * Fix dashboard tests * Fix all dashboard tests * Apply some improvements * Explicit params instead of visConfig Json * Fix i18n failure * Add top level setting * Minor fix * Fix jest tests * Address PR comments * Fix i18n error * fix functional test * Add an icon tip on the distinct colors per slice switch * Fix some of the PR comments * Address more PR comments * Small fix * Functional test * address some PR comments * Add padding to the pie container * Add a max width to the container * Improve dashboard functional test * Move the labels expression function to the pie plugin * Fix i18n * Fix functional test * Apply PR comments * Do not forget to also add the migration to them embeddable too :D * Fix distinct colors for IP range layer * Remove console errors * Fix small mulitples colors with multiple layers * Fix lint problem * Fix problems created from merging with master * Address PR comments * Change the config in order the pie chart to not appear so huge on the editor * Address PR comments * Change the max percentage digits to 4 * Change the max size to 1000 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 1 + .i18nrc.json | 1 + docs/developer/plugin-list.asciidoc | 4 + packages/kbn-optimizer/limits.yml | 1 + src/plugins/charts/public/index.ts | 1 + .../public/static/components/color_picker.tsx | 31 +- .../data_sets/ecommerce/saved_objects.ts | 2 +- .../data_sets/flights/saved_objects.ts | 2 +- .../data_sets/logs/saved_objects.ts | 2 +- src/plugins/vis_type_pie/README.md | 1 + .../server => vis_type_pie/common}/index.ts | 4 +- src/plugins/vis_type_pie/jest.config.js | 13 + src/plugins/vis_type_pie/kibana.json | 8 + .../public/__snapshots__/pie_fn.test.ts.snap | 73 + .../public/__snapshots__/to_ast.test.ts.snap | 122 ++ src/plugins/vis_type_pie/public/chart.scss | 18 + .../public/components/chart_split.tsx | 67 + .../vis_type_pie/public/editor/collections.ts | 40 + .../public/editor/components/index.tsx | 26 + .../public/editor/components/pie.test.tsx | 124 ++ .../public/editor/components/pie.tsx | 287 ++++ .../components/truncate_labels.test.tsx | 51 + .../editor/components/truncate_labels.tsx | 43 + .../vis_type_pie/public/editor/positions.ts | 37 + .../public/expression_functions/pie_labels.ts | 113 ++ src/plugins/vis_type_pie/public/index.ts | 14 + src/plugins/vis_type_pie/public/mocks.ts | 328 ++++ .../public/pie_component.test.tsx | 123 ++ .../vis_type_pie/public/pie_component.tsx | 355 +++++ .../vis_type_pie/public/pie_fn.test.ts | 53 + src/plugins/vis_type_pie/public/pie_fn.ts | 153 ++ .../vis_type_pie/public/pie_renderer.tsx | 63 + src/plugins/vis_type_pie/public/plugin.ts | 73 + .../public/sample_vis.test.mocks.ts | 1332 +++++++++++++++++ .../vis_type_pie/public/to_ast.test.ts | 31 + src/plugins/vis_type_pie/public/to_ast.ts | 71 + .../vis_type_pie/public/to_ast_esaggs.ts | 33 + .../vis_type_pie/public/types/index.ts | 9 + .../vis_type_pie/public/types/types.ts | 96 ++ .../public/utils/filter_helpers.test.ts | 98 ++ .../public/utils/filter_helpers.ts | 89 ++ .../public/utils/get_color_picker.test.tsx | 116 ++ .../public/utils/get_color_picker.tsx | 121 ++ .../public/utils/get_columns.test.ts | 222 +++ .../vis_type_pie/public/utils/get_columns.ts | 43 + .../vis_type_pie/public/utils/get_config.ts | 76 + .../public/utils/get_distinct_series.test.ts | 30 + .../public/utils/get_distinct_series.ts | 31 + .../public/utils/get_layers.test.ts | 114 ++ .../vis_type_pie/public/utils/get_layers.ts | 186 +++ .../public/utils/get_legend_actions.tsx | 117 ++ .../utils/get_split_dimension_accessor.ts | 31 + .../vis_type_pie/public/utils/index.ts | 16 + .../vis_type_pie/public/vis_type/index.ts | 14 + .../vis_type_pie/public/vis_type/pie.ts | 98 ++ src/plugins/vis_type_pie/tsconfig.json | 24 + src/plugins/vis_type_vislib/kibana.json | 2 +- .../public/editor/components/index.tsx | 6 - .../public/editor/components/pie.tsx | 97 -- src/plugins/vis_type_vislib/public/pie.ts | 75 +- src/plugins/vis_type_vislib/public/plugin.ts | 5 +- .../vis_type_vislib/public/to_ast_pie.test.ts | 2 +- .../build_hierarchical_data.test.ts | 4 +- .../hierarchical/build_hierarchical_data.ts | 17 +- src/plugins/vis_type_vislib/tsconfig.json | 1 + src/plugins/vis_type_xy/common/index.ts | 2 - src/plugins/vis_type_xy/kibana.json | 1 - src/plugins/vis_type_xy/public/plugin.ts | 2 +- .../public/sample_vis.test.mocks.ts | 1319 ---------------- src/plugins/vis_type_xy/server/plugin.ts | 46 - .../visualizations/common/constants.ts | 1 + src/plugins/visualizations/kibana.json | 3 +- .../visualize_embeddable_factory.ts | 10 +- .../visualization_common_migrations.ts | 23 + ...ualization_saved_object_migrations.test.ts | 48 + .../visualization_saved_object_migrations.ts | 26 +- src/plugins/visualizations/server/plugin.ts | 23 +- test/examples/embeddables/dashboard.ts | 6 +- .../apps/dashboard/dashboard_state.ts | 16 +- test/functional/apps/visualize/_pie_chart.ts | 31 +- test/functional/apps/visualize/index.ts | 1 + .../page_objects/visualize_chart_page.ts | 122 +- .../page_objects/visualize_editor_page.ts | 8 + .../services/visualizations/pie_chart.ts | 91 +- tsconfig.json | 1 + tsconfig.refs.json | 1 + .../translations/translations/ja-JP.json | 26 +- .../translations/translations/zh-CN.json | 26 +- .../dashboard_to_dashboard_drilldown.ts | 6 +- 89 files changed, 5602 insertions(+), 1678 deletions(-) create mode 100644 src/plugins/vis_type_pie/README.md rename src/plugins/{vis_type_xy/server => vis_type_pie/common}/index.ts (76%) create mode 100644 src/plugins/vis_type_pie/jest.config.js create mode 100644 src/plugins/vis_type_pie/kibana.json create mode 100644 src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap create mode 100644 src/plugins/vis_type_pie/public/__snapshots__/to_ast.test.ts.snap create mode 100644 src/plugins/vis_type_pie/public/chart.scss create mode 100644 src/plugins/vis_type_pie/public/components/chart_split.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/collections.ts create mode 100644 src/plugins/vis_type_pie/public/editor/components/index.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/components/pie.test.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/components/pie.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/components/truncate_labels.test.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/positions.ts create mode 100644 src/plugins/vis_type_pie/public/expression_functions/pie_labels.ts create mode 100644 src/plugins/vis_type_pie/public/index.ts create mode 100644 src/plugins/vis_type_pie/public/mocks.ts create mode 100644 src/plugins/vis_type_pie/public/pie_component.test.tsx create mode 100644 src/plugins/vis_type_pie/public/pie_component.tsx create mode 100644 src/plugins/vis_type_pie/public/pie_fn.test.ts create mode 100644 src/plugins/vis_type_pie/public/pie_fn.ts create mode 100644 src/plugins/vis_type_pie/public/pie_renderer.tsx create mode 100644 src/plugins/vis_type_pie/public/plugin.ts create mode 100644 src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts create mode 100644 src/plugins/vis_type_pie/public/to_ast.test.ts create mode 100644 src/plugins/vis_type_pie/public/to_ast.ts create mode 100644 src/plugins/vis_type_pie/public/to_ast_esaggs.ts create mode 100644 src/plugins/vis_type_pie/public/types/index.ts create mode 100644 src/plugins/vis_type_pie/public/types/types.ts create mode 100644 src/plugins/vis_type_pie/public/utils/filter_helpers.test.ts create mode 100644 src/plugins/vis_type_pie/public/utils/filter_helpers.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_color_picker.test.tsx create mode 100644 src/plugins/vis_type_pie/public/utils/get_color_picker.tsx create mode 100644 src/plugins/vis_type_pie/public/utils/get_columns.test.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_columns.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_config.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_distinct_series.test.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_distinct_series.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_layers.test.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_layers.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_legend_actions.tsx create mode 100644 src/plugins/vis_type_pie/public/utils/get_split_dimension_accessor.ts create mode 100644 src/plugins/vis_type_pie/public/utils/index.ts create mode 100644 src/plugins/vis_type_pie/public/vis_type/index.ts create mode 100644 src/plugins/vis_type_pie/public/vis_type/pie.ts create mode 100644 src/plugins/vis_type_pie/tsconfig.json delete mode 100644 src/plugins/vis_type_vislib/public/editor/components/pie.tsx delete mode 100644 src/plugins/vis_type_xy/server/plugin.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9ccf660946dd5..68fadd4958cba 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -24,6 +24,7 @@ /src/plugins/vis_type_vega/ @elastic/kibana-app /src/plugins/vis_type_vislib/ @elastic/kibana-app /src/plugins/vis_type_xy/ @elastic/kibana-app +/src/plugins/vis_type_pie/ @elastic/kibana-app /src/plugins/visualize/ @elastic/kibana-app /src/plugins/visualizations/ @elastic/kibana-app /packages/kbn-tinymath/ @elastic/kibana-app diff --git a/.i18nrc.json b/.i18nrc.json index 57dffa4147e52..ad91042a2172d 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -56,6 +56,7 @@ "visTypeVega": "src/plugins/vis_type_vega", "visTypeVislib": "src/plugins/vis_type_vislib", "visTypeXy": "src/plugins/vis_type_xy", + "visTypePie": "src/plugins/vis_type_pie", "visualizations": "src/plugins/visualizations", "visualize": "src/plugins/visualize", "apmOss": "src/plugins/apm_oss", diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 087626240ff33..7d06562547f70 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -265,6 +265,10 @@ The plugin exposes the static DefaultEditorController class to consume. |Contains the metric visualization. +|{kib-repo}blob/{branch}/src/plugins/vis_type_pie/README.md[visTypePie] +|Contains the pie chart implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy charts library advanced setting. + + |{kib-repo}blob/{branch}/src/plugins/vis_type_table/README.md[visTypeTable] |Contains the data table visualization, that allows presenting data in a simple table format. diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 6ccf6269751b1..3427eee4b5c0b 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -87,6 +87,7 @@ pageLoadAssetSize: visDefaultEditor: 50178 visTypeMarkdown: 30896 visTypeMetric: 42790 + visTypePie: 34051 visTypeTable: 94934 visTypeTagcloud: 37575 visTypeTimelion: 68883 diff --git a/src/plugins/charts/public/index.ts b/src/plugins/charts/public/index.ts index b42407bb10365..cc1a54c2e25b0 100644 --- a/src/plugins/charts/public/index.ts +++ b/src/plugins/charts/public/index.ts @@ -14,6 +14,7 @@ export { ChartsPluginSetup, ChartsPluginStart } from './plugin'; export * from './static'; export * from './services/palettes/types'; +export { lightenColor } from './services/palettes/lighten_color'; export { PaletteOutput, CustomPaletteArguments, diff --git a/src/plugins/charts/public/static/components/color_picker.tsx b/src/plugins/charts/public/static/components/color_picker.tsx index 4974400a3767a..813748accd8fd 100644 --- a/src/plugins/charts/public/static/components/color_picker.tsx +++ b/src/plugins/charts/public/static/components/color_picker.tsx @@ -18,7 +18,7 @@ import { EuiFlexGroup, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; - +import { lightenColor } from '../../services/palettes/lighten_color'; import './color_picker.scss'; export const legacyColors: string[] = [ @@ -105,6 +105,14 @@ interface ColorPickerProps { * Callback for onKeyPress event */ onKeyDown?: (e: React.KeyboardEvent) => void; + /** + * Optional define the series maxDepth + */ + maxDepth?: number; + /** + * Optional define the layer index + */ + layerIndex?: number; } const euiColors = euiPaletteColorBlind({ rotations: 4, order: 'group' }); @@ -115,6 +123,8 @@ export const ColorPicker = ({ useLegacyColors = true, colorIsOverwritten = true, onKeyDown, + maxDepth, + layerIndex, }: ColorPickerProps) => { const legendColors = useLegacyColors ? legacyColors : euiColors; @@ -159,13 +169,18 @@ export const ColorPicker = ({ ))}
- {legendColors.some((c) => c === selectedColor) && colorIsOverwritten && ( - - onChange(null, e)}> - - - - )} + {legendColors.some( + (c) => + c === selectedColor || + (layerIndex && maxDepth && lightenColor(c, layerIndex, maxDepth) === selectedColor) + ) && + colorIsOverwritten && ( + + onChange(null, e)}> + + + + )} ); }; diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts index dc5831aa00a0b..a12a2ff195211 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts @@ -45,7 +45,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[eCommerce] Sales by Gender', }), visState: - '{"title":"[eCommerce] Sales by Gender","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"customer_gender","size":5,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', + '{"title":"[eCommerce] Sales by Gender","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100},"palette":{"type":"palette","name":"default"}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"customer_gender","size":5,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', uiStateJSON: '{}', description: '', version: 1, diff --git a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts index 1fa19189b8c84..05a3d012d707c 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts @@ -100,7 +100,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[Flights] Airline Carrier', }), visState: - '{"title":"[Flights] Airline Carrier","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"Carrier","size":5,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', + '{"title":"[Flights] Airline Carrier","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100},"palette":{"type":"palette","name":"default"}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"Carrier","size":5,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', uiStateJSON: '{"vis":{"legendOpen":false}}', description: '', version: 1, diff --git a/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts index 4a17f96bf89ba..661e6ca0ce50f 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts @@ -234,7 +234,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[Logs] Visitors by OS', }), visState: - '{"title":"[Logs] Visitors by OS","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"machine.os.keyword","otherBucket":true,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing","size":10,"order":"desc","orderBy":"1"}}]}', + '{"title":"[Logs] Visitors by OS","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100},"palette":{"type":"palette","name":"default"}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"machine.os.keyword","otherBucket":true,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing","size":10,"order":"desc","orderBy":"1"}}]}', uiStateJSON: '{}', description: '', version: 1, diff --git a/src/plugins/vis_type_pie/README.md b/src/plugins/vis_type_pie/README.md new file mode 100644 index 0000000000000..41b8131a5381d --- /dev/null +++ b/src/plugins/vis_type_pie/README.md @@ -0,0 +1 @@ +Contains the pie chart implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy charts library advanced setting. \ No newline at end of file diff --git a/src/plugins/vis_type_xy/server/index.ts b/src/plugins/vis_type_pie/common/index.ts similarity index 76% rename from src/plugins/vis_type_xy/server/index.ts rename to src/plugins/vis_type_pie/common/index.ts index bfd8b7d28a98d..1aa1680530b32 100644 --- a/src/plugins/vis_type_xy/server/index.ts +++ b/src/plugins/vis_type_pie/common/index.ts @@ -6,6 +6,4 @@ * Side Public License, v 1. */ -import { VisTypeXyServerPlugin } from './plugin'; - -export const plugin = () => new VisTypeXyServerPlugin(); +export const DEFAULT_PERCENT_DECIMALS = 2; diff --git a/src/plugins/vis_type_pie/jest.config.js b/src/plugins/vis_type_pie/jest.config.js new file mode 100644 index 0000000000000..e4900ef4a35c8 --- /dev/null +++ b/src/plugins/vis_type_pie/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/vis_type_pie'], +}; diff --git a/src/plugins/vis_type_pie/kibana.json b/src/plugins/vis_type_pie/kibana.json new file mode 100644 index 0000000000000..c2d51fba8260d --- /dev/null +++ b/src/plugins/vis_type_pie/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "visTypePie", + "version": "kibana", + "ui": true, + "requiredPlugins": ["charts", "data", "expressions", "visualizations", "usageCollection"], + "requiredBundles": ["visDefaultEditor"] + } + \ No newline at end of file diff --git a/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap b/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap new file mode 100644 index 0000000000000..dc83d9fdf48ac --- /dev/null +++ b/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`interpreter/functions#pie returns an object with the correct structure 1`] = ` +Object { + "as": "pie_vis", + "type": "render", + "value": Object { + "params": Object { + "listenOnChange": true, + }, + "syncColors": false, + "visConfig": Object { + "addLegend": true, + "addTooltip": true, + "buckets": undefined, + "dimensions": Object { + "buckets": undefined, + "metric": Object { + "accessor": 0, + "aggType": "count", + "format": Object { + "id": "number", + }, + "params": Object {}, + }, + "splitColumn": undefined, + "splitRow": undefined, + }, + "distinctColors": false, + "isDonut": true, + "labels": Object { + "percentDecimals": 2, + "position": "default", + "show": false, + "truncate": 100, + "values": true, + "valuesFormat": "percent", + }, + "legendPosition": "right", + "metric": Object { + "accessor": 0, + "aggType": "count", + "format": Object { + "id": "number", + }, + "params": Object {}, + }, + "nestedLegend": true, + "palette": Object { + "name": "kibana_palette", + "type": "palette", + }, + "splitColumn": undefined, + "splitRow": undefined, + }, + "visData": Object { + "columns": Array [ + Object { + "id": "col-0-1", + "name": "Count", + }, + ], + "rows": Array [ + Object { + "col-0-1": 0, + }, + ], + "type": "datatable", + }, + "visType": "pie", + }, +} +`; diff --git a/src/plugins/vis_type_pie/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_type_pie/public/__snapshots__/to_ast.test.ts.snap new file mode 100644 index 0000000000000..0c8398a142027 --- /dev/null +++ b/src/plugins/vis_type_pie/public/__snapshots__/to_ast.test.ts.snap @@ -0,0 +1,122 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`vis type pie vis toExpressionAst function should match basic snapshot 1`] = ` +Object { + "chain": Array [ + Object { + "arguments": Object { + "aggs": Array [], + "index": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "id": Array [ + "123", + ], + }, + "function": "indexPatternLoad", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "metricsAtAllLevels": Array [ + true, + ], + "partialRows": Array [ + false, + ], + }, + "function": "esaggs", + "type": "function", + }, + Object { + "arguments": Object { + "addLegend": Array [ + true, + ], + "addTooltip": Array [ + true, + ], + "buckets": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + 1, + ], + "format": Array [ + "terms", + ], + "formatParams": Array [ + "{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}", + ], + }, + "function": "visdimension", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "isDonut": Array [ + true, + ], + "labels": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "lastLevel": Array [ + true, + ], + "show": Array [ + true, + ], + "truncate": Array [ + 100, + ], + "values": Array [ + true, + ], + }, + "function": "pielabels", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "legendPosition": Array [ + "right", + ], + "metric": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + 0, + ], + "format": Array [ + "number", + ], + }, + "function": "visdimension", + "type": "function", + }, + ], + "type": "expression", + }, + ], + }, + "function": "pie_vis", + "type": "function", + }, + ], + "type": "expression", +} +`; diff --git a/src/plugins/vis_type_pie/public/chart.scss b/src/plugins/vis_type_pie/public/chart.scss new file mode 100644 index 0000000000000..8c098b13581f5 --- /dev/null +++ b/src/plugins/vis_type_pie/public/chart.scss @@ -0,0 +1,18 @@ +.pieChart__wrapper, +.pieChart__container { + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; +} + +.pieChart__container { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + padding: $euiSizeS; + margin-left: auto; + margin-right: auto; +} diff --git a/src/plugins/vis_type_pie/public/components/chart_split.tsx b/src/plugins/vis_type_pie/public/components/chart_split.tsx new file mode 100644 index 0000000000000..46f841113c03d --- /dev/null +++ b/src/plugins/vis_type_pie/public/components/chart_split.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { Accessor, AccessorFn, GroupBy, GroupBySort, SmallMultiples } from '@elastic/charts'; +import { DatatableColumn } from '../../../expressions/public'; +import { SplitDimensionParams } from '../types'; + +interface ChartSplitProps { + splitColumnAccessor?: Accessor | AccessorFn; + splitRowAccessor?: Accessor | AccessorFn; + splitDimension?: DatatableColumn; +} + +const CHART_SPLIT_ID = '__pie_chart_split__'; +export const SMALL_MULTIPLES_ID = '__pie_chart_sm__'; + +export const ChartSplit = ({ + splitColumnAccessor, + splitRowAccessor, + splitDimension, +}: ChartSplitProps) => { + if (!splitColumnAccessor && !splitRowAccessor) return null; + let sort: GroupBySort = 'alphaDesc'; + if (splitDimension?.meta?.params?.id === 'terms') { + const params = splitDimension?.meta?.sourceParams?.params as SplitDimensionParams; + sort = params?.order === 'asc' ? 'alphaAsc' : 'alphaDesc'; + } + + return ( + <> + { + const splitTypeAccessor = splitColumnAccessor || splitRowAccessor; + if (splitTypeAccessor) { + return typeof splitTypeAccessor === 'function' + ? splitTypeAccessor(datum) + : datum[splitTypeAccessor]; + } + return spec.id; + }} + sort={sort} + /> + + + ); +}; diff --git a/src/plugins/vis_type_pie/public/editor/collections.ts b/src/plugins/vis_type_pie/public/editor/collections.ts new file mode 100644 index 0000000000000..d65e933a8835c --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/collections.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { i18n } from '@kbn/i18n'; +import { LabelPositions, ValueFormats } from '../types'; + +export const getLabelPositions = [ + { + text: i18n.translate('visTypePie.labelPositions.insideText', { + defaultMessage: 'Inside', + }), + value: LabelPositions.INSIDE, + }, + { + text: i18n.translate('visTypePie.labelPositions.insideOrOutsideText', { + defaultMessage: 'Inside or outside', + }), + value: LabelPositions.DEFAULT, + }, +]; + +export const getValuesFormats = [ + { + text: i18n.translate('visTypePie.valuesFormats.percent', { + defaultMessage: 'Show percent', + }), + value: ValueFormats.PERCENT, + }, + { + text: i18n.translate('visTypePie.valuesFormats.value', { + defaultMessage: 'Show value', + }), + value: ValueFormats.VALUE, + }, +]; diff --git a/src/plugins/vis_type_pie/public/editor/components/index.tsx b/src/plugins/vis_type_pie/public/editor/components/index.tsx new file mode 100644 index 0000000000000..6bc31208fbdb0 --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/components/index.tsx @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { lazy } from 'react'; +import { VisEditorOptionsProps } from '../../../../visualizations/public'; +import { PieVisParams, PieTypeProps } from '../../types'; + +const PieOptionsLazy = lazy(() => import('./pie')); + +export const getPieOptions = ({ + showElasticChartsOptions, + palettes, + trackUiMetric, +}: PieTypeProps) => (props: VisEditorOptionsProps) => ( + +); diff --git a/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx b/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx new file mode 100644 index 0000000000000..524986524fd7e --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { mountWithIntl } from '@kbn/test/jest'; +import { ReactWrapper } from 'enzyme'; +import PieOptions, { PieOptionsProps } from './pie'; +import { chartPluginMock } from '../../../../charts/public/mocks'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { act } from 'react-dom/test-utils'; + +describe('PalettePicker', function () { + let props: PieOptionsProps; + let component: ReactWrapper; + + beforeAll(() => { + props = ({ + palettes: chartPluginMock.createSetupContract().palettes, + showElasticChartsOptions: true, + vis: { + type: { + editorConfig: { + collections: { + legendPositions: [ + { + text: 'Top', + value: 'top', + }, + { + text: 'Left', + value: 'left', + }, + { + text: 'Right', + value: 'right', + }, + { + text: 'Bottom', + value: 'bottom', + }, + ], + }, + }, + }, + }, + stateParams: { + isDonut: true, + legendPosition: 'left', + labels: { + show: true, + }, + }, + setValue: jest.fn(), + } as unknown) as PieOptionsProps; + }); + + it('renders the nested legend switch for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieNestedLegendSwitch').length).toBe(1); + }); + }); + + it('not renders the nested legend switch for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieNestedLegendSwitch').length).toBe(0); + }); + }); + + it('renders the label position dropdown for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieLabelPositionSelect').length).toBe(1); + }); + }); + + it('not renders the label position dropdown for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieLabelPositionSelect').length).toBe(0); + }); + }); + + it('renders the top level switch for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieTopLevelSwitch').length).toBe(1); + }); + }); + + it('renders the top level switch for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieTopLevelSwitch').length).toBe(1); + }); + }); + + it('renders the value format dropdown for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieValueFormatsSelect').length).toBe(1); + }); + }); + + it('not renders the value format dropdown for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieValueFormatsSelect').length).toBe(0); + }); + }); + + it('renders the percent slider for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieValueDecimals').length).toBe(1); + }); + }); +}); diff --git a/src/plugins/vis_type_pie/public/editor/components/pie.tsx b/src/plugins/vis_type_pie/public/editor/components/pie.tsx new file mode 100644 index 0000000000000..8ce4f4defbaed --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/components/pie.tsx @@ -0,0 +1,287 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useEffect } from 'react'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { + EuiPanel, + EuiTitle, + EuiSpacer, + EuiRange, + EuiFormRow, + EuiIconTip, + EuiFlexItem, + EuiFlexGroup, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + BasicOptions, + SwitchOption, + SelectOption, + PalettePicker, +} from '../../../../vis_default_editor/public'; +import { VisEditorOptionsProps } from '../../../../visualizations/public'; +import { TruncateLabelsOption } from './truncate_labels'; +import { PaletteRegistry } from '../../../../charts/public'; +import { DEFAULT_PERCENT_DECIMALS } from '../../../common'; +import { PieVisParams, LabelPositions, ValueFormats, PieTypeProps } from '../../types'; +import { getLabelPositions, getValuesFormats } from '../collections'; +import { getLegendPositions } from '../positions'; + +export interface PieOptionsProps extends VisEditorOptionsProps, PieTypeProps {} + +function DecimalSlider({ + paramName, + value, + setValue, +}: { + value: number; + paramName: ParamName; + setValue: (paramName: ParamName, value: number) => void; +}) { + return ( + + { + setValue(paramName, Number(e.currentTarget.value)); + }} + /> + + ); +} + +const PieOptions = (props: PieOptionsProps) => { + const { stateParams, setValue, aggs } = props; + const setLabels = ( + paramName: T, + value: PieVisParams['labels'][T] + ) => setValue('labels', { ...stateParams.labels, [paramName]: value }); + const legendUiStateValue = props.uiState?.get('vis.legendOpen'); + const [palettesRegistry, setPalettesRegistry] = useState(undefined); + const [legendVisibility, setLegendVisibility] = useState(() => { + const bwcLegendStateDefault = stateParams.addLegend == null ? false : stateParams.addLegend; + return props.uiState?.get('vis.legendOpen', bwcLegendStateDefault) as boolean; + }); + const hasSplitChart = Boolean(aggs?.aggs?.find((agg) => agg.schema === 'split' && agg.enabled)); + const segments = aggs?.aggs?.filter((agg) => agg.schema === 'segment' && agg.enabled) ?? []; + + useEffect(() => { + setLegendVisibility(legendUiStateValue); + }, [legendUiStateValue]); + + useEffect(() => { + const fetchPalettes = async () => { + const palettes = await props.palettes?.getPalettes(); + setPalettesRegistry(palettes); + }; + fetchPalettes(); + }, [props.palettes]); + + return ( + <> + + +

+ +

+
+ + + + {props.showElasticChartsOptions && ( + <> + + + + + + + + + + + { + setLegendVisibility(value); + setValue(paramName, value); + }} + data-test-subj="visTypePieAddLegendSwitch" + /> + { + if (props.trackUiMetric) { + props.trackUiMetric(METRIC_TYPE.CLICK, 'nested_legend_switched'); + } + setValue(paramName, value); + }} + data-test-subj="visTypePieNestedLegendSwitch" + /> + + )} + {props.showElasticChartsOptions && palettesRegistry && ( + { + if (props.trackUiMetric) { + props.trackUiMetric(METRIC_TYPE.CLICK, 'palette_selected'); + } + setValue(paramName, value); + }} + /> + )} +
+ + + + + +

+ +

+
+ + + {props.showElasticChartsOptions && ( + { + if (props.trackUiMetric) { + props.trackUiMetric(METRIC_TYPE.CLICK, 'label_position_selected'); + } + setLabels(paramName, value); + }} + data-test-subj="visTypePieLabelPositionSelect" + /> + )} + + + {props.showElasticChartsOptions && ( + <> + { + if (props.trackUiMetric) { + props.trackUiMetric(METRIC_TYPE.CLICK, 'values_format_selected'); + } + setLabels(paramName, value); + }} + data-test-subj="visTypePieValueFormatsSelect" + /> + + + )} + +
+ + ); +}; + +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export { PieOptions as default }; diff --git a/src/plugins/vis_type_pie/public/editor/components/truncate_labels.test.tsx b/src/plugins/vis_type_pie/public/editor/components/truncate_labels.test.tsx new file mode 100644 index 0000000000000..1d4bb238dcb50 --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/components/truncate_labels.test.tsx @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { mountWithIntl } from '@kbn/test/jest'; +import { ReactWrapper } from 'enzyme'; +import { TruncateLabelsOption, TruncateLabelsOptionProps } from './truncate_labels'; +import { findTestSubject } from '@elastic/eui/lib/test'; + +describe('TruncateLabelsOption', function () { + let props: TruncateLabelsOptionProps; + let component: ReactWrapper; + + beforeAll(() => { + props = { + disabled: false, + value: 20, + setValue: jest.fn(), + }; + }); + + it('renders an input type number', () => { + component = mountWithIntl(); + expect(findTestSubject(component, 'pieLabelTruncateInput').length).toBe(1); + }); + + it('renders the value on the input number', function () { + component = mountWithIntl(); + const input = findTestSubject(component, 'pieLabelTruncateInput'); + expect(input.props().value).toBe(20); + }); + + it('disables the input if disabled prop is given', function () { + const newProps = { ...props, disabled: true }; + component = mountWithIntl(); + const input = findTestSubject(component, 'pieLabelTruncateInput'); + expect(input.props().disabled).toBe(true); + }); + + it('should set the new value', function () { + component = mountWithIntl(); + const input = findTestSubject(component, 'pieLabelTruncateInput'); + input.simulate('change', { target: { value: 100 } }); + expect(props.setValue).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx b/src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx new file mode 100644 index 0000000000000..e6eb56725753c --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { ChangeEvent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiFieldNumber } from '@elastic/eui'; + +export interface TruncateLabelsOptionProps { + disabled?: boolean; + value?: number | null; + setValue: (paramName: 'truncate', value: null | number) => void; +} + +function TruncateLabelsOption({ disabled, value = null, setValue }: TruncateLabelsOptionProps) { + const onChange = (ev: ChangeEvent) => + setValue('truncate', ev.target.value === '' ? null : parseFloat(ev.target.value)); + + return ( + + + + ); +} + +export { TruncateLabelsOption }; diff --git a/src/plugins/vis_type_pie/public/editor/positions.ts b/src/plugins/vis_type_pie/public/editor/positions.ts new file mode 100644 index 0000000000000..ea099a23cf9b4 --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/positions.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { i18n } from '@kbn/i18n'; +import { Position } from '@elastic/charts'; + +export const getLegendPositions = [ + { + text: i18n.translate('visTypePie.legendPositions.topText', { + defaultMessage: 'Top', + }), + value: Position.Top, + }, + { + text: i18n.translate('visTypePie.legendPositions.leftText', { + defaultMessage: 'Left', + }), + value: Position.Left, + }, + { + text: i18n.translate('visTypePie.legendPositions.rightText', { + defaultMessage: 'Right', + }), + value: Position.Right, + }, + { + text: i18n.translate('visTypePie.legendPositions.bottomText', { + defaultMessage: 'Bottom', + }), + value: Position.Bottom, + }, +]; diff --git a/src/plugins/vis_type_pie/public/expression_functions/pie_labels.ts b/src/plugins/vis_type_pie/public/expression_functions/pie_labels.ts new file mode 100644 index 0000000000000..269d5d5f779d6 --- /dev/null +++ b/src/plugins/vis_type_pie/public/expression_functions/pie_labels.ts @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { i18n } from '@kbn/i18n'; +import { + ExpressionFunctionDefinition, + Datatable, + ExpressionValueBoxed, +} from '../../../expressions/public'; + +interface Arguments { + show: boolean; + position: string; + values: boolean; + truncate: number | null; + valuesFormat: string; + lastLevel: boolean; + percentDecimals: number; +} + +export type ExpressionValuePieLabels = ExpressionValueBoxed< + 'pie_labels', + { + show: boolean; + position: string; + values: boolean; + truncate: number | null; + valuesFormat: string; + last_level: boolean; + percentDecimals: number; + } +>; + +export const pieLabels = (): ExpressionFunctionDefinition< + 'pielabels', + Datatable | null, + Arguments, + ExpressionValuePieLabels +> => ({ + name: 'pielabels', + help: i18n.translate('visTypePie.function.pieLabels.help', { + defaultMessage: 'Generates the pie labels object', + }), + type: 'pie_labels', + args: { + show: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.pieLabels.show.help', { + defaultMessage: 'Displays the pie labels', + }), + required: true, + }, + position: { + types: ['string'], + default: 'default', + help: i18n.translate('visTypePie.function.pieLabels.position.help', { + defaultMessage: 'Defines the label position', + }), + }, + values: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.pieLabels.values.help', { + defaultMessage: 'Displays the values inside the slices', + }), + default: true, + }, + percentDecimals: { + types: ['number'], + help: i18n.translate('visTypePie.function.pieLabels.percentDecimals.help', { + defaultMessage: 'Defines the number of decimals that will appear on the values as percent', + }), + default: 2, + }, + lastLevel: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.pieLabels.lastLevel.help', { + defaultMessage: 'Show top level labels only', + }), + default: true, + }, + truncate: { + types: ['number', 'null'], + help: i18n.translate('visTypePie.function.pieLabels.truncate.help', { + defaultMessage: 'Defines the number of characters that the slice value will display', + }), + default: null, + }, + valuesFormat: { + types: ['string'], + default: 'percent', + help: i18n.translate('visTypePie.function.pieLabels.valuesFormat.help', { + defaultMessage: 'Defines the format of the values', + }), + }, + }, + fn: (context, args) => { + return { + type: 'pie_labels', + show: args.show, + position: args.position, + percentDecimals: args.percentDecimals, + values: args.values, + truncate: args.truncate, + valuesFormat: args.valuesFormat, + last_level: args.lastLevel, + }; + }, +}); diff --git a/src/plugins/vis_type_pie/public/index.ts b/src/plugins/vis_type_pie/public/index.ts new file mode 100644 index 0000000000000..adf8b2d073f39 --- /dev/null +++ b/src/plugins/vis_type_pie/public/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { VisTypePiePlugin } from './plugin'; + +export { pieVisType } from './vis_type'; +export { Dimensions, Dimension } from './types'; + +export const plugin = () => new VisTypePiePlugin(); diff --git a/src/plugins/vis_type_pie/public/mocks.ts b/src/plugins/vis_type_pie/public/mocks.ts new file mode 100644 index 0000000000000..53579422e44eb --- /dev/null +++ b/src/plugins/vis_type_pie/public/mocks.ts @@ -0,0 +1,328 @@ +/* + * Copyright 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 { Datatable } from '../../expressions/public'; +import { BucketColumns, PieVisParams, LabelPositions, ValueFormats } from './types'; + +export const createMockBucketColumns = (): BucketColumns[] => { + return [ + { + id: 'col-0-2', + name: 'Carrier: Descending', + meta: { + type: 'string', + field: 'Carrier', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '2', + enabled: true, + type: 'terms', + params: { + field: 'Carrier', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + }, + }, + format: { + id: 'terms', + params: { + id: 'string', + }, + }, + }, + { + id: 'col-2-3', + name: 'Cancelled: Descending', + meta: { + type: 'boolean', + field: 'Cancelled', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'boolean', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '3', + enabled: true, + type: 'terms', + params: { + field: 'Cancelled', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + }, + }, + format: { + id: 'terms', + params: { + id: 'boolean', + }, + }, + }, + ]; +}; + +export const createMockVisData = (): Datatable => { + return { + type: 'datatable', + rows: [ + { + 'col-0-2': 'Logstash Airways', + 'col-2-3': 0, + 'col-1-1': 797, + 'col-3-1': 689, + }, + { + 'col-0-2': 'Logstash Airways', + 'col-2-3': 1, + 'col-1-1': 797, + 'col-3-1': 108, + }, + { + 'col-0-2': 'JetBeats', + 'col-2-3': 0, + 'col-1-1': 766, + 'col-3-1': 654, + }, + { + 'col-0-2': 'JetBeats', + 'col-2-3': 1, + 'col-1-1': 766, + 'col-3-1': 112, + }, + { + 'col-0-2': 'ES-Air', + 'col-2-3': 0, + 'col-1-1': 744, + 'col-3-1': 665, + }, + { + 'col-0-2': 'ES-Air', + 'col-2-3': 1, + 'col-1-1': 744, + 'col-3-1': 79, + }, + { + 'col-0-2': 'Kibana Airlines', + 'col-2-3': 0, + 'col-1-1': 731, + 'col-3-1': 655, + }, + { + 'col-0-2': 'Kibana Airlines', + 'col-2-3': 1, + 'col-1-1': 731, + 'col-3-1': 76, + }, + ], + columns: [ + { + id: 'col-0-2', + name: 'Carrier: Descending', + meta: { + type: 'string', + field: 'Carrier', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '2', + enabled: true, + type: 'terms', + params: { + field: 'Carrier', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + }, + }, + }, + { + id: 'col-1-1', + name: 'Count', + meta: { + type: 'number', + index: 'kibana_sample_data_flights', + params: { + id: 'number', + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '1', + enabled: true, + type: 'count', + params: {}, + schema: 'metric', + }, + }, + }, + { + id: 'col-2-3', + name: 'Cancelled: Descending', + meta: { + type: 'boolean', + field: 'Cancelled', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'boolean', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '3', + enabled: true, + type: 'terms', + params: { + field: 'Cancelled', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + }, + }, + }, + { + id: 'col-3-1', + name: 'Count', + meta: { + type: 'number', + index: 'kibana_sample_data_flights', + params: { + id: 'number', + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '1', + enabled: true, + type: 'count', + params: {}, + schema: 'metric', + }, + }, + }, + ], + }; +}; + +export const createMockPieParams = (): PieVisParams => { + return ({ + addLegend: true, + addTooltip: true, + isDonut: true, + labels: { + position: LabelPositions.DEFAULT, + show: true, + truncate: 100, + values: true, + valuesFormat: ValueFormats.PERCENT, + percentDecimals: 2, + }, + legendPosition: 'right', + nestedLegend: false, + distinctColors: false, + palette: { + name: 'default', + type: 'palette', + }, + type: 'pie', + dimensions: { + metric: { + accessor: 1, + format: { + id: 'number', + }, + params: {}, + label: 'Count', + aggType: 'count', + }, + buckets: [ + { + accessor: 0, + format: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + label: 'Carrier: Descending', + aggType: 'terms', + }, + { + accessor: 2, + format: { + id: 'terms', + params: { + id: 'boolean', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + label: 'Cancelled: Descending', + aggType: 'terms', + }, + ], + }, + } as unknown) as PieVisParams; +}; diff --git a/src/plugins/vis_type_pie/public/pie_component.test.tsx b/src/plugins/vis_type_pie/public/pie_component.test.tsx new file mode 100644 index 0000000000000..177396f25adb6 --- /dev/null +++ b/src/plugins/vis_type_pie/public/pie_component.test.tsx @@ -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 React from 'react'; +import { Settings, TooltipType, SeriesIdentifier } from '@elastic/charts'; +import { chartPluginMock } from '../../charts/public/mocks'; +import { dataPluginMock } from '../../data/public/mocks'; +import { shallow, mount } from 'enzyme'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { act } from 'react-dom/test-utils'; +import PieComponent, { PieComponentProps } from './pie_component'; +import { createMockPieParams, createMockVisData } from './mocks'; + +jest.mock('@elastic/charts', () => { + const original = jest.requireActual('@elastic/charts'); + + return { + ...original, + getSpecId: jest.fn(() => {}), + }; +}); + +const chartsThemeService = chartPluginMock.createSetupContract().theme; +const palettesRegistry = chartPluginMock.createPaletteRegistry(); +const visParams = createMockPieParams(); +const visData = createMockVisData(); + +const mockState = new Map(); +const uiState = { + get: jest + .fn() + .mockImplementation((key, fallback) => (mockState.has(key) ? mockState.get(key) : fallback)), + set: jest.fn().mockImplementation((key, value) => mockState.set(key, value)), + emit: jest.fn(), + setSilent: jest.fn(), +} as any; + +describe('PieComponent', function () { + let wrapperProps: PieComponentProps; + + beforeAll(() => { + wrapperProps = { + chartsThemeService, + palettesRegistry, + visParams, + visData, + uiState, + syncColors: false, + fireEvent: jest.fn(), + renderComplete: jest.fn(), + services: dataPluginMock.createStartContract(), + }; + }); + + it('renders the legend on the correct position', () => { + const component = shallow(); + expect(component.find(Settings).prop('legendPosition')).toEqual('right'); + }); + + it('renders the legend toggle component', async () => { + const component = mount(); + await act(async () => { + expect(findTestSubject(component, 'vislibToggleLegend').length).toBe(1); + }); + }); + + it('hides the legend if the legend toggle is clicked', async () => { + const component = mount(); + findTestSubject(component, 'vislibToggleLegend').simulate('click'); + await act(async () => { + expect(component.find(Settings).prop('showLegend')).toEqual(false); + }); + }); + + it('defaults on showing the legend for the inner cicle', () => { + const component = shallow(); + expect(component.find(Settings).prop('legendMaxDepth')).toBe(1); + }); + + it('shows the nested legend when the user requests it', () => { + const newParams = { ...visParams, nestedLegend: true }; + const newProps = { ...wrapperProps, visParams: newParams }; + const component = shallow(); + expect(component.find(Settings).prop('legendMaxDepth')).toBeUndefined(); + }); + + it('defaults on displaying the tooltip', () => { + const component = shallow(); + expect(component.find(Settings).prop('tooltip')).toStrictEqual({ type: TooltipType.Follow }); + }); + + it('doesnt show the tooltip when the user requests it', () => { + const newParams = { ...visParams, addTooltip: false }; + const newProps = { ...wrapperProps, visParams: newParams }; + const component = shallow(); + expect(component.find(Settings).prop('tooltip')).toStrictEqual({ type: TooltipType.None }); + }); + + it('calls filter callback', () => { + const component = shallow(); + component.find(Settings).first().prop('onElementClick')!([ + [ + [ + { + groupByRollup: 6, + value: 6, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: 'Logstash Airways', + }, + ], + {} as SeriesIdentifier, + ], + ]); + expect(wrapperProps.fireEvent).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_type_pie/public/pie_component.tsx b/src/plugins/vis_type_pie/public/pie_component.tsx new file mode 100644 index 0000000000000..b79eed2087a16 --- /dev/null +++ b/src/plugins/vis_type_pie/public/pie_component.tsx @@ -0,0 +1,355 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { memo, useCallback, useMemo, useState, useEffect, useRef } from 'react'; + +import { + Chart, + Datum, + LayerValue, + Partition, + Position, + Settings, + RenderChangeListener, + TooltipProps, + TooltipType, + SeriesIdentifier, +} from '@elastic/charts'; +import { + LegendToggle, + ClickTriggerEvent, + ChartsPluginSetup, + PaletteRegistry, +} from '../../charts/public'; +import { DataPublicPluginStart, FieldFormat } from '../../data/public'; +import type { PersistedState } from '../../visualizations/public'; +import { Datatable, DatatableColumn, IInterpreterRenderHandlers } from '../../expressions/public'; +import { DEFAULT_PERCENT_DECIMALS } from '../common'; +import { PieVisParams, BucketColumns, ValueFormats, PieContainerDimensions } from './types'; +import { + getColorPicker, + getLayers, + getLegendActions, + canFilter, + getFilterClickData, + getFilterEventData, + getConfig, + getColumns, + getSplitDimensionAccessor, +} from './utils'; +import { ChartSplit, SMALL_MULTIPLES_ID } from './components/chart_split'; + +import './chart.scss'; + +declare global { + interface Window { + /** + * Flag used to enable debugState on elastic charts + */ + _echDebugStateFlag?: boolean; + } +} + +export interface PieComponentProps { + visParams: PieVisParams; + visData: Datatable; + uiState: PersistedState; + fireEvent: IInterpreterRenderHandlers['event']; + renderComplete: IInterpreterRenderHandlers['done']; + chartsThemeService: ChartsPluginSetup['theme']; + palettesRegistry: PaletteRegistry; + services: DataPublicPluginStart; + syncColors: boolean; +} + +const PieComponent = (props: PieComponentProps) => { + const chartTheme = props.chartsThemeService.useChartsTheme(); + const chartBaseTheme = props.chartsThemeService.useChartsBaseTheme(); + const [showLegend, setShowLegend] = useState(() => { + const bwcLegendStateDefault = + props.visParams.addLegend == null ? false : props.visParams.addLegend; + return props.uiState?.get('vis.legendOpen', bwcLegendStateDefault) as boolean; + }); + const [dimensions, setDimensions] = useState(); + + const parentRef = useRef(null); + + useEffect(() => { + if (parentRef && parentRef.current) { + const parentHeight = parentRef.current!.getBoundingClientRect().height; + const parentWidth = parentRef.current!.getBoundingClientRect().width; + setDimensions({ width: parentWidth, height: parentHeight }); + } + }, [parentRef]); + + const onRenderChange = useCallback( + (isRendered) => { + if (isRendered) { + props.renderComplete(); + } + }, + [props] + ); + + // handles slice click event + const handleSliceClick = useCallback( + ( + clickedLayers: LayerValue[], + bucketColumns: Array>, + visData: Datatable, + splitChartDimension?: DatatableColumn, + splitChartFormatter?: FieldFormat + ): void => { + const data = getFilterClickData( + clickedLayers, + bucketColumns, + visData, + splitChartDimension, + splitChartFormatter + ); + const event = { + name: 'filterBucket', + data: { data }, + }; + props.fireEvent(event); + }, + [props] + ); + + // handles legend action event data + const getLegendActionEventData = useCallback( + (visData: Datatable) => (series: SeriesIdentifier): ClickTriggerEvent | null => { + const data = getFilterEventData(visData, series); + + return { + name: 'filterBucket', + data: { + negate: false, + data, + }, + }; + }, + [] + ); + + const handleLegendAction = useCallback( + (event: ClickTriggerEvent, negate = false) => { + props.fireEvent({ + ...event, + data: { + ...event.data, + negate, + }, + }); + }, + [props] + ); + + const toggleLegend = useCallback(() => { + setShowLegend((value) => { + const newValue = !value; + props.uiState?.set('vis.legendOpen', newValue); + return newValue; + }); + }, [props.uiState]); + + useEffect(() => { + setShowLegend(props.visParams.addLegend); + props.uiState?.set('vis.legendOpen', props.visParams.addLegend); + }, [props.uiState, props.visParams.addLegend]); + + const setColor = useCallback( + (newColor: string | null, seriesLabel: string | number) => { + const colors = props.uiState?.get('vis.colors') || {}; + if (colors[seriesLabel] === newColor || !newColor) { + delete colors[seriesLabel]; + } else { + colors[seriesLabel] = newColor; + } + props.uiState?.setSilent('vis.colors', null); + props.uiState?.set('vis.colors', colors); + props.uiState?.emit('reload'); + }, + [props.uiState] + ); + + const { visData, visParams, services, syncColors } = props; + + function getSliceValue(d: Datum, metricColumn: DatatableColumn) { + if (typeof d[metricColumn.id] === 'number' && d[metricColumn.id] !== 0) { + return d[metricColumn.id]; + } + return Number.EPSILON; + } + + // formatters + const metricFieldFormatter = services.fieldFormats.deserialize( + visParams.dimensions.metric.format + ); + const splitChartFormatter = visParams.dimensions.splitColumn + ? services.fieldFormats.deserialize(visParams.dimensions.splitColumn[0].format) + : visParams.dimensions.splitRow + ? services.fieldFormats.deserialize(visParams.dimensions.splitRow[0].format) + : undefined; + const percentFormatter = services.fieldFormats.deserialize({ + id: 'percent', + params: { + pattern: `0,0.[${'0'.repeat(visParams.labels.percentDecimals ?? DEFAULT_PERCENT_DECIMALS)}]%`, + }, + }); + + const { bucketColumns, metricColumn } = useMemo(() => getColumns(visParams, visData), [ + visData, + visParams, + ]); + + const layers = useMemo( + () => + getLayers( + bucketColumns, + visParams, + props.uiState?.get('vis.colors', {}), + visData.rows, + props.palettesRegistry, + services.fieldFormats, + syncColors + ), + [ + bucketColumns, + visParams, + props.uiState, + props.palettesRegistry, + visData.rows, + services.fieldFormats, + syncColors, + ] + ); + const config = useMemo(() => getConfig(visParams, chartTheme, dimensions), [ + chartTheme, + visParams, + dimensions, + ]); + const tooltip: TooltipProps = { + type: visParams.addTooltip ? TooltipType.Follow : TooltipType.None, + }; + const legendPosition = visParams.legendPosition ?? Position.Right; + + const legendColorPicker = useMemo( + () => + getColorPicker( + legendPosition, + setColor, + bucketColumns, + visParams.palette.name, + visData.rows, + props.uiState, + visParams.distinctColors + ), + [ + legendPosition, + setColor, + bucketColumns, + visParams.palette.name, + visParams.distinctColors, + visData.rows, + props.uiState, + ] + ); + + const splitChartColumnAccessor = visParams.dimensions.splitColumn + ? getSplitDimensionAccessor( + services.fieldFormats, + visData.columns + )(visParams.dimensions.splitColumn[0]) + : undefined; + const splitChartRowAccessor = visParams.dimensions.splitRow + ? getSplitDimensionAccessor( + services.fieldFormats, + visData.columns + )(visParams.dimensions.splitRow[0]) + : undefined; + + const splitChartDimension = visParams.dimensions.splitColumn + ? visData.columns[visParams.dimensions.splitColumn[0].accessor] + : visParams.dimensions.splitRow + ? visData.columns[visParams.dimensions.splitRow[0].accessor] + : undefined; + + return ( +
+
+ + + + { + handleSliceClick( + args[0][0] as LayerValue[], + bucketColumns, + visData, + splitChartDimension, + splitChartFormatter + ); + }} + legendAction={getLegendActions( + canFilter, + getLegendActionEventData(visData), + handleLegendAction, + visParams, + services.actions, + services.fieldFormats + )} + theme={chartTheme} + baseTheme={chartBaseTheme} + onRenderChange={onRenderChange} + /> + getSliceValue(d, metricColumn)} + percentFormatter={(d: number) => percentFormatter.convert(d / 100)} + valueGetter={ + !visParams.labels.show || + visParams.labels.valuesFormat === ValueFormats.VALUE || + !visParams.labels.values + ? undefined + : 'percent' + } + valueFormatter={(d: number) => + !visParams.labels.show || !visParams.labels.values + ? '' + : metricFieldFormatter.convert(d) + } + layers={layers} + config={config} + topGroove={!visParams.labels.show ? 0 : undefined} + /> + +
+
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export default memo(PieComponent); diff --git a/src/plugins/vis_type_pie/public/pie_fn.test.ts b/src/plugins/vis_type_pie/public/pie_fn.test.ts new file mode 100644 index 0000000000000..d387d4035e8ab --- /dev/null +++ b/src/plugins/vis_type_pie/public/pie_fn.test.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 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 { functionWrapper } from '../../expressions/common/expression_functions/specs/tests/utils'; +import { createPieVisFn } from './pie_fn'; + +describe('interpreter/functions#pie', () => { + const fn = functionWrapper(createPieVisFn()); + const context = { + type: 'datatable', + rows: [{ 'col-0-1': 0 }], + columns: [{ id: 'col-0-1', name: 'Count' }], + }; + const visConfig = { + addTooltip: true, + addLegend: true, + legendPosition: 'right', + isDonut: true, + nestedLegend: true, + distinctColors: false, + palette: 'kibana_palette', + labels: { + show: false, + values: true, + position: 'default', + valuesFormat: 'percent', + percentDecimals: 2, + truncate: 100, + }, + metric: { + accessor: 0, + format: { + id: 'number', + }, + params: {}, + aggType: 'count', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns an object with the correct structure', async () => { + const actual = await fn(context, visConfig); + expect(actual).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/vis_type_pie/public/pie_fn.ts b/src/plugins/vis_type_pie/public/pie_fn.ts new file mode 100644 index 0000000000000..1b5b8574f9311 --- /dev/null +++ b/src/plugins/vis_type_pie/public/pie_fn.ts @@ -0,0 +1,153 @@ +/* + * Copyright 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition, Datatable, Render } from '../../expressions/public'; +import { PieVisParams, PieVisConfig } from './types'; + +export const vislibPieName = 'pie_vis'; + +export interface RenderValue { + visData: Datatable; + visType: string; + visConfig: PieVisParams; + syncColors: boolean; +} + +export type VisTypePieExpressionFunctionDefinition = ExpressionFunctionDefinition< + typeof vislibPieName, + Datatable, + PieVisConfig, + Render +>; + +export const createPieVisFn = (): VisTypePieExpressionFunctionDefinition => ({ + name: vislibPieName, + type: 'render', + inputTypes: ['datatable'], + help: i18n.translate('visTypePie.functions.help', { + defaultMessage: 'Pie visualization', + }), + args: { + metric: { + types: ['vis_dimension'], + help: i18n.translate('visTypePie.function.args.metricHelpText', { + defaultMessage: 'Metric dimensions config', + }), + required: true, + }, + buckets: { + types: ['vis_dimension'], + help: i18n.translate('visTypePie.function.args.bucketsHelpText', { + defaultMessage: 'Buckets dimensions config', + }), + multi: true, + }, + splitColumn: { + types: ['vis_dimension'], + help: i18n.translate('visTypePie.function.args.splitColumnHelpText', { + defaultMessage: 'Split by column dimension config', + }), + multi: true, + }, + splitRow: { + types: ['vis_dimension'], + help: i18n.translate('visTypePie.function.args.splitRowHelpText', { + defaultMessage: 'Split by row dimension config', + }), + multi: true, + }, + addTooltip: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.args.addTooltipHelpText', { + defaultMessage: 'Show tooltip on slice hover', + }), + default: true, + }, + addLegend: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.args.addLegendHelpText', { + defaultMessage: 'Show legend chart legend', + }), + }, + legendPosition: { + types: ['string'], + help: i18n.translate('visTypePie.function.args.legendPositionHelpText', { + defaultMessage: 'Position the legend on top, bottom, left, right of the chart', + }), + }, + nestedLegend: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.args.nestedLegendHelpText', { + defaultMessage: 'Show a more detailed legend', + }), + default: false, + }, + distinctColors: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.args.distinctColorsHelpText', { + defaultMessage: + 'Maps different color per slice. Slices with the same value have the same color', + }), + default: false, + }, + isDonut: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.args.isDonutHelpText', { + defaultMessage: 'Displays the pie chart as donut', + }), + default: false, + }, + palette: { + types: ['string'], + help: i18n.translate('visTypePie.function.args.paletteHelpText', { + defaultMessage: 'Defines the chart palette name', + }), + default: 'default', + }, + labels: { + types: ['pie_labels'], + help: i18n.translate('visTypePie.function.args.labelsHelpText', { + defaultMessage: 'Pie labels config', + }), + }, + }, + fn(context, args, handlers) { + const visConfig = { + ...args, + palette: { + type: 'palette', + name: args.palette, + }, + dimensions: { + metric: args.metric, + buckets: args.buckets, + splitColumn: args.splitColumn, + splitRow: args.splitRow, + }, + } as PieVisParams; + + if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.logDatatable('default', context); + } + + return { + type: 'render', + as: vislibPieName, + value: { + visData: context, + visConfig, + syncColors: handlers?.isSyncColorsEnabled?.() ?? false, + visType: 'pie', + params: { + listenOnChange: true, + }, + }, + }; + }, +}); diff --git a/src/plugins/vis_type_pie/public/pie_renderer.tsx b/src/plugins/vis_type_pie/public/pie_renderer.tsx new file mode 100644 index 0000000000000..bcd4cad4efa66 --- /dev/null +++ b/src/plugins/vis_type_pie/public/pie_renderer.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { lazy } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { ExpressionRenderDefinition } from '../../expressions/public'; +import { VisualizationContainer } from '../../visualizations/public'; +import type { PersistedState } from '../../visualizations/public'; +import { VisTypePieDependencies } from './plugin'; + +import { RenderValue, vislibPieName } from './pie_fn'; + +const PieComponent = lazy(() => import('./pie_component')); + +function shouldShowNoResultsMessage(visData: any): boolean { + const rows: object[] | undefined = visData?.rows; + const isZeroHits = visData?.hits === 0 || (rows && !rows.length); + + return Boolean(isZeroHits); +} + +export const getPieVisRenderer: ( + deps: VisTypePieDependencies +) => ExpressionRenderDefinition = ({ theme, palettes, getStartDeps }) => ({ + name: vislibPieName, + displayName: 'Pie visualization', + reuseDomNode: true, + render: async (domNode, { visConfig, visData, syncColors }, handlers) => { + const showNoResult = shouldShowNoResultsMessage(visData); + + handlers.onDestroy(() => { + unmountComponentAtNode(domNode); + }); + + const services = await getStartDeps(); + const palettesRegistry = await palettes.getPalettes(); + + render( + + + + + , + domNode + ); + }, +}); diff --git a/src/plugins/vis_type_pie/public/plugin.ts b/src/plugins/vis_type_pie/public/plugin.ts new file mode 100644 index 0000000000000..440a3a75a2eb1 --- /dev/null +++ b/src/plugins/vis_type_pie/public/plugin.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { CoreSetup, DocLinksStart } from 'src/core/public'; +import { VisualizationsSetup } from '../../visualizations/public'; +import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; +import { ChartsPluginSetup } from '../../charts/public'; +import { UsageCollectionSetup } from '../../usage_collection/public'; +import { DataPublicPluginStart } from '../../data/public'; +import { LEGACY_CHARTS_LIBRARY } from '../../visualizations/common/constants'; +import { pieLabels as pieLabelsExpressionFunction } from './expression_functions/pie_labels'; +import { createPieVisFn } from './pie_fn'; +import { getPieVisRenderer } from './pie_renderer'; +import { pieVisType } from './vis_type'; + +/** @internal */ +export interface VisTypePieSetupDependencies { + visualizations: VisualizationsSetup; + expressions: ReturnType; + charts: ChartsPluginSetup; + usageCollection: UsageCollectionSetup; +} + +/** @internal */ +export interface VisTypePiePluginStartDependencies { + data: DataPublicPluginStart; +} + +/** @internal */ +export interface VisTypePieDependencies { + theme: ChartsPluginSetup['theme']; + palettes: ChartsPluginSetup['palettes']; + getStartDeps: () => Promise<{ data: DataPublicPluginStart; docLinks: DocLinksStart }>; +} + +export class VisTypePiePlugin { + setup( + core: CoreSetup, + { expressions, visualizations, charts, usageCollection }: VisTypePieSetupDependencies + ) { + if (!core.uiSettings.get(LEGACY_CHARTS_LIBRARY, false)) { + const getStartDeps = async () => { + const [coreStart, deps] = await core.getStartServices(); + return { + data: deps.data, + docLinks: coreStart.docLinks, + }; + }; + const trackUiMetric = usageCollection?.reportUiCounter.bind(usageCollection, 'vis_type_pie'); + + expressions.registerFunction(createPieVisFn); + expressions.registerRenderer( + getPieVisRenderer({ theme: charts.theme, palettes: charts.palettes, getStartDeps }) + ); + expressions.registerFunction(pieLabelsExpressionFunction); + visualizations.createBaseVisualization( + pieVisType({ + showElasticChartsOptions: true, + palettes: charts.palettes, + trackUiMetric, + }) + ); + } + return {}; + } + + start() {} +} diff --git a/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts b/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts new file mode 100644 index 0000000000000..3b07743e79f45 --- /dev/null +++ b/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts @@ -0,0 +1,1332 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const samplePieVis = { + type: { + name: 'pie', + title: 'Pie', + description: 'Compare parts of a whole', + icon: 'visPie', + stage: 'production', + options: { + showTimePicker: true, + showQueryBar: true, + showFilterBar: true, + showIndexSelection: true, + hierarchicalData: false, + }, + visConfig: { + defaults: { + type: 'pie', + addTooltip: true, + addLegend: true, + legendPosition: 'right', + isDonut: true, + nestedLegend: true, + distinctColors: false, + palette: 'kibana_palette', + labels: { + show: false, + values: true, + last_level: true, + valuesFormat: 'percent', + percentDecimals: 2, + truncate: 100, + }, + }, + }, + editorConfig: { + collections: { + legendPositions: [ + { + text: 'Top', + value: 'top', + }, + { + text: 'Left', + value: 'left', + }, + { + text: 'Right', + value: 'right', + }, + { + text: 'Bottom', + value: 'bottom', + }, + ], + }, + schemas: { + all: [ + { + group: 'metrics', + name: 'metric', + title: 'Slice size', + min: 1, + max: 1, + aggFilter: ['sum', 'count', 'cardinality', 'top_hits'], + defaults: [ + { + schema: 'metric', + type: 'count', + }, + ], + editor: false, + params: [], + }, + { + group: 'buckets', + name: 'segment', + title: 'Split slices', + min: 0, + max: null, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + editor: false, + params: [], + }, + { + group: 'buckets', + name: 'split', + title: 'Split chart', + mustBeFirst: true, + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + params: [ + { + name: 'row', + default: true, + }, + ], + editor: false, + }, + ], + buckets: [null, null], + metrics: [null], + }, + }, + hidden: false, + hierarchicalData: true, + }, + title: '[Flights] Airline Carrier', + description: '', + params: { + type: 'pie', + addTooltip: true, + addLegend: true, + legendPosition: 'right', + isDonut: true, + labels: { + show: true, + values: true, + last_level: true, + truncate: 100, + }, + }, + data: { + indexPattern: { id: '123' }, + searchSource: { + id: 'data_source1', + requestStartHandlers: [], + inheritOptions: {}, + history: [], + fields: { + filter: [], + query: { + query: '', + language: 'kuery', + }, + index: { + id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + title: 'kibana_sample_data_flights', + fieldFormatMap: { + AvgTicketPrice: { + id: 'number', + params: { + parsedUrl: { + origin: 'http://localhost:5801', + pathname: '/app/visualize', + basePath: '', + }, + pattern: '$0,0.[00]', + }, + }, + hour_of_day: { + id: 'number', + params: { + pattern: '00', + }, + }, + }, + fields: [ + { + count: 0, + name: 'AvgTicketPrice', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'Cancelled', + type: 'boolean', + esTypes: ['boolean'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'Carrier', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'Dest', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestAirportID', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestCityName', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestCountry', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestLocation', + type: 'geo_point', + esTypes: ['geo_point'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestRegion', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestWeather', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DistanceKilometers', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DistanceMiles', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightDelay', + type: 'boolean', + esTypes: ['boolean'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightDelayMin', + type: 'number', + esTypes: ['integer'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightDelayType', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightNum', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightTimeHour', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightTimeMin', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'Origin', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginAirportID', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginCityName', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginCountry', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginLocation', + type: 'geo_point', + esTypes: ['geo_point'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginRegion', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginWeather', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: '_id', + type: 'string', + esTypes: ['_id'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + count: 0, + name: '_index', + type: 'string', + esTypes: ['_index'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + count: 0, + name: '_score', + type: 'number', + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: '_source', + type: '_source', + esTypes: ['_source'], + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: '_type', + type: 'string', + esTypes: ['_type'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + count: 0, + name: 'dayOfWeek', + type: 'number', + esTypes: ['integer'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'timestamp', + type: 'date', + esTypes: ['date'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + script: "doc['timestamp'].value.hourOfDay", + lang: 'painless', + name: 'hour_of_day', + type: 'number', + scripted: true, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + ], + timeFieldName: 'timestamp', + metaFields: ['_source', '_id', '_type', '_index', '_score'], + version: 'WzM1LDFd', + originalSavedObjectBody: { + title: 'kibana_sample_data_flights', + timeFieldName: 'timestamp', + fields: + '[{"count":0,"name":"AvgTicketPrice","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Cancelled","type":"boolean","esTypes":["boolean"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Carrier","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Dest","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestAirportID","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestCityName","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestCountry","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestLocation","type":"geo_point","esTypes":["geo_point"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestRegion","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestWeather","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DistanceKilometers","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DistanceMiles","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelay","type":"boolean","esTypes":["boolean"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelayMin","type":"number","esTypes":["integer"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelayType","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightNum","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightTimeHour","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightTimeMin","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Origin","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginAirportID","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginCityName","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginCountry","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginLocation","type":"geo_point","esTypes":["geo_point"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginRegion","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginWeather","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"_id","type":"string","esTypes":["_id"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"_index","type":"string","esTypes":["_index"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"_score","type":"number","scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"_source","type":"_source","esTypes":["_source"],"scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"_type","type":"string","esTypes":["_type"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"dayOfWeek","type":"number","esTypes":["integer"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"timestamp","type":"date","esTypes":["date"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"script":"doc[\'timestamp\'].value.hourOfDay","lang":"painless","name":"hour_of_day","type":"number","scripted":true,"searchable":true,"aggregatable":true,"readFromDocValues":false}]', + fieldFormatMap: + '{"AvgTicketPrice":{"id":"number","params":{"parsedUrl":{"origin":"http://localhost:5801","pathname":"/app/visualize","basePath":""},"pattern":"$0,0.[00]"}},"hour_of_day":{"id":"number","params":{"parsedUrl":{"origin":"http://localhost:5801","pathname":"/app/visualize","basePath":""},"pattern":"00"}}}', + }, + shortDotsEnable: false, + fieldFormats: { + fieldFormats: {}, + defaultMap: { + ip: { + id: 'ip', + params: {}, + }, + date: { + id: 'date', + params: {}, + }, + date_nanos: { + id: 'date_nanos', + params: {}, + es: true, + }, + number: { + id: 'number', + params: {}, + }, + boolean: { + id: 'boolean', + params: {}, + }, + _source: { + id: '_source', + params: {}, + }, + _default_: { + id: 'string', + params: {}, + }, + }, + metaParamsOptions: {}, + }, + }, + }, + dependencies: { + legacy: { + loadingCount$: { + _isScalar: false, + observers: [ + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + destination: { + closed: true, + }, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 1, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [ + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 13, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + null, + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [null], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 1, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + null, + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [null], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 1, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + null, + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [null], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 3, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + null, + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [null], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + null, + ], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + null, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + }, + }, + aggs: { + typesRegistry: {}, + getResponseAggs: () => [ + { + id: '1', + enabled: true, + type: 'count', + params: {}, + schema: 'metric', + toSerializedFieldFormat: () => ({ + id: 'number', + }), + }, + { + id: '2', + enabled: true, + type: 'terms', + params: { + field: 'Carrier', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + toSerializedFieldFormat: () => ({ + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + parsedUrl: { + origin: 'http://localhost:5801', + pathname: '/app/visualize', + basePath: '', + }, + }, + }), + }, + ], + aggs: [], + }, + }, + isHierarchical: () => true, + uiState: { + vis: { + legendOpen: false, + }, + }, +}; diff --git a/src/plugins/vis_type_pie/public/to_ast.test.ts b/src/plugins/vis_type_pie/public/to_ast.test.ts new file mode 100644 index 0000000000000..019c6e2176710 --- /dev/null +++ b/src/plugins/vis_type_pie/public/to_ast.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { Vis } from '../../visualizations/public'; + +import { PieVisParams } from './types'; +import { samplePieVis } from './sample_vis.test.mocks'; +import { toExpressionAst } from './to_ast'; + +describe('vis type pie vis toExpressionAst function', () => { + let vis: Vis; + const params = { + timefilter: {}, + timeRange: {}, + abortSignal: {}, + } as any; + + beforeEach(() => { + vis = samplePieVis as any; + }); + + it('should match basic snapshot', async () => { + const actual = await toExpressionAst(vis, params); + expect(actual).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/vis_type_pie/public/to_ast.ts b/src/plugins/vis_type_pie/public/to_ast.ts new file mode 100644 index 0000000000000..e8c9f301b4366 --- /dev/null +++ b/src/plugins/vis_type_pie/public/to_ast.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getVisSchemas, VisToExpressionAst, SchemaConfig } from '../../visualizations/public'; +import { buildExpression, buildExpressionFunction } from '../../expressions/public'; +import { PieVisParams, LabelsParams } from './types'; +import { vislibPieName, VisTypePieExpressionFunctionDefinition } from './pie_fn'; +import { getEsaggsFn } from './to_ast_esaggs'; + +const prepareDimension = (params: SchemaConfig) => { + const visdimension = buildExpressionFunction('visdimension', { accessor: params.accessor }); + + if (params.format) { + visdimension.addArgument('format', params.format.id); + visdimension.addArgument('formatParams', JSON.stringify(params.format.params)); + } + + return buildExpression([visdimension]); +}; + +const prepareLabels = (params: LabelsParams) => { + const pieLabels = buildExpressionFunction('pielabels', { + show: params.show, + lastLevel: params.last_level, + values: params.values, + truncate: params.truncate, + }); + if (params.position) { + pieLabels.addArgument('position', params.position); + } + if (params.valuesFormat) { + pieLabels.addArgument('valuesFormat', params.valuesFormat); + } + if (params.percentDecimals != null) { + pieLabels.addArgument('percentDecimals', params.percentDecimals); + } + return buildExpression([pieLabels]); +}; + +export const toExpressionAst: VisToExpressionAst = async (vis, params) => { + const schemas = getVisSchemas(vis, params); + const args = { + // explicitly pass each param to prevent extra values trapping + addTooltip: vis.params.addTooltip, + addLegend: vis.params.addLegend, + legendPosition: vis.params.legendPosition, + nestedLegend: vis.params?.nestedLegend, + distinctColors: vis.params?.distinctColors, + isDonut: vis.params.isDonut, + palette: vis.params?.palette?.name, + labels: prepareLabels(vis.params.labels), + metric: schemas.metric.map(prepareDimension), + buckets: schemas.segment?.map(prepareDimension), + splitColumn: schemas.split_column?.map(prepareDimension), + splitRow: schemas.split_row?.map(prepareDimension), + }; + + const visTypePie = buildExpressionFunction( + vislibPieName, + args + ); + + const ast = buildExpression([getEsaggsFn(vis), visTypePie]); + + return ast.toAst(); +}; diff --git a/src/plugins/vis_type_pie/public/to_ast_esaggs.ts b/src/plugins/vis_type_pie/public/to_ast_esaggs.ts new file mode 100644 index 0000000000000..9b760bd4bebcc --- /dev/null +++ b/src/plugins/vis_type_pie/public/to_ast_esaggs.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { Vis } from '../../visualizations/public'; +import { buildExpression, buildExpressionFunction } from '../../expressions/public'; +import { + EsaggsExpressionFunctionDefinition, + IndexPatternLoadExpressionFunctionDefinition, +} from '../../data/public'; + +import { PieVisParams } from './types'; + +/** + * Get esaggs expressions function + * @param vis + */ +export function getEsaggsFn(vis: Vis) { + return buildExpressionFunction('esaggs', { + index: buildExpression([ + buildExpressionFunction('indexPatternLoad', { + id: vis.data.indexPattern!.id!, + }), + ]), + metricsAtAllLevels: vis.isHierarchical(), + partialRows: false, + aggs: vis.data.aggs!.aggs.map((agg) => buildExpression(agg.toExpressionAst())), + }); +} diff --git a/src/plugins/vis_type_pie/public/types/index.ts b/src/plugins/vis_type_pie/public/types/index.ts new file mode 100644 index 0000000000000..12594660136d8 --- /dev/null +++ b/src/plugins/vis_type_pie/public/types/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './types'; diff --git a/src/plugins/vis_type_pie/public/types/types.ts b/src/plugins/vis_type_pie/public/types/types.ts new file mode 100644 index 0000000000000..4f3365545d062 --- /dev/null +++ b/src/plugins/vis_type_pie/public/types/types.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { Position } from '@elastic/charts'; +import { UiCounterMetricType } from '@kbn/analytics'; +import { DatatableColumn, SerializedFieldFormat } from '../../../expressions/public'; +import { ExpressionValueVisDimension } from '../../../visualizations/public'; +import { ExpressionValuePieLabels } from '../expression_functions/pie_labels'; +import { PaletteOutput, ChartsPluginSetup } from '../../../charts/public'; + +export interface Dimension { + accessor: number; + format: { + id?: string; + params?: SerializedFieldFormat; + }; +} + +export interface Dimensions { + metric: Dimension; + buckets?: Dimension[]; + splitRow?: Dimension[]; + splitColumn?: Dimension[]; +} + +interface PieCommonParams { + addTooltip: boolean; + addLegend: boolean; + legendPosition: Position; + nestedLegend: boolean; + distinctColors: boolean; + isDonut: boolean; +} + +export interface LabelsParams { + show: boolean; + last_level: boolean; + position: LabelPositions; + values: boolean; + truncate: number | null; + valuesFormat: ValueFormats; + percentDecimals: number; +} + +export interface PieVisParams extends PieCommonParams { + dimensions: Dimensions; + labels: LabelsParams; + palette: PaletteOutput; +} + +export interface PieVisConfig extends PieCommonParams { + buckets?: ExpressionValueVisDimension[]; + metric: ExpressionValueVisDimension; + splitColumn?: ExpressionValueVisDimension[]; + splitRow?: ExpressionValueVisDimension[]; + labels: ExpressionValuePieLabels; + palette: string; +} + +export interface BucketColumns extends DatatableColumn { + format?: { + id?: string; + params?: SerializedFieldFormat; + }; +} + +export enum LabelPositions { + INSIDE = 'inside', + DEFAULT = 'default', +} + +export enum ValueFormats { + PERCENT = 'percent', + VALUE = 'value', +} + +export interface PieTypeProps { + showElasticChartsOptions?: boolean; + palettes?: ChartsPluginSetup['palettes']; + trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; +} + +export interface SplitDimensionParams { + order?: string; + orderBy?: string; +} + +export interface PieContainerDimensions { + width: number; + height: number; +} diff --git a/src/plugins/vis_type_pie/public/utils/filter_helpers.test.ts b/src/plugins/vis_type_pie/public/utils/filter_helpers.test.ts new file mode 100644 index 0000000000000..3f532cf4c384f --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/filter_helpers.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 '../../../expressions/public'; +import { getFilterClickData, getFilterEventData } from './filter_helpers'; +import { createMockBucketColumns, createMockVisData } from '../mocks'; + +const bucketColumns = createMockBucketColumns(); +const visData = createMockVisData(); + +describe('getFilterClickData', () => { + it('returns the correct filter data for the specific layer', () => { + const clickedLayers = [ + { + groupByRollup: 'Logstash Airways', + value: 729, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: '', + }, + ]; + const data = getFilterClickData(clickedLayers, bucketColumns, visData); + expect(data.length).toEqual(clickedLayers.length); + expect(data[0].value).toEqual('Logstash Airways'); + expect(data[0].row).toEqual(0); + expect(data[0].column).toEqual(0); + }); + + it('changes the filter if the user clicks on another layer', () => { + const clickedLayers = [ + { + groupByRollup: 'ES-Air', + value: 572, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: '', + }, + ]; + const data = getFilterClickData(clickedLayers, bucketColumns, visData); + expect(data.length).toEqual(clickedLayers.length); + expect(data[0].value).toEqual('ES-Air'); + expect(data[0].row).toEqual(4); + expect(data[0].column).toEqual(0); + }); + + it('returns the correct filters for small multiples', () => { + const clickedLayers = [ + { + groupByRollup: 'ES-Air', + value: 572, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: 1, + }, + ]; + const splitDimension = { + id: 'col-2-3', + name: 'Cancelled: Descending', + } as DatatableColumn; + const data = getFilterClickData(clickedLayers, bucketColumns, visData, splitDimension); + expect(data.length).toEqual(2); + expect(data[0].value).toEqual('ES-Air'); + expect(data[0].row).toEqual(5); + expect(data[0].column).toEqual(0); + expect(data[1].value).toEqual(1); + }); +}); + +describe('getFilterEventData', () => { + it('returns the correct filter data for the specific series', () => { + const series = { + key: 'Kibana Airlines', + specId: 'pie', + }; + const data = getFilterEventData(visData, series); + expect(data[0].value).toEqual('Kibana Airlines'); + expect(data[0].row).toEqual(6); + expect(data[0].column).toEqual(0); + }); + + it('changes the filter if the user clicks on another series', () => { + const series = { + key: 'JetBeats', + specId: 'pie', + }; + const data = getFilterEventData(visData, series); + expect(data[0].value).toEqual('JetBeats'); + expect(data[0].row).toEqual(2); + expect(data[0].column).toEqual(0); + }); +}); diff --git a/src/plugins/vis_type_pie/public/utils/filter_helpers.ts b/src/plugins/vis_type_pie/public/utils/filter_helpers.ts new file mode 100644 index 0000000000000..251ff8acc698e --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/filter_helpers.ts @@ -0,0 +1,89 @@ +/* + * Copyright 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 { LayerValue, SeriesIdentifier } from '@elastic/charts'; +import { Datatable, DatatableColumn } from '../../../expressions/public'; +import { DataPublicPluginStart, FieldFormat } from '../../../data/public'; +import { ClickTriggerEvent } from '../../../charts/public'; +import { ValueClickContext } from '../../../embeddable/public'; +import { BucketColumns } from '../types'; + +export const canFilter = async ( + event: ClickTriggerEvent | null, + actions: DataPublicPluginStart['actions'] +): Promise => { + if (!event) { + return false; + } + const filters = await actions.createFiltersFromValueClickAction(event.data); + return Boolean(filters.length); +}; + +export const getFilterClickData = ( + clickedLayers: LayerValue[], + bucketColumns: Array>, + visData: Datatable, + splitChartDimension?: DatatableColumn, + splitChartFormatter?: FieldFormat +): ValueClickContext['data']['data'] => { + const data: ValueClickContext['data']['data'] = []; + const matchingIndex = visData.rows.findIndex((row) => + clickedLayers.every((layer, index) => { + const columnId = bucketColumns[index].id; + if (!columnId) return; + const isCurrentLayer = row[columnId] === layer.groupByRollup; + if (!splitChartDimension) { + return isCurrentLayer; + } + const value = + splitChartFormatter?.convert(row[splitChartDimension.id]) || row[splitChartDimension.id]; + return isCurrentLayer && value === layer.smAccessorValue; + }) + ); + + data.push( + ...clickedLayers.map((clickedLayer, index) => ({ + column: visData.columns.findIndex((col) => col.id === bucketColumns[index].id), + row: matchingIndex, + value: clickedLayer.groupByRollup, + table: visData, + })) + ); + + // Allows filtering with the small multiples value + if (splitChartDimension) { + data.push({ + column: visData.columns.findIndex((col) => col.id === splitChartDimension.id), + row: matchingIndex, + table: visData, + value: clickedLayers[0].smAccessorValue, + }); + } + + return data; +}; + +export const getFilterEventData = ( + visData: Datatable, + series: SeriesIdentifier +): ValueClickContext['data']['data'] => { + return visData.columns.reduce((acc, { id }, column) => { + const value = series.key; + const row = visData.rows.findIndex((r) => r[id] === value); + if (row > -1) { + acc.push({ + table: visData, + column, + row, + value, + }); + } + + return acc; + }, []); +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_color_picker.test.tsx b/src/plugins/vis_type_pie/public/utils/get_color_picker.test.tsx new file mode 100644 index 0000000000000..5e9087947b95e --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_color_picker.test.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { LegendColorPickerProps } from '@elastic/charts'; +import { EuiPopover } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test/jest'; +import { ComponentType, ReactWrapper } from 'enzyme'; +import { getColorPicker } from './get_color_picker'; +import { ColorPicker } from '../../../charts/public'; +import type { PersistedState } from '../../../visualizations/public'; +import { createMockBucketColumns, createMockVisData } from '../mocks'; + +const bucketColumns = createMockBucketColumns(); +const visData = createMockVisData(); + +jest.mock('@elastic/charts', () => { + const original = jest.requireActual('@elastic/charts'); + + return { + ...original, + getSpecId: jest.fn(() => {}), + }; +}); + +describe('getColorPicker', function () { + const mockState = new Map(); + const uiState = ({ + get: jest + .fn() + .mockImplementation((key, fallback) => (mockState.has(key) ? mockState.get(key) : fallback)), + set: jest.fn().mockImplementation((key, value) => mockState.set(key, value)), + emit: jest.fn(), + setSilent: jest.fn(), + } as unknown) as PersistedState; + + let wrapperProps: LegendColorPickerProps; + const Component: ComponentType = getColorPicker( + 'left', + jest.fn(), + bucketColumns, + 'default', + visData.rows, + uiState, + false + ); + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapperProps = { + color: 'rgb(109, 204, 177)', + onClose: jest.fn(), + onChange: jest.fn(), + anchor: document.createElement('div'), + seriesIdentifiers: [ + { + key: 'Logstash Airways', + specId: 'pie', + }, + ], + }; + }); + + it('renders the color picker for default palette and inner layer', () => { + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).length).toBe(1); + }); + + it('renders the picker on the correct position', () => { + wrapper = mountWithIntl(); + expect(wrapper.find(EuiPopover).prop('anchorPosition')).toEqual('rightCenter'); + }); + + it('converts the color to the right hex and passes it to the color picker', () => { + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).prop('color')).toEqual('#6dccb1'); + }); + + it('doesnt render the picker for default palette and not inner layer', () => { + const newProps = { ...wrapperProps, seriesIdentifier: { key: '1', specId: 'pie' } }; + wrapper = mountWithIntl(); + expect(wrapper).toEqual({}); + }); + + it('renders the color picker with the colorIsOverwritten prop set to false if color is not overwritten for the specific series', () => { + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).prop('colorIsOverwritten')).toBe(false); + }); + + it('renders the color picker with the colorIsOverwritten prop set to true if color is overwritten for the specific series', () => { + uiState.set('vis.colors', { 'Logstash Airways': '#6092c0' }); + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).prop('colorIsOverwritten')).toBe(true); + }); + + it('renders the picker for kibana palette and not distinctColors', () => { + const LegacyPaletteComponent: ComponentType = getColorPicker( + 'left', + jest.fn(), + bucketColumns, + 'kibana_palette', + visData.rows, + uiState, + true + ); + const newProps = { ...wrapperProps, seriesIdentifier: { key: '1', specId: 'pie' } }; + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).length).toBe(1); + expect(wrapper.find(ColorPicker).prop('useLegacyColors')).toBe(true); + }); +}); diff --git a/src/plugins/vis_type_pie/public/utils/get_color_picker.tsx b/src/plugins/vis_type_pie/public/utils/get_color_picker.tsx new file mode 100644 index 0000000000000..436ce81d3ce3c --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_color_picker.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback } from 'react'; +import Color from 'color'; +import { LegendColorPicker, Position } from '@elastic/charts'; +import { PopoverAnchorPosition, EuiPopover, EuiOutsideClickDetector } from '@elastic/eui'; +import { DatatableRow } from '../../../expressions/public'; +import type { PersistedState } from '../../../visualizations/public'; +import { ColorPicker } from '../../../charts/public'; +import { BucketColumns } from '../types'; + +const KEY_CODE_ENTER = 13; + +function getAnchorPosition(legendPosition: Position): PopoverAnchorPosition { + switch (legendPosition) { + case Position.Bottom: + return 'upCenter'; + case Position.Top: + return 'downCenter'; + case Position.Left: + return 'rightCenter'; + default: + return 'leftCenter'; + } +} + +function getLayerIndex( + seriesKey: string, + data: DatatableRow[], + layers: Array> +): number { + const row = data.find((d) => Object.keys(d).find((key) => d[key] === seriesKey)); + const bucketId = row && Object.keys(row).find((key) => row[key] === seriesKey); + return layers.findIndex((layer) => layer.id === bucketId) + 1; +} + +function isOnInnerLayer( + firstBucket: Partial, + data: DatatableRow[], + seriesKey: string +): DatatableRow | undefined { + return data.find((d) => firstBucket.id && d[firstBucket.id] === seriesKey); +} + +export const getColorPicker = ( + legendPosition: Position, + setColor: (newColor: string | null, seriesKey: string | number) => void, + bucketColumns: Array>, + palette: string, + data: DatatableRow[], + uiState: PersistedState, + distinctColors: boolean +): LegendColorPicker => ({ + anchor, + color, + onClose, + onChange, + seriesIdentifiers: [seriesIdentifier], +}) => { + const seriesName = seriesIdentifier.key; + const overwriteColors: Record = uiState?.get('vis.colors', {}) ?? {}; + const colorIsOverwritten = Object.keys(overwriteColors).includes(seriesName.toString()); + let keyDownEventOn = false; + const handleChange = (newColor: string | null) => { + if (newColor) { + onChange(newColor); + } + setColor(newColor, seriesName); + // close the popover if no color is applied or the user has clicked a color + if (!newColor || !keyDownEventOn) { + onClose(); + } + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.keyCode === KEY_CODE_ENTER) { + onClose?.(); + } + keyDownEventOn = true; + }; + + const handleOutsideClick = useCallback(() => { + onClose?.(); + }, [onClose]); + + if (!distinctColors) { + const enablePicker = isOnInnerLayer(bucketColumns[0], data, seriesName) || !bucketColumns[0].id; + if (!enablePicker) return null; + } + const hexColor = new Color(color).hex(); + return ( + + + + + + ); +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_columns.test.ts b/src/plugins/vis_type_pie/public/utils/get_columns.test.ts new file mode 100644 index 0000000000000..3170628ec2e12 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_columns.test.ts @@ -0,0 +1,222 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { getColumns } from './get_columns'; +import { PieVisParams } from '../types'; +import { createMockPieParams, createMockVisData } from '../mocks'; + +const visParams = createMockPieParams(); +const visData = createMockVisData(); + +describe('getColumns', () => { + it('should return the correct bucket columns if visParams returns dimensions', () => { + const { bucketColumns } = getColumns(visParams, visData); + expect(bucketColumns.length).toEqual(visParams.dimensions.buckets?.length); + expect(bucketColumns).toEqual([ + { + format: { + id: 'terms', + params: { + id: 'string', + missingBucketLabel: 'Missing', + otherBucketLabel: 'Other', + }, + }, + id: 'col-0-2', + meta: { + field: 'Carrier', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'string', + missingBucketLabel: 'Missing', + otherBucketLabel: 'Other', + }, + }, + source: 'esaggs', + sourceParams: { + enabled: true, + id: '2', + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + params: { + field: 'Carrier', + missingBucket: false, + missingBucketLabel: 'Missing', + order: 'desc', + orderBy: '1', + otherBucket: false, + otherBucketLabel: 'Other', + size: 5, + }, + schema: 'segment', + type: 'terms', + }, + type: 'string', + }, + name: 'Carrier: Descending', + }, + { + format: { + id: 'terms', + params: { + id: 'boolean', + missingBucketLabel: 'Missing', + otherBucketLabel: 'Other', + }, + }, + id: 'col-2-3', + meta: { + field: 'Cancelled', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'boolean', + missingBucketLabel: 'Missing', + otherBucketLabel: 'Other', + }, + }, + source: 'esaggs', + sourceParams: { + enabled: true, + id: '3', + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + params: { + field: 'Cancelled', + missingBucket: false, + missingBucketLabel: 'Missing', + order: 'desc', + orderBy: '1', + otherBucket: false, + otherBucketLabel: 'Other', + size: 5, + }, + schema: 'segment', + type: 'terms', + }, + type: 'boolean', + }, + name: 'Cancelled: Descending', + }, + ]); + }); + + it('should return the correct metric column if visParams returns dimensions', () => { + const { metricColumn } = getColumns(visParams, visData); + expect(metricColumn).toEqual({ + id: 'col-3-1', + meta: { + index: 'kibana_sample_data_flights', + params: { id: 'number' }, + source: 'esaggs', + sourceParams: { + enabled: true, + id: '1', + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + params: {}, + schema: 'metric', + type: 'count', + }, + type: 'number', + }, + name: 'Count', + }); + }); + + it('should return the first data column if no buckets specified', () => { + const visParamsOnlyMetric = ({ + addLegend: true, + addTooltip: true, + isDonut: true, + labels: { + position: 'default', + show: true, + truncate: 100, + values: true, + valuesFormat: 'percent', + percentDecimals: 2, + }, + legendPosition: 'right', + nestedLegend: false, + palette: { + name: 'default', + type: 'palette', + }, + type: 'pie', + dimensions: { + metric: { + accessor: 1, + format: { + id: 'number', + }, + params: {}, + label: 'Count', + aggType: 'count', + }, + }, + } as unknown) as PieVisParams; + const { metricColumn } = getColumns(visParamsOnlyMetric, visData); + expect(metricColumn).toEqual({ + id: 'col-1-1', + meta: { + index: 'kibana_sample_data_flights', + params: { + id: 'number', + }, + source: 'esaggs', + sourceParams: { + enabled: true, + id: '1', + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + params: {}, + schema: 'metric', + type: 'count', + }, + type: 'number', + }, + name: 'Count', + }); + }); + + it('should return an object with the name of the metric if no buckets specified', () => { + const visParamsOnlyMetric = ({ + addLegend: true, + addTooltip: true, + isDonut: true, + labels: { + position: 'default', + show: true, + truncate: 100, + values: true, + valuesFormat: 'percent', + percentDecimals: 2, + }, + legendPosition: 'right', + nestedLegend: false, + palette: { + name: 'default', + type: 'palette', + }, + type: 'pie', + dimensions: { + metric: { + accessor: 1, + format: { + id: 'number', + }, + params: {}, + label: 'Count', + aggType: 'count', + }, + }, + } as unknown) as PieVisParams; + const { bucketColumns, metricColumn } = getColumns(visParamsOnlyMetric, visData); + expect(bucketColumns).toEqual([{ name: metricColumn.name }]); + }); +}); diff --git a/src/plugins/vis_type_pie/public/utils/get_columns.ts b/src/plugins/vis_type_pie/public/utils/get_columns.ts new file mode 100644 index 0000000000000..4a32466d808da --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_columns.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 { DatatableColumn, Datatable } from '../../../expressions/public'; +import { BucketColumns, PieVisParams } from '../types'; + +export const getColumns = ( + visParams: PieVisParams, + visData: Datatable +): { + metricColumn: DatatableColumn; + bucketColumns: Array>; +} => { + if (visParams.dimensions.buckets && visParams.dimensions.buckets.length > 0) { + const bucketColumns: Array> = visParams.dimensions.buckets.map( + ({ accessor, format }) => ({ + ...visData.columns[accessor], + format, + }) + ); + const lastBucketId = bucketColumns[bucketColumns.length - 1].id; + const matchingIndex = visData.columns.findIndex((col) => col.id === lastBucketId); + return { + bucketColumns, + metricColumn: visData.columns[matchingIndex + 1], + }; + } + const metricAccessor = visParams?.dimensions?.metric.accessor ?? 0; + const metricColumn = visData.columns[metricAccessor]; + return { + metricColumn, + bucketColumns: [ + { + name: metricColumn.name, + }, + ], + }; +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_config.ts b/src/plugins/vis_type_pie/public/utils/get_config.ts new file mode 100644 index 0000000000000..a8a4edb01cd9c --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_config.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PartitionConfig, PartitionLayout, RecursivePartial, Theme } from '@elastic/charts'; +import { LabelPositions, PieVisParams, PieContainerDimensions } from '../types'; +const MAX_SIZE = 1000; + +export const getConfig = ( + visParams: PieVisParams, + chartTheme: RecursivePartial, + dimensions?: PieContainerDimensions +): RecursivePartial => { + // On small multiples we want the labels to only appear inside + const isSplitChart = Boolean(visParams.dimensions.splitColumn || visParams.dimensions.splitRow); + const usingMargin = + dimensions && !isSplitChart + ? { + margin: { + top: (1 - Math.min(1, MAX_SIZE / dimensions?.height)) / 2, + bottom: (1 - Math.min(1, MAX_SIZE / dimensions?.height)) / 2, + left: (1 - Math.min(1, MAX_SIZE / dimensions?.width)) / 2, + right: (1 - Math.min(1, MAX_SIZE / dimensions?.width)) / 2, + }, + } + : null; + + const usingOuterSizeRatio = + dimensions && !isSplitChart + ? { + outerSizeRatio: MAX_SIZE / Math.min(dimensions?.width, dimensions?.height), + } + : null; + const config: RecursivePartial = { + partitionLayout: PartitionLayout.sunburst, + fontFamily: chartTheme.barSeriesStyle?.displayValue?.fontFamily, + ...usingOuterSizeRatio, + specialFirstInnermostSector: false, + minFontSize: 10, + maxFontSize: 16, + linkLabel: { + maxCount: 5, + fontSize: 11, + textColor: chartTheme.axes?.axisTitle?.fill, + maxTextLength: visParams.labels.truncate ?? undefined, + }, + sectorLineStroke: chartTheme.lineSeriesStyle?.point?.fill, + sectorLineWidth: 1.5, + circlePadding: 4, + emptySizeRatio: visParams.isDonut ? 0.3 : 0, + ...usingMargin, + }; + if (!visParams.labels.show) { + // Force all labels to be linked, then prevent links from showing + config.linkLabel = { maxCount: 0, maximumSection: Number.POSITIVE_INFINITY }; + } + + if (visParams.labels.last_level && visParams.labels.show) { + config.linkLabel = { + maxCount: Number.POSITIVE_INFINITY, + maximumSection: Number.POSITIVE_INFINITY, + }; + } + + if ( + (visParams.labels.position === LabelPositions.INSIDE || isSplitChart) && + visParams.labels.show + ) { + config.linkLabel = { maxCount: 0 }; + } + return config; +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_distinct_series.test.ts b/src/plugins/vis_type_pie/public/utils/get_distinct_series.test.ts new file mode 100644 index 0000000000000..3d700614a07ed --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_distinct_series.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { getDistinctSeries } from './get_distinct_series'; +import { createMockVisData, createMockBucketColumns } from '../mocks'; + +const visData = createMockVisData(); +const buckets = createMockBucketColumns(); + +describe('getDistinctSeries', () => { + it('should return the distinct values for all buckets', () => { + const { allSeries } = getDistinctSeries(visData.rows, buckets); + expect(allSeries).toEqual(['Logstash Airways', 'JetBeats', 'ES-Air', 'Kibana Airlines', 0, 1]); + }); + + it('should return only the distinct values for the parent bucket', () => { + const { parentSeries } = getDistinctSeries(visData.rows, buckets); + expect(parentSeries).toEqual(['Logstash Airways', 'JetBeats', 'ES-Air', 'Kibana Airlines']); + }); + + it('should return empty array for empty buckets', () => { + const { parentSeries } = getDistinctSeries(visData.rows, [{ name: 'Count' }]); + expect(parentSeries.length).toEqual(0); + }); +}); diff --git a/src/plugins/vis_type_pie/public/utils/get_distinct_series.ts b/src/plugins/vis_type_pie/public/utils/get_distinct_series.ts new file mode 100644 index 0000000000000..ba5042dfc210c --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_distinct_series.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { DatatableRow } from '../../../expressions/public'; +import { BucketColumns } from '../types'; + +export const getDistinctSeries = (rows: DatatableRow[], buckets: Array>) => { + const parentBucketId = buckets[0].id; + const parentSeries: string[] = []; + const allSeries: string[] = []; + buckets.forEach(({ id }) => { + if (!id) return; + rows.forEach((row) => { + const name = row[id]; + if (!allSeries.includes(name)) { + allSeries.push(name); + } + if (id === parentBucketId && !parentSeries.includes(row[parentBucketId])) { + parentSeries.push(row[parentBucketId]); + } + }); + }); + return { + allSeries, + parentSeries, + }; +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_layers.test.ts b/src/plugins/vis_type_pie/public/utils/get_layers.test.ts new file mode 100644 index 0000000000000..e0658eaa295f9 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_layers.test.ts @@ -0,0 +1,114 @@ +/* + * Copyright 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 { ShapeTreeNode } from '@elastic/charts'; +import { PaletteDefinition, SeriesLayer } from '../../../charts/public'; +import { computeColor } from './get_layers'; +import { createMockVisData, createMockBucketColumns, createMockPieParams } from '../mocks'; + +const visData = createMockVisData(); +const buckets = createMockBucketColumns(); +const visParams = createMockPieParams(); +const colors = ['color1', 'color2', 'color3', 'color4']; +export const getPaletteRegistry = () => { + const mockPalette1: jest.Mocked = { + id: 'default', + title: 'My Palette', + getCategoricalColor: jest.fn((layer: SeriesLayer[]) => colors[layer[0].rankAtDepth]), + getCategoricalColors: jest.fn((num: number) => colors), + toExpression: jest.fn(() => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'system_palette', + arguments: { + name: ['default'], + }, + }, + ], + })), + }; + + return { + get: () => mockPalette1, + getAll: () => [mockPalette1], + }; +}; + +describe('computeColor', () => { + it('should return the correct color based on the parent sortIndex', () => { + const d = ({ + dataName: 'ES-Air', + depth: 1, + sortIndex: 0, + parent: { + children: [['ES-Air'], ['Kibana Airlines']], + depth: 0, + sortIndex: 0, + }, + } as unknown) as ShapeTreeNode; + const color = computeColor( + d, + false, + {}, + buckets, + visData.rows, + visParams, + getPaletteRegistry(), + false + ); + expect(color).toEqual(colors[0]); + }); + + it('slices with the same label should have the same color for small multiples', () => { + const d = ({ + dataName: 'ES-Air', + depth: 1, + sortIndex: 0, + parent: { + children: [['ES-Air'], ['Kibana Airlines']], + depth: 0, + sortIndex: 0, + }, + } as unknown) as ShapeTreeNode; + const color = computeColor( + d, + true, + {}, + buckets, + visData.rows, + visParams, + getPaletteRegistry(), + false + ); + expect(color).toEqual('color3'); + }); + it('returns the overwriteColor if exists', () => { + const d = ({ + dataName: 'ES-Air', + depth: 1, + sortIndex: 0, + parent: { + children: [['ES-Air'], ['Kibana Airlines']], + depth: 0, + sortIndex: 0, + }, + } as unknown) as ShapeTreeNode; + const color = computeColor( + d, + true, + { 'ES-Air': '#000028' }, + buckets, + visData.rows, + visParams, + getPaletteRegistry(), + false + ); + expect(color).toEqual('#000028'); + }); +}); diff --git a/src/plugins/vis_type_pie/public/utils/get_layers.ts b/src/plugins/vis_type_pie/public/utils/get_layers.ts new file mode 100644 index 0000000000000..27dcf2d379811 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_layers.ts @@ -0,0 +1,186 @@ +/* + * Copyright 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 { i18n } from '@kbn/i18n'; +import { + Datum, + PartitionFillLabel, + PartitionLayer, + ShapeTreeNode, + ArrayEntry, +} from '@elastic/charts'; +import { isEqual } from 'lodash'; +import { SeriesLayer, PaletteRegistry, lightenColor } from '../../../charts/public'; +import { DataPublicPluginStart } from '../../../data/public'; +import { DatatableRow } from '../../../expressions/public'; +import { BucketColumns, PieVisParams, SplitDimensionParams } from '../types'; +import { getDistinctSeries } from './get_distinct_series'; + +const EMPTY_SLICE = Symbol('empty_slice'); + +export const computeColor = ( + d: ShapeTreeNode, + isSplitChart: boolean, + overwriteColors: { [key: string]: string }, + columns: Array>, + rows: DatatableRow[], + visParams: PieVisParams, + palettes: PaletteRegistry | null, + syncColors: boolean +) => { + const { parentSeries, allSeries } = getDistinctSeries(rows, columns); + + if (visParams.distinctColors) { + const dataName = d.dataName; + if (Object.keys(overwriteColors).includes(dataName.toString())) { + return overwriteColors[dataName]; + } + + const index = allSeries.findIndex((name) => isEqual(name, dataName)); + const isSplitParentLayer = isSplitChart && parentSeries.includes(dataName); + return palettes?.get(visParams.palette.name).getCategoricalColor( + [ + { + name: dataName, + rankAtDepth: isSplitParentLayer + ? parentSeries.findIndex((name) => name === dataName) + : index > -1 + ? index + : 0, + totalSeriesAtDepth: isSplitParentLayer ? parentSeries.length : allSeries.length || 1, + }, + ], + { + maxDepth: 1, + totalSeries: allSeries.length || 1, + behindText: visParams.labels.show, + syncColors, + } + ); + } + const seriesLayers: SeriesLayer[] = []; + let tempParent: typeof d | typeof d['parent'] = d; + while (tempParent.parent && tempParent.depth > 0) { + const seriesName = String(tempParent.parent.children[tempParent.sortIndex][0]); + const isSplitParentLayer = isSplitChart && parentSeries.includes(seriesName); + seriesLayers.unshift({ + name: seriesName, + rankAtDepth: isSplitParentLayer + ? parentSeries.findIndex((name) => name === seriesName) + : tempParent.sortIndex, + totalSeriesAtDepth: isSplitParentLayer + ? parentSeries.length + : tempParent.parent.children.length, + }); + tempParent = tempParent.parent; + } + + let overwriteColor; + seriesLayers.forEach((layer) => { + if (Object.keys(overwriteColors).includes(layer.name)) { + overwriteColor = overwriteColors[layer.name]; + } + }); + + if (overwriteColor) { + return lightenColor(overwriteColor, seriesLayers.length, columns.length); + } + return palettes?.get(visParams.palette.name).getCategoricalColor(seriesLayers, { + behindText: visParams.labels.show, + maxDepth: columns.length, + totalSeries: rows.length, + syncColors, + }); +}; + +export const getLayers = ( + columns: Array>, + visParams: PieVisParams, + overwriteColors: { [key: string]: string }, + rows: DatatableRow[], + palettes: PaletteRegistry | null, + formatter: DataPublicPluginStart['fieldFormats'], + syncColors: boolean +): PartitionLayer[] => { + const fillLabel: Partial = { + textInvertible: true, + valueFont: { + fontWeight: 700, + }, + }; + + if (!visParams.labels.values) { + fillLabel.valueFormatter = () => ''; + } + const isSplitChart = Boolean(visParams.dimensions.splitColumn || visParams.dimensions.splitRow); + return columns.map((col) => { + return { + groupByRollup: (d: Datum) => { + return col.id ? d[col.id] : col.name; + }, + showAccessor: (d: Datum) => d !== EMPTY_SLICE, + nodeLabel: (d: unknown) => { + if (d === '') { + return i18n.translate('visTypePie.emptyLabelValue', { + defaultMessage: '(empty)', + }); + } + if (col.format) { + const formattedLabel = formatter.deserialize(col.format).convert(d) ?? ''; + if (visParams.labels.truncate && formattedLabel.length <= visParams.labels.truncate) { + return formattedLabel; + } else { + return `${formattedLabel.slice(0, Number(visParams.labels.truncate))}\u2026`; + } + } + return String(d); + }, + sortPredicate: ([name1, node1]: ArrayEntry, [name2, node2]: ArrayEntry) => { + const params = col.meta?.sourceParams?.params as SplitDimensionParams | undefined; + const sort: string | undefined = params?.orderBy; + // unconditionally put "Other" to the end (as the "Other" slice may be larger than a regular slice, yet should be at the end) + if (name1 === '__other__' && name2 !== '__other__') return 1; + if (name2 === '__other__' && name1 !== '__other__') return -1; + // metric sorting + if (sort !== '_key') { + if (params?.order === 'desc') { + return node2.value - node1.value; + } else { + return node1.value - node2.value; + } + // alphabetical sorting + } else { + if (name1 > name2) { + return params?.order === 'desc' ? -1 : 1; + } + if (name2 > name1) { + return params?.order === 'desc' ? 1 : -1; + } + } + return 0; + }, + fillLabel, + shape: { + fillColor: (d) => { + const outputColor = computeColor( + d, + isSplitChart, + overwriteColors, + columns, + rows, + visParams, + palettes, + syncColors + ); + + return outputColor || 'rgba(0,0,0,0)'; + }, + }, + }; + }); +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_legend_actions.tsx b/src/plugins/vis_type_pie/public/utils/get_legend_actions.tsx new file mode 100644 index 0000000000000..9f1d5e0db4583 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_legend_actions.tsx @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useEffect } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { EuiContextMenuPanelDescriptor, EuiIcon, EuiPopover, EuiContextMenu } from '@elastic/eui'; +import { LegendAction, SeriesIdentifier } from '@elastic/charts'; +import { DataPublicPluginStart } from '../../../data/public'; +import { PieVisParams } from '../types'; +import { ClickTriggerEvent } from '../../../charts/public'; + +export const getLegendActions = ( + canFilter: ( + data: ClickTriggerEvent | null, + actions: DataPublicPluginStart['actions'] + ) => Promise, + getFilterEventData: (series: SeriesIdentifier) => ClickTriggerEvent | null, + onFilter: (data: ClickTriggerEvent, negate?: any) => void, + visParams: PieVisParams, + actions: DataPublicPluginStart['actions'], + formatter: DataPublicPluginStart['fieldFormats'] +): LegendAction => { + return ({ series: [pieSeries] }) => { + const [popoverOpen, setPopoverOpen] = useState(false); + const [isfilterable, setIsfilterable] = useState(true); + const filterData = getFilterEventData(pieSeries); + + useEffect(() => { + (async () => setIsfilterable(await canFilter(filterData, actions)))(); + }, [filterData]); + + if (!isfilterable || !filterData) { + return null; + } + + let formattedTitle = ''; + if (visParams.dimensions.buckets) { + const column = visParams.dimensions.buckets.find( + (bucket) => bucket.accessor === filterData.data.data[0].column + ); + formattedTitle = formatter.deserialize(column?.format).convert(pieSeries.key) ?? ''; + } + + const title = formattedTitle || pieSeries.key; + const panels: EuiContextMenuPanelDescriptor[] = [ + { + id: 'main', + title: `${title}`, + items: [ + { + name: i18n.translate('visTypePie.legend.filterForValueButtonAriaLabel', { + defaultMessage: 'Filter for value', + }), + 'data-test-subj': `legend-${title}-filterIn`, + icon: , + onClick: () => { + setPopoverOpen(false); + onFilter(filterData); + }, + }, + { + name: i18n.translate('visTypePie.legend.filterOutValueButtonAriaLabel', { + defaultMessage: 'Filter out value', + }), + 'data-test-subj': `legend-${title}-filterOut`, + icon: , + onClick: () => { + setPopoverOpen(false); + onFilter(filterData, true); + }, + }, + ], + }, + ]; + + const Button = ( +
undefined} + onClick={() => setPopoverOpen(!popoverOpen)} + > + +
+ ); + + return ( + setPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="upLeft" + title={i18n.translate('visTypePie.legend.filterOptionsLegend', { + defaultMessage: '{legendDataLabel}, filter options', + values: { legendDataLabel: title }, + })} + > + + + ); + }; +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_split_dimension_accessor.ts b/src/plugins/vis_type_pie/public/utils/get_split_dimension_accessor.ts new file mode 100644 index 0000000000000..e1029b11a7b75 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_split_dimension_accessor.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { AccessorFn } from '@elastic/charts'; +import { FieldFormatsStart } from '../../../data/public'; +import { DatatableColumn } from '../../../expressions/public'; +import { Dimension } from '../types'; + +export const getSplitDimensionAccessor = ( + fieldFormats: FieldFormatsStart, + columns: DatatableColumn[] +) => (splitDimension: Dimension): AccessorFn => { + const formatter = fieldFormats.deserialize(splitDimension.format); + const splitChartColumn = columns[splitDimension.accessor]; + const accessor = splitChartColumn.id; + + const fn: AccessorFn = (d) => { + const v = d[accessor]; + if (v === undefined) { + return; + } + const f = formatter.convert(v); + return f; + }; + + return fn; +}; diff --git a/src/plugins/vis_type_pie/public/utils/index.ts b/src/plugins/vis_type_pie/public/utils/index.ts new file mode 100644 index 0000000000000..0cf4292ad565a --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { getLayers } from './get_layers'; +export { getColorPicker } from './get_color_picker'; +export { getLegendActions } from './get_legend_actions'; +export { canFilter, getFilterClickData, getFilterEventData } from './filter_helpers'; +export { getConfig } from './get_config'; +export { getColumns } from './get_columns'; +export { getSplitDimensionAccessor } from './get_split_dimension_accessor'; +export { getDistinctSeries } from './get_distinct_series'; diff --git a/src/plugins/vis_type_pie/public/vis_type/index.ts b/src/plugins/vis_type_pie/public/vis_type/index.ts new file mode 100644 index 0000000000000..e02e802028a35 --- /dev/null +++ b/src/plugins/vis_type_pie/public/vis_type/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { getPieVisTypeDefinition } from './pie'; +import type { PieTypeProps } from '../types'; + +export const pieVisType = (props: PieTypeProps) => { + return getPieVisTypeDefinition(props); +}; diff --git a/src/plugins/vis_type_pie/public/vis_type/pie.ts b/src/plugins/vis_type_pie/public/vis_type/pie.ts new file mode 100644 index 0000000000000..9d1556ac33ad7 --- /dev/null +++ b/src/plugins/vis_type_pie/public/vis_type/pie.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 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 { i18n } from '@kbn/i18n'; +import { Position } from '@elastic/charts'; +import { AggGroupNames } from '../../../data/public'; +import { VIS_EVENT_TO_TRIGGER, VisTypeDefinition } from '../../../visualizations/public'; +import { DEFAULT_PERCENT_DECIMALS } from '../../common'; +import { PieVisParams, LabelPositions, ValueFormats, PieTypeProps } from '../types'; +import { toExpressionAst } from '../to_ast'; +import { getPieOptions } from '../editor/components'; + +export const getPieVisTypeDefinition = ({ + showElasticChartsOptions = false, + palettes, + trackUiMetric, +}: PieTypeProps): VisTypeDefinition => ({ + name: 'pie', + title: i18n.translate('visTypePie.pie.pieTitle', { defaultMessage: 'Pie' }), + icon: 'visPie', + description: i18n.translate('visTypePie.pie.pieDescription', { + defaultMessage: 'Compare data in proportion to a whole.', + }), + toExpressionAst, + getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter], + visConfig: { + defaults: { + type: 'pie', + addTooltip: true, + addLegend: !showElasticChartsOptions, + legendPosition: Position.Right, + nestedLegend: false, + distinctColors: false, + isDonut: true, + palette: { + type: 'palette', + name: 'default', + }, + labels: { + show: true, + last_level: !showElasticChartsOptions, + values: true, + valuesFormat: ValueFormats.PERCENT, + percentDecimals: DEFAULT_PERCENT_DECIMALS, + truncate: 100, + position: LabelPositions.DEFAULT, + }, + }, + }, + editorConfig: { + optionsTemplate: getPieOptions({ + showElasticChartsOptions, + palettes, + trackUiMetric, + }), + schemas: [ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visTypePie.pie.metricTitle', { + defaultMessage: 'Slice size', + }), + min: 1, + max: 1, + aggFilter: ['sum', 'count', 'cardinality', 'top_hits'], + defaults: [{ schema: 'metric', type: 'count' }], + }, + { + group: AggGroupNames.Buckets, + name: 'segment', + title: i18n.translate('visTypePie.pie.segmentTitle', { + defaultMessage: 'Split slices', + }), + min: 0, + max: Infinity, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + }, + { + group: AggGroupNames.Buckets, + name: 'split', + title: i18n.translate('visTypePie.pie.splitTitle', { + defaultMessage: 'Split chart', + }), + mustBeFirst: true, + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + }, + ], + }, + hierarchicalData: true, + requiresSearch: true, +}); diff --git a/src/plugins/vis_type_pie/tsconfig.json b/src/plugins/vis_type_pie/tsconfig.json new file mode 100644 index 0000000000000..f12db316f1972 --- /dev/null +++ b/src/plugins/vis_type_pie/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*" + ], + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../charts/tsconfig.json" }, + { "path": "../data/tsconfig.json" }, + { "path": "../expressions/tsconfig.json" }, + { "path": "../visualizations/tsconfig.json" }, + { "path": "../usage_collection/tsconfig.json" }, + { "path": "../vis_default_editor/tsconfig.json" }, + ] + } \ No newline at end of file diff --git a/src/plugins/vis_type_vislib/kibana.json b/src/plugins/vis_type_vislib/kibana.json index 175c21f47c182..56dfba0aca59c 100644 --- a/src/plugins/vis_type_vislib/kibana.json +++ b/src/plugins/vis_type_vislib/kibana.json @@ -4,5 +4,5 @@ "server": true, "ui": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations", "kibanaLegacy"], - "requiredBundles": ["kibanaUtils", "visDefaultEditor", "visTypeXy"] + "requiredBundles": ["kibanaUtils", "visDefaultEditor", "visTypeXy", "visTypePie"] } diff --git a/src/plugins/vis_type_vislib/public/editor/components/index.tsx b/src/plugins/vis_type_vislib/public/editor/components/index.tsx index a90aaeab58503..34547dc7115e2 100644 --- a/src/plugins/vis_type_vislib/public/editor/components/index.tsx +++ b/src/plugins/vis_type_vislib/public/editor/components/index.tsx @@ -10,21 +10,15 @@ import React, { lazy } from 'react'; import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; import { GaugeVisParams } from '../../gauge'; -import { PieVisParams } from '../../pie'; import { HeatmapVisParams } from '../../heatmap'; const GaugeOptionsLazy = lazy(() => import('./gauge')); -const PieOptionsLazy = lazy(() => import('./pie')); const HeatmapOptionsLazy = lazy(() => import('./heatmap')); export const GaugeOptions = (props: VisEditorOptionsProps) => ( ); -export const PieOptions = (props: VisEditorOptionsProps) => ( - -); - export const HeatmapOptions = (props: VisEditorOptionsProps) => ( ); diff --git a/src/plugins/vis_type_vislib/public/editor/components/pie.tsx b/src/plugins/vis_type_vislib/public/editor/components/pie.tsx deleted file mode 100644 index 6c84bc744676a..0000000000000 --- a/src/plugins/vis_type_vislib/public/editor/components/pie.tsx +++ /dev/null @@ -1,97 +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 React from 'react'; - -import { EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; -import { BasicOptions, SwitchOption } from '../../../../vis_default_editor/public'; -import { TruncateLabelsOption, getPositions } from '../../../../vis_type_xy/public'; - -import { PieVisParams } from '../../pie'; - -const legendPositions = getPositions(); - -function PieOptions(props: VisEditorOptionsProps) { - const { stateParams, setValue } = props; - const setLabels = ( - paramName: T, - value: PieVisParams['labels'][T] - ) => setValue('labels', { ...stateParams.labels, [paramName]: value }); - - return ( - <> - - -

- -

-
- - - -
- - - - - -

- -

-
- - - - - -
- - ); -} - -// default export required for React.Lazy -// eslint-disable-next-line import/no-default-export -export { PieOptions as default }; diff --git a/src/plugins/vis_type_vislib/public/pie.ts b/src/plugins/vis_type_vislib/public/pie.ts index d1d8d2a5279fe..4f6eb7e536509 100644 --- a/src/plugins/vis_type_vislib/public/pie.ts +++ b/src/plugins/vis_type_vislib/public/pie.ts @@ -6,14 +6,9 @@ * Side Public License, v 1. */ -import { i18n } from '@kbn/i18n'; -import { Position } from '@elastic/charts'; - -import { AggGroupNames } from '../../data/public'; -import { VisTypeDefinition, VIS_EVENT_TO_TRIGGER } from '../../visualizations/public'; - +import { pieVisType } from '../../vis_type_pie/public'; +import { VisTypeDefinition } from '../../visualizations/public'; import { CommonVislibParams } from './types'; -import { PieOptions } from './editor'; import { toExpressionAst } from './to_ast_pie'; export interface PieVisParams extends CommonVislibParams { @@ -27,67 +22,7 @@ export interface PieVisParams extends CommonVislibParams { }; } -export const pieVisTypeDefinition: VisTypeDefinition = { - name: 'pie', - title: i18n.translate('visTypeVislib.pie.pieTitle', { defaultMessage: 'Pie' }), - icon: 'visPie', - description: i18n.translate('visTypeVislib.pie.pieDescription', { - defaultMessage: 'Compare data in proportion to a whole.', - }), - getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter], +export const pieVisTypeDefinition = { + ...pieVisType({}), toExpressionAst, - visConfig: { - defaults: { - type: 'pie', - addTooltip: true, - addLegend: true, - legendPosition: Position.Right, - isDonut: true, - labels: { - show: false, - values: true, - last_level: true, - truncate: 100, - }, - }, - }, - editorConfig: { - optionsTemplate: PieOptions, - schemas: [ - { - group: AggGroupNames.Metrics, - name: 'metric', - title: i18n.translate('visTypeVislib.pie.metricTitle', { - defaultMessage: 'Slice size', - }), - min: 1, - max: 1, - aggFilter: ['sum', 'count', 'cardinality', 'top_hits'], - defaults: [{ schema: 'metric', type: 'count' }], - }, - { - group: AggGroupNames.Buckets, - name: 'segment', - title: i18n.translate('visTypeVislib.pie.segmentTitle', { - defaultMessage: 'Split slices', - }), - min: 0, - max: Infinity, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], - }, - { - group: AggGroupNames.Buckets, - name: 'split', - title: i18n.translate('visTypeVislib.pie.splitTitle', { - defaultMessage: 'Split chart', - }), - mustBeFirst: true, - min: 0, - max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], - }, - ], - }, - hierarchicalData: true, - requiresSearch: true, -}; +} as VisTypeDefinition; diff --git a/src/plugins/vis_type_vislib/public/plugin.ts b/src/plugins/vis_type_vislib/public/plugin.ts index 9d329c92bede0..52faf8a74778c 100644 --- a/src/plugins/vis_type_vislib/public/plugin.ts +++ b/src/plugins/vis_type_vislib/public/plugin.ts @@ -13,7 +13,7 @@ import { VisualizationsSetup } from '../../visualizations/public'; import { ChartsPluginSetup } from '../../charts/public'; import { DataPublicPluginStart } from '../../data/public'; import { KibanaLegacyStart } from '../../kibana_legacy/public'; -import { LEGACY_CHARTS_LIBRARY } from '../../vis_type_xy/public'; +import { LEGACY_CHARTS_LIBRARY } from '../../visualizations/common/constants'; import { createVisTypeVislibVisFn } from './vis_type_vislib_vis_fn'; import { createPieVisFn } from './pie_fn'; @@ -53,9 +53,8 @@ export class VisTypeVislibPlugin if (!core.uiSettings.get(LEGACY_CHARTS_LIBRARY, false)) { // Register only non-replaced vis types convertedTypeDefinitions.forEach(visualizations.createBaseVisualization); - visualizations.createBaseVisualization(pieVisTypeDefinition); expressions.registerRenderer(getVislibVisRenderer(core, charts)); - [createVisTypeVislibVisFn(), createPieVisFn()].forEach(expressions.registerFunction); + expressions.registerFunction(createVisTypeVislibVisFn()); } else { // Register all vis types visLibVisTypeDefinitions.forEach(visualizations.createBaseVisualization); diff --git a/src/plugins/vis_type_vislib/public/to_ast_pie.test.ts b/src/plugins/vis_type_vislib/public/to_ast_pie.test.ts index 3ca52f27e3fa1..3178c23ee8fa0 100644 --- a/src/plugins/vis_type_vislib/public/to_ast_pie.test.ts +++ b/src/plugins/vis_type_vislib/public/to_ast_pie.test.ts @@ -10,7 +10,7 @@ import { Vis } from '../../visualizations/public'; import { buildExpression } from '../../expressions/public'; import { PieVisParams } from './pie'; -import { samplePieVis } from '../../vis_type_xy/public/sample_vis.test.mocks'; +import { samplePieVis } from '../../vis_type_pie/public/sample_vis.test.mocks'; import { toExpressionAst } from './to_ast_pie'; jest.mock('../../expressions/public', () => ({ diff --git a/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.test.ts b/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.test.ts index 71f692b80b531..de91053b6dc4d 100644 --- a/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.test.ts +++ b/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.test.ts @@ -5,8 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - -import { buildHierarchicalData, Dimensions, Dimension } from './build_hierarchical_data'; +import type { Dimensions, Dimension } from '../../../../../vis_type_pie/public'; +import { buildHierarchicalData } from './build_hierarchical_data'; import { Table, TableParent } from '../../types'; function tableVisResponseHandler(table: Table, dimensions: Dimensions) { diff --git a/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.ts b/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.ts index b235d3936ae0f..da10edf9591fb 100644 --- a/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.ts +++ b/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.ts @@ -7,24 +7,9 @@ */ import { toArray } from 'lodash'; -import { SerializedFieldFormat } from '../../../../../expressions/common/types'; import { getFormatService } from '../../../services'; import { Table } from '../../types'; - -export interface Dimension { - accessor: number; - format: { - id?: string; - params?: SerializedFieldFormat; - }; -} - -export interface Dimensions { - metric: Dimension; - buckets?: Dimension[]; - splitRow?: Dimension[]; - splitColumn?: Dimension[]; -} +import type { Dimensions } from '../../../../../vis_type_pie/public'; interface Slice { name: string; diff --git a/src/plugins/vis_type_vislib/tsconfig.json b/src/plugins/vis_type_vislib/tsconfig.json index 74bc1440d9dbc..5bf1af9ba75fe 100644 --- a/src/plugins/vis_type_vislib/tsconfig.json +++ b/src/plugins/vis_type_vislib/tsconfig.json @@ -22,5 +22,6 @@ { "path": "../kibana_utils/tsconfig.json" }, { "path": "../vis_default_editor/tsconfig.json" }, { "path": "../vis_type_xy/tsconfig.json" }, + { "path": "../vis_type_pie/tsconfig.json" }, ] } diff --git a/src/plugins/vis_type_xy/common/index.ts b/src/plugins/vis_type_xy/common/index.ts index a80946f7c62fa..f17bc8476d9a6 100644 --- a/src/plugins/vis_type_xy/common/index.ts +++ b/src/plugins/vis_type_xy/common/index.ts @@ -19,5 +19,3 @@ export enum ChartType { * Type of xy visualizations */ export type XyVisType = ChartType | 'horizontal_bar'; - -export const LEGACY_CHARTS_LIBRARY = 'visualization:visualize:legacyChartsLibrary'; diff --git a/src/plugins/vis_type_xy/kibana.json b/src/plugins/vis_type_xy/kibana.json index 619fa8e71c0dd..a32b1e4d1d8b5 100644 --- a/src/plugins/vis_type_xy/kibana.json +++ b/src/plugins/vis_type_xy/kibana.json @@ -1,7 +1,6 @@ { "id": "visTypeXy", "version": "kibana", - "server": true, "ui": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations", "usageCollection"], "requiredBundles": ["kibanaUtils", "visDefaultEditor"] diff --git a/src/plugins/vis_type_xy/public/plugin.ts b/src/plugins/vis_type_xy/public/plugin.ts index 7bdb4f78bc631..e8d53127765b4 100644 --- a/src/plugins/vis_type_xy/public/plugin.ts +++ b/src/plugins/vis_type_xy/public/plugin.ts @@ -23,7 +23,7 @@ import { } from './services'; import { visTypesDefinitions } from './vis_types'; -import { LEGACY_CHARTS_LIBRARY } from '../common'; +import { LEGACY_CHARTS_LIBRARY } from '../../visualizations/common/constants'; import { xyVisRenderer } from './vis_renderer'; import * as expressionFunctions from './expression_functions'; diff --git a/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts b/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts index 39370d941b52a..8fafd4c723055 100644 --- a/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts @@ -5,1325 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - -export const samplePieVis = { - type: { - name: 'pie', - title: 'Pie', - description: 'Compare parts of a whole', - icon: 'visPie', - stage: 'production', - options: { - showTimePicker: true, - showQueryBar: true, - showFilterBar: true, - showIndexSelection: true, - hierarchicalData: false, - }, - visConfig: { - defaults: { - type: 'pie', - addTooltip: true, - addLegend: true, - legendPosition: 'right', - isDonut: true, - labels: { - show: false, - values: true, - last_level: true, - truncate: 100, - }, - }, - }, - editorConfig: { - collections: { - legendPositions: [ - { - text: 'Top', - value: 'top', - }, - { - text: 'Left', - value: 'left', - }, - { - text: 'Right', - value: 'right', - }, - { - text: 'Bottom', - value: 'bottom', - }, - ], - }, - schemas: { - all: [ - { - group: 'metrics', - name: 'metric', - title: 'Slice size', - min: 1, - max: 1, - aggFilter: ['sum', 'count', 'cardinality', 'top_hits'], - defaults: [ - { - schema: 'metric', - type: 'count', - }, - ], - editor: false, - params: [], - }, - { - group: 'buckets', - name: 'segment', - title: 'Split slices', - min: 0, - max: null, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], - editor: false, - params: [], - }, - { - group: 'buckets', - name: 'split', - title: 'Split chart', - mustBeFirst: true, - min: 0, - max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], - params: [ - { - name: 'row', - default: true, - }, - ], - editor: false, - }, - ], - buckets: [null, null], - metrics: [null], - }, - }, - hidden: false, - hierarchicalData: true, - }, - title: '[Flights] Airline Carrier', - description: '', - params: { - type: 'pie', - addTooltip: true, - addLegend: true, - legendPosition: 'right', - isDonut: true, - labels: { - show: true, - values: true, - last_level: true, - truncate: 100, - }, - }, - data: { - searchSource: { - id: 'data_source1', - requestStartHandlers: [], - inheritOptions: {}, - history: [], - fields: { - filter: [], - query: { - query: '', - language: 'kuery', - }, - index: { - id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', - title: 'kibana_sample_data_flights', - fieldFormatMap: { - AvgTicketPrice: { - id: 'number', - params: { - parsedUrl: { - origin: 'http://localhost:5801', - pathname: '/app/visualize', - basePath: '', - }, - pattern: '$0,0.[00]', - }, - }, - hour_of_day: { - id: 'number', - params: { - pattern: '00', - }, - }, - }, - fields: [ - { - count: 0, - name: 'AvgTicketPrice', - type: 'number', - esTypes: ['float'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'Cancelled', - type: 'boolean', - esTypes: ['boolean'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'Carrier', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'Dest', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestAirportID', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestCityName', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestCountry', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestLocation', - type: 'geo_point', - esTypes: ['geo_point'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestRegion', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestWeather', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DistanceKilometers', - type: 'number', - esTypes: ['float'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DistanceMiles', - type: 'number', - esTypes: ['float'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightDelay', - type: 'boolean', - esTypes: ['boolean'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightDelayMin', - type: 'number', - esTypes: ['integer'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightDelayType', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightNum', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightTimeHour', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightTimeMin', - type: 'number', - esTypes: ['float'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'Origin', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginAirportID', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginCityName', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginCountry', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginLocation', - type: 'geo_point', - esTypes: ['geo_point'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginRegion', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginWeather', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: '_id', - type: 'string', - esTypes: ['_id'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: false, - }, - { - count: 0, - name: '_index', - type: 'string', - esTypes: ['_index'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: false, - }, - { - count: 0, - name: '_score', - type: 'number', - scripted: false, - searchable: false, - aggregatable: false, - readFromDocValues: false, - }, - { - count: 0, - name: '_source', - type: '_source', - esTypes: ['_source'], - scripted: false, - searchable: false, - aggregatable: false, - readFromDocValues: false, - }, - { - count: 0, - name: '_type', - type: 'string', - esTypes: ['_type'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: false, - }, - { - count: 0, - name: 'dayOfWeek', - type: 'number', - esTypes: ['integer'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'timestamp', - type: 'date', - esTypes: ['date'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - script: "doc['timestamp'].value.hourOfDay", - lang: 'painless', - name: 'hour_of_day', - type: 'number', - scripted: true, - searchable: true, - aggregatable: true, - readFromDocValues: false, - }, - ], - timeFieldName: 'timestamp', - metaFields: ['_source', '_id', '_type', '_index', '_score'], - version: 'WzM1LDFd', - originalSavedObjectBody: { - title: 'kibana_sample_data_flights', - timeFieldName: 'timestamp', - fields: - '[{"count":0,"name":"AvgTicketPrice","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Cancelled","type":"boolean","esTypes":["boolean"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Carrier","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Dest","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestAirportID","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestCityName","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestCountry","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestLocation","type":"geo_point","esTypes":["geo_point"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestRegion","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestWeather","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DistanceKilometers","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DistanceMiles","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelay","type":"boolean","esTypes":["boolean"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelayMin","type":"number","esTypes":["integer"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelayType","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightNum","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightTimeHour","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightTimeMin","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Origin","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginAirportID","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginCityName","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginCountry","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginLocation","type":"geo_point","esTypes":["geo_point"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginRegion","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginWeather","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"_id","type":"string","esTypes":["_id"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"_index","type":"string","esTypes":["_index"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"_score","type":"number","scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"_source","type":"_source","esTypes":["_source"],"scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"_type","type":"string","esTypes":["_type"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"dayOfWeek","type":"number","esTypes":["integer"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"timestamp","type":"date","esTypes":["date"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"script":"doc[\'timestamp\'].value.hourOfDay","lang":"painless","name":"hour_of_day","type":"number","scripted":true,"searchable":true,"aggregatable":true,"readFromDocValues":false}]', - fieldFormatMap: - '{"AvgTicketPrice":{"id":"number","params":{"parsedUrl":{"origin":"http://localhost:5801","pathname":"/app/visualize","basePath":""},"pattern":"$0,0.[00]"}},"hour_of_day":{"id":"number","params":{"parsedUrl":{"origin":"http://localhost:5801","pathname":"/app/visualize","basePath":""},"pattern":"00"}}}', - }, - shortDotsEnable: false, - fieldFormats: { - fieldFormats: {}, - defaultMap: { - ip: { - id: 'ip', - params: {}, - }, - date: { - id: 'date', - params: {}, - }, - date_nanos: { - id: 'date_nanos', - params: {}, - es: true, - }, - number: { - id: 'number', - params: {}, - }, - boolean: { - id: 'boolean', - params: {}, - }, - _source: { - id: '_source', - params: {}, - }, - _default_: { - id: 'string', - params: {}, - }, - }, - metaParamsOptions: {}, - }, - }, - }, - dependencies: { - legacy: { - loadingCount$: { - _isScalar: false, - observers: [ - { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: null, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - destination: { - closed: false, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - destination: { - closed: true, - }, - _context: {}, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - count: 1, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - hasPrev: true, - prev: 0, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [], - active: 1, - index: 2, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [ - { - _isScalar: false, - }, - ], - active: 1, - index: 1, - }, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - subject: { - _isScalar: false, - observers: [ - { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: null, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - destination: { - closed: false, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - _context: {}, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - count: 13, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - hasPrev: true, - prev: 0, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [], - active: 1, - index: 2, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [ - { - _isScalar: false, - }, - ], - active: 1, - index: 1, - }, - }, - _subscriptions: [ - null, - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - subject: { - _isScalar: false, - observers: [null], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - _value: 0, - }, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - hasKey: true, - key: 0, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - seenValue: false, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: null, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - destination: { - closed: false, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - _context: {}, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - count: 1, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - hasPrev: true, - prev: 0, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [], - active: 1, - index: 2, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [ - { - _isScalar: false, - }, - ], - active: 1, - index: 1, - }, - }, - _subscriptions: [ - null, - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - subject: { - _isScalar: false, - observers: [null], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - _value: 0, - }, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - hasKey: true, - key: 0, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - seenValue: false, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: null, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - destination: { - closed: false, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - _context: {}, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - count: 1, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - hasPrev: true, - prev: 0, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [], - active: 1, - index: 2, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [ - { - _isScalar: false, - }, - ], - active: 1, - index: 1, - }, - }, - _subscriptions: [ - null, - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - subject: { - _isScalar: false, - observers: [null], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - _value: 0, - }, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - hasKey: true, - key: 0, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - seenValue: false, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: null, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - destination: { - closed: false, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - _context: {}, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - count: 3, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - hasPrev: true, - prev: 0, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [], - active: 1, - index: 2, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [ - { - _isScalar: false, - }, - ], - active: 1, - index: 1, - }, - }, - _subscriptions: [ - null, - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - subject: { - _isScalar: false, - observers: [null], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - _value: 0, - }, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - hasKey: true, - key: 0, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - seenValue: false, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - null, - ], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - }, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - null, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - seenValue: false, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - hasKey: true, - key: 0, - }, - ], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - _value: 0, - }, - }, - }, - }, - aggs: { - typesRegistry: {}, - getResponseAggs: () => [ - { - id: '1', - enabled: true, - type: 'count', - params: {}, - schema: 'metric', - toSerializedFieldFormat: () => ({ - id: 'number', - }), - }, - { - id: '2', - enabled: true, - type: 'terms', - params: { - field: 'Carrier', - orderBy: '1', - order: 'desc', - size: 5, - otherBucket: false, - otherBucketLabel: 'Other', - missingBucket: false, - missingBucketLabel: 'Missing', - }, - schema: 'segment', - toSerializedFieldFormat: () => ({ - id: 'terms', - params: { - id: 'string', - otherBucketLabel: 'Other', - missingBucketLabel: 'Missing', - parsedUrl: { - origin: 'http://localhost:5801', - pathname: '/app/visualize', - basePath: '', - }, - }, - }), - }, - ], - }, - }, - isHierarchical: () => true, - uiState: { - vis: { - legendOpen: false, - }, - }, -}; - export const sampleAreaVis = { type: { name: 'area', diff --git a/src/plugins/vis_type_xy/server/plugin.ts b/src/plugins/vis_type_xy/server/plugin.ts deleted file mode 100644 index 08aefdeb836b0..0000000000000 --- a/src/plugins/vis_type_xy/server/plugin.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 { i18n } from '@kbn/i18n'; -import { schema } from '@kbn/config-schema'; - -import { CoreSetup, Plugin, UiSettingsParams } from 'kibana/server'; - -import { LEGACY_CHARTS_LIBRARY } from '../common'; - -export const getUiSettingsConfig: () => Record> = () => ({ - // TODO: Remove this when vis_type_vislib is removed - // https://github.com/elastic/kibana/issues/56143 - [LEGACY_CHARTS_LIBRARY]: { - name: i18n.translate('visTypeXy.advancedSettings.visualization.legacyChartsLibrary.name', { - defaultMessage: 'Legacy charts library', - }), - requiresPageReload: true, - value: false, - description: i18n.translate( - 'visTypeXy.advancedSettings.visualization.legacyChartsLibrary.description', - { - defaultMessage: 'Enables legacy charts library for area, line and bar charts in visualize.', - } - ), - category: ['visualization'], - schema: schema.boolean(), - }, -}); - -export class VisTypeXyServerPlugin implements Plugin { - public setup(core: CoreSetup) { - core.uiSettings.register(getUiSettingsConfig()); - - return {}; - } - - public start() { - return {}; - } -} diff --git a/src/plugins/visualizations/common/constants.ts b/src/plugins/visualizations/common/constants.ts index a8a0963ac8948..a33e74b498a2c 100644 --- a/src/plugins/visualizations/common/constants.ts +++ b/src/plugins/visualizations/common/constants.ts @@ -7,3 +7,4 @@ */ export const VISUALIZE_ENABLE_LABS_SETTING = 'visualize:enableLabs'; +export const LEGACY_CHARTS_LIBRARY = 'visualization:visualize:legacyChartsLibrary'; diff --git a/src/plugins/visualizations/kibana.json b/src/plugins/visualizations/kibana.json index 0ced74e2733d3..939b331414166 100644 --- a/src/plugins/visualizations/kibana.json +++ b/src/plugins/visualizations/kibana.json @@ -12,5 +12,6 @@ "savedObjects" ], "optionalPlugins": ["usageCollection"], - "requiredBundles": ["kibanaUtils", "discover"] + "requiredBundles": ["kibanaUtils", "discover"], + "extraPublicDirs": ["common/constants"] } diff --git a/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts b/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts index 212c033a65c26..edfd05b84dfc8 100644 --- a/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts +++ b/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts @@ -13,6 +13,7 @@ import { commonAddSupportOfDualIndexSelectionModeInTSVB, commonHideTSVBLastValueIndicator, commonRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel, + commonMigrateVislibPie, commonAddEmptyValueColorRule, } from '../migrations/visualization_common_migrations'; @@ -44,6 +45,13 @@ const byValueAddEmptyValueColorRule = (state: SerializableState) => { }; }; +const byValueMigrateVislibPie = (state: SerializableState) => { + return { + ...state, + savedVis: commonMigrateVislibPie(state.savedVis), + }; +}; + export const visualizeEmbeddableFactory = (): EmbeddableRegistryDefinition => { return { id: 'visualization', @@ -55,7 +63,7 @@ export const visualizeEmbeddableFactory = (): EmbeddableRegistryDefinition => { byValueHideTSVBLastValueIndicator, byValueRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel )(state), - '7.14.0': (state) => flow(byValueAddEmptyValueColorRule)(state), + '7.14.0': (state) => flow(byValueAddEmptyValueColorRule, byValueMigrateVislibPie)(state), }, }; }; diff --git a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts index 13b8d8c4a0f98..f5afeee0ff35e 100644 --- a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts @@ -91,3 +91,26 @@ export const commonAddEmptyValueColorRule = (visState: any) => { return visState; }; + +export const commonMigrateVislibPie = (visState: any) => { + if (visState && visState.type === 'pie') { + const { params } = visState; + const hasPalette = params?.palette; + + return { + ...visState, + params: { + ...visState.params, + ...(!hasPalette && { + palette: { + type: 'palette', + name: 'kibana_palette', + }, + }), + distinctColors: true, + }, + }; + } + + return visState; +}; diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts index 36e1635ad4730..7ee43f36c864e 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts @@ -2114,4 +2114,52 @@ describe('migration visualization', () => { checkRuleIsNotAddedToArray('gauge_color_rules', params, migratedParams, rule4); }); }); + + describe('7.14.0 update pie visualization defaults', () => { + const migrate = (doc: any) => + visualizationSavedObjectTypeMigrations['7.14.0']( + doc as Parameters[0], + savedObjectMigrationContext + ); + const getTestDoc = (hasPalette = false) => ({ + attributes: { + title: 'My Vis', + description: 'This is my super cool vis.', + visState: JSON.stringify({ + type: 'pie', + title: '[Flights] Delay Type', + params: { + type: 'pie', + ...(hasPalette && { + palette: { + type: 'palette', + name: 'default', + }, + }), + }, + }), + }, + }); + + it('should decorate existing docs with the kibana legacy palette if the palette is not defined - pie', () => { + const migratedTestDoc = migrate(getTestDoc()); + const { palette } = JSON.parse(migratedTestDoc.attributes.visState).params; + + expect(palette.name).toEqual('kibana_palette'); + }); + + it('should not overwrite the palette with the legacy one if the palette already exists in the saved object', () => { + const migratedTestDoc = migrate(getTestDoc(true)); + const { palette } = JSON.parse(migratedTestDoc.attributes.visState).params; + + expect(palette.name).toEqual('default'); + }); + + it('should default the distinct colors per slice setting to true', () => { + const migratedTestDoc = migrate(getTestDoc()); + const { distinctColors } = JSON.parse(migratedTestDoc.attributes.visState).params; + + expect(distinctColors).toBe(true); + }); + }); }); diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts index c5050b4a6940b..f386d9eb12091 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts @@ -15,6 +15,7 @@ import { commonAddSupportOfDualIndexSelectionModeInTSVB, commonHideTSVBLastValueIndicator, commonRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel, + commonMigrateVislibPie, commonAddEmptyValueColorRule, } from './visualization_common_migrations'; @@ -990,6 +991,29 @@ const addEmptyValueColorRule: SavedObjectMigrationFn = (doc) => { return doc; }; +// [Pie Chart] Migrate vislib pie chart to use the new plugin vis_type_pie +const migrateVislibPie: SavedObjectMigrationFn = (doc) => { + const visStateJSON = get(doc, 'attributes.visState'); + let visState; + + if (visStateJSON) { + try { + visState = JSON.parse(visStateJSON); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + } + const newVisState = commonMigrateVislibPie(visState); + return { + ...doc, + attributes: { + ...doc.attributes, + visState: JSON.stringify(newVisState), + }, + }; + } + return doc; +}; + export const visualizationSavedObjectTypeMigrations = { /** * We need to have this migration twice, once with a version prior to 7.0.0 once with a version @@ -1036,5 +1060,5 @@ export const visualizationSavedObjectTypeMigrations = { hideTSVBLastValueIndicator, removeDefaultIndexPatternAndTimeFieldFromTSVBModel ), - '7.14.0': flow(addEmptyValueColorRule), + '7.14.0': flow(addEmptyValueColorRule, migrateVislibPie), }; diff --git a/src/plugins/visualizations/server/plugin.ts b/src/plugins/visualizations/server/plugin.ts index 5a5a80b2689d6..1fec63f2bb45a 100644 --- a/src/plugins/visualizations/server/plugin.ts +++ b/src/plugins/visualizations/server/plugin.ts @@ -18,7 +18,7 @@ import { Logger, } from '../../../core/server'; -import { VISUALIZE_ENABLE_LABS_SETTING } from '../common/constants'; +import { VISUALIZE_ENABLE_LABS_SETTING, LEGACY_CHARTS_LIBRARY } from '../common/constants'; import { visualizationSavedObjectType } from './saved_objects'; @@ -58,6 +58,27 @@ export class VisualizationsPlugin category: ['visualization'], schema: schema.boolean(), }, + // TODO: Remove this when vis_type_vislib is removed + // https://github.com/elastic/kibana/issues/56143 + [LEGACY_CHARTS_LIBRARY]: { + name: i18n.translate( + 'visualizations.advancedSettings.visualization.legacyChartsLibrary.name', + { + defaultMessage: 'Legacy charts library', + } + ), + requiresPageReload: true, + value: false, + description: i18n.translate( + 'visualizations.advancedSettings.visualization.legacyChartsLibrary.description', + { + defaultMessage: + 'Enables legacy charts library for area, line, bar, pie charts in visualize.', + } + ), + category: ['visualization'], + schema: schema.boolean(), + }, }); if (plugins.usageCollection) { diff --git a/test/examples/embeddables/dashboard.ts b/test/examples/embeddables/dashboard.ts index 597846ab6a43d..69788ebad2af2 100644 --- a/test/examples/embeddables/dashboard.ts +++ b/test/examples/embeddables/dashboard.ts @@ -97,7 +97,8 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide const pieChart = getService('pieChart'); const browser = getService('browser'); const dashboardExpect = getService('dashboardExpect'); - const PageObjects = getPageObjects(['common']); + const elasticChart = getService('elasticChart'); + const PageObjects = getPageObjects(['common', 'visChart']); describe('dashboard container', () => { before(async () => { @@ -109,6 +110,9 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide }); it('pie charts', async () => { + if (await PageObjects.visChart.isNewChartsLibraryEnabled()) { + await elasticChart.setNewChartUiDebugFlag(); + } await pieChart.expectPieSliceCount(5); }); diff --git a/test/functional/apps/dashboard/dashboard_state.ts b/test/functional/apps/dashboard/dashboard_state.ts index acb2bd869819d..0f7722925293b 100644 --- a/test/functional/apps/dashboard/dashboard_state.ts +++ b/test/functional/apps/dashboard/dashboard_state.ts @@ -256,8 +256,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('for embeddable config color parameters on a visualization', () => { + let originalPieSliceStyle = ''; it('updates a pie slice color on a soft refresh', async function () { await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME); + + originalPieSliceStyle = await pieChart.getPieSliceStyle(`80,000`); await PageObjects.visChart.openLegendOptionColors( '80,000', `[data-title="${PIE_CHART_VIS_NAME}"]` @@ -272,7 +275,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const allPieSlicesColor = await pieChart.getAllPieSliceStyles('80,000'); let whitePieSliceCounts = 0; allPieSlicesColor.forEach((style) => { - if (style.indexOf('rgb(255, 255, 255)') > 0) { + if (style.indexOf('rgb(255, 255, 255)') > -1) { whitePieSliceCounts++; } }); @@ -290,14 +293,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('resets a pie slice color to the original when removed', async function () { const currentUrl = await getUrlFromShare(); - const newUrl = currentUrl.replace(`vis:(colors:('80,000':%23FFFFFF))`, ''); + const newUrl = isNewChartsLibraryEnabled + ? currentUrl.replace(`'80000':%23FFFFFF`, '') + : currentUrl.replace(`vis:(colors:('80,000':%23FFFFFF))`, ''); await browser.get(newUrl.toString(), false); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async () => { - const pieSliceStyle = await pieChart.getPieSliceStyle(`80,000`); - // The default green color that was stored with the visualization before any dashboard overrides. - expect(pieSliceStyle.indexOf('rgb(87, 193, 123)')).to.be.greaterThan(0); + const pieSliceStyle = await pieChart.getPieSliceStyle('80,000'); + + // After removing all overrides, pie slice style should match original. + expect(pieSliceStyle).to.be(originalPieSliceStyle); }); }); diff --git a/test/functional/apps/visualize/_pie_chart.ts b/test/functional/apps/visualize/_pie_chart.ts index dd58ca6514c36..8f76e2765e42c 100644 --- a/test/functional/apps/visualize/_pie_chart.ts +++ b/test/functional/apps/visualize/_pie_chart.ts @@ -15,6 +15,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const filterBar = getService('filterBar'); const pieChart = getService('pieChart'); const inspector = getService('inspector'); + const browser = getService('browser'); + const kibanaServer = getService('kibanaServer'); + const PageObjects = getPageObjects([ 'common', 'visualize', @@ -25,9 +28,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ]); describe('pie chart', function () { + // Used to track flag before and after reset + let isNewChartsLibraryEnabled = false; const vizName1 = 'Visualization PieChart'; before(async function () { + isNewChartsLibraryEnabled = await PageObjects.visChart.isNewChartsLibraryEnabled(); await PageObjects.visualize.initTests(); + if (isNewChartsLibraryEnabled) { + await kibanaServer.uiSettings.update({ + 'visualization:visualize:legacyChartsLibrary': false, + }); + await browser.refresh(); + } log.debug('navigateToApp visualize'); await PageObjects.visualize.navigateToNewAggBasedVisualization(); log.debug('clickPieChart'); @@ -84,7 +96,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('other bucket', () => { it('should show other and missing bucket', async function () { - const expectedTableData = ['win 8', 'win xp', 'win 7', 'ios', 'Missing', 'Other']; + const expectedTableData = ['Missing', 'Other', 'ios', 'win 7', 'win 8', 'win xp']; await PageObjects.visualize.navigateToNewAggBasedVisualization(); log.debug('clickPieChart'); @@ -168,7 +180,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'ID', 'BR', 'Other', - ]; + ].sort(); await PageObjects.visEditor.toggleOpenEditor(2, 'false'); await PageObjects.visEditor.clickBucket('Split slices'); @@ -190,7 +202,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should show correct result with one agg disabled', async () => { - const expectedTableData = ['win 8', 'win xp', 'win 7', 'ios', 'osx']; + const expectedTableData = ['ios', 'osx', 'win 7', 'win 8', 'win xp']; await PageObjects.visEditor.clickBucket('Split slices'); await PageObjects.visEditor.selectAggregation('Terms'); @@ -207,7 +219,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualize.loadSavedVisualization(vizName1); await PageObjects.visChart.waitForRenderingCount(); - const expectedTableData = ['win 8', 'win xp', 'win 7', 'ios', 'osx']; + const expectedTableData = ['ios', 'osx', 'win 7', 'win 8', 'win xp']; await pieChart.expectPieChartLabels(expectedTableData); }); @@ -276,7 +288,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'ios', 'win 8', 'osx', - ]; + ].sort(); await pieChart.expectPieChartLabels(expectedTableData); }); @@ -426,7 +438,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'CN', '360,000', 'CN', - ]; + ].sort(); + if (await PageObjects.visChart.isNewLibraryChart('visTypePieChart')) { + await PageObjects.visEditor.clickOptionsTab(); + await PageObjects.visEditor.togglePieLegend(); + await PageObjects.visEditor.togglePieNestedLegend(); + await PageObjects.visEditor.clickDataTab(); + await PageObjects.visEditor.clickGo(); + } await PageObjects.visChart.filterLegend('CN'); await PageObjects.visChart.waitForVisualization(); await pieChart.expectPieChartLabels(expectedTableData); diff --git a/test/functional/apps/visualize/index.ts b/test/functional/apps/visualize/index.ts index b87184bab3c0d..1e0e12a7d31bb 100644 --- a/test/functional/apps/visualize/index.ts +++ b/test/functional/apps/visualize/index.ts @@ -49,6 +49,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_point_series_options')); loadTestFile(require.resolve('./_vertical_bar_chart')); loadTestFile(require.resolve('./_vertical_bar_chart_nontimeindex')); + loadTestFile(require.resolve('./_pie_chart')); }); describe('visualize ciGroup9', function () { diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index 7b69101b92475..7ecf800b4be7c 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -7,10 +7,12 @@ */ import { Position } from '@elastic/charts'; +import Color from 'color'; import { FtrProviderContext } from '../ftr_provider_context'; -const elasticChartSelector = 'visTypeXyChart'; +const xyChartSelector = 'visTypeXyChart'; +const pieChartSelector = 'visTypePieChart'; export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); @@ -25,8 +27,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr const { common } = getPageObjects(['common']); class VisualizeChart { - private async getDebugState() { - return await elasticChart.getChartDebugData(elasticChartSelector); + public async getEsChartDebugState(chartSelector: string) { + return await elasticChart.getChartDebugData(chartSelector); } /** @@ -45,32 +47,32 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr /** * Is new charts library enabled and an area, line or histogram chart exists */ - private async isVisTypeXYChart(): Promise { + public async isNewLibraryChart(chartSelector: string): Promise { const enabled = await this.isNewChartsLibraryEnabled(); if (!enabled) { - log.debug(`-- isVisTypeXYChart = false`); + log.debug(`-- isNewLibraryChart = false`); return false; } - // check if enabled but not a line, area or histogram chart + // check if enabled but not a line, area, histogram or pie chart if (await find.existsByCssSelector('.visLib__chart', 1)) { const chart = await find.byCssSelector('.visLib__chart'); const chartType = await chart.getAttribute('data-vislib-chart-type'); - if (!['line', 'area', 'histogram'].includes(chartType)) { - log.debug(`-- isVisTypeXYChart = false`); + if (!['line', 'area', 'histogram', 'pie'].includes(chartType)) { + log.debug(`-- isNewLibraryChart = false`); return false; } } - if (!(await elasticChart.hasChart(elasticChartSelector, 1))) { + if (!(await elasticChart.hasChart(chartSelector, 1))) { // not be a vislib chart type - log.debug(`-- isVisTypeXYChart = false`); + log.debug(`-- isNewLibraryChart = false`); return false; } - log.debug(`-- isVisTypeXYChart = true`); + log.debug(`-- isNewLibraryChart = true`); return true; } @@ -81,7 +83,7 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr * @param elasticChartsValue value expected for `@elastic/charts` chart */ public async getExpectedValue(vislibValue: T, elasticChartsValue: T): Promise { - if (await this.isVisTypeXYChart()) { + if (await this.isNewLibraryChart(xyChartSelector)) { return elasticChartsValue; } @@ -89,8 +91,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getYAxisTitle() { - if (await this.isVisTypeXYChart()) { - const xAxis = (await this.getDebugState())?.axes?.y ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const xAxis = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; return xAxis[0]?.title; } @@ -99,8 +101,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getXAxisLabels() { - if (await this.isVisTypeXYChart()) { - const [xAxis] = (await this.getDebugState())?.axes?.x ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const [xAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.x ?? []; return xAxis?.labels; } @@ -112,8 +114,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getYAxisLabels() { - if (await this.isVisTypeXYChart()) { - const [yAxis] = (await this.getDebugState())?.axes?.y ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const [yAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; return yAxis?.labels; } @@ -125,8 +127,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getYAxisLabelsAsNumbers() { - if (await this.isVisTypeXYChart()) { - const [yAxis] = (await this.getDebugState())?.axes?.y ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const [yAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; return yAxis?.values; } @@ -141,8 +143,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr * Returns an array of height values */ public async getAreaChartData(dataLabel: string, axis = 'ValueAxis-1') { - if (await this.isVisTypeXYChart()) { - const areas = (await this.getDebugState())?.areas ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; const points = areas.find(({ name }) => name === dataLabel)?.lines.y1.points ?? []; return points.map(({ y }) => y); } @@ -183,8 +185,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr * @param dataLabel data-label value */ public async getAreaChartPaths(dataLabel: string) { - if (await this.isVisTypeXYChart()) { - const areas = (await this.getDebugState())?.areas ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; const path = areas.find(({ name }) => name === dataLabel)?.path ?? ''; return path.split('L'); } @@ -208,9 +210,9 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr * @param axis axis value, 'ValueAxis-1' by default */ public async getLineChartData(dataLabel = 'Count', axis = 'ValueAxis-1') { - if (await this.isVisTypeXYChart()) { + if (await this.isNewLibraryChart(xyChartSelector)) { // For now lines are rendered as areas to enable stacking - const areas = (await this.getDebugState())?.areas ?? []; + const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; const lines = areas.map(({ lines: { y1 }, name, color }) => ({ ...y1, name, color })); const points = lines.find(({ name }) => name === dataLabel)?.points ?? []; return points.map(({ y }) => y); @@ -248,8 +250,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr * @param axis axis value, 'ValueAxis-1' by default */ public async getBarChartData(dataLabel = 'Count', axis = 'ValueAxis-1') { - if (await this.isVisTypeXYChart()) { - const bars = (await this.getDebugState())?.bars ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const bars = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; const values = bars.find(({ name }) => name === dataLabel)?.bars ?? []; return values.map(({ y }) => y); } @@ -293,8 +295,9 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async toggleLegend(show = true) { - const isVisTypeXYChart = await this.isVisTypeXYChart(); - const legendSelector = isVisTypeXYChart ? '.echLegend' : '.visLegend'; + const isVisTypeXYChart = await this.isNewLibraryChart(xyChartSelector); + const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector); + const legendSelector = isVisTypeXYChart || isVisTypePieChart ? '.echLegend' : '.visLegend'; await retry.try(async () => { const isVisible = await find.existsByCssSelector(legendSelector); @@ -321,16 +324,25 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async doesSelectedLegendColorExist(color: string) { - if (await this.isVisTypeXYChart()) { - const items = (await this.getDebugState())?.legend?.items ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const items = (await this.getEsChartDebugState(xyChartSelector))?.legend?.items ?? []; return items.some(({ color: c }) => c === color); } + if (await this.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; + return slices.some(({ color: c }) => { + const rgbColor = new Color(color).rgb().toString(); + return c === rgbColor; + }); + } + return await testSubjects.exists(`legendSelectedColor-${color}`); } public async expectError() { - if (!this.isVisTypeXYChart()) { + if (!this.isNewLibraryChart(xyChartSelector)) { await testSubjects.existOrFail('vislibVisualizeError'); } } @@ -371,17 +383,25 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr public async waitForVisualization() { await this.waitForVisualizationRenderingStabilized(); - if (!(await this.isVisTypeXYChart())) { + if (!(await this.isNewLibraryChart(xyChartSelector))) { await find.byCssSelector('.visualization'); } } public async getLegendEntries() { - if (await this.isVisTypeXYChart()) { - const items = (await this.getDebugState())?.legend?.items ?? []; + const isVisTypeXYChart = await this.isNewLibraryChart(xyChartSelector); + const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector); + if (isVisTypeXYChart) { + const items = (await this.getEsChartDebugState(xyChartSelector))?.legend?.items ?? []; return items.map(({ name }) => name); } + if (isVisTypePieChart) { + const slices = + (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; + return slices.map(({ name }) => name); + } + const legendEntries = await find.allByCssSelector( '.visLegend__button', defaultFindTimeout * 2 @@ -391,10 +411,13 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr ); } - public async openLegendOptionColors(name: string, chartSelector = elasticChartSelector) { + public async openLegendOptionColors(name: string, chartSelector: string) { await this.waitForVisualizationRenderingStabilized(); await retry.try(async () => { - if (await this.isVisTypeXYChart()) { + if ( + (await this.isNewLibraryChart(xyChartSelector)) || + (await this.isNewLibraryChart(pieChartSelector)) + ) { const chart = await find.byCssSelector(chartSelector); const legendItemColor = await chart.findByCssSelector( `[data-ech-series-name="${name}"] .echLegendItem__color` @@ -408,7 +431,9 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr await this.waitForVisualizationRenderingStabilized(); // arbitrary color chosen, any available would do - const arbitraryColor = (await this.isVisTypeXYChart()) ? '#d36086' : '#EF843C'; + const arbitraryColor = (await this.isNewLibraryChart(xyChartSelector)) + ? '#d36086' + : '#EF843C'; const isOpen = await this.doesLegendColorChoiceExist(arbitraryColor); if (!isOpen) { throw new Error('legend color selector not open'); @@ -524,8 +549,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getRightValueAxesCount() { - if (await this.isVisTypeXYChart()) { - const yAxes = (await this.getDebugState())?.axes?.y ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const yAxes = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; return yAxes.filter(({ position }) => position === Position.Right).length; } const axes = await find.allByCssSelector('.visAxis__column--right g.axis'); @@ -544,8 +569,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getHistogramSeriesCount() { - if (await this.isVisTypeXYChart()) { - const bars = (await this.getDebugState())?.bars ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const bars = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; return bars.filter(({ visible }) => visible).length; } @@ -554,8 +579,11 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getGridLines(): Promise> { - if (await this.isVisTypeXYChart()) { - const { x, y } = (await this.getDebugState())?.axes ?? { x: [], y: [] }; + if (await this.isNewLibraryChart(xyChartSelector)) { + const { x, y } = (await this.getEsChartDebugState(xyChartSelector))?.axes ?? { + x: [], + y: [], + }; return [...x, ...y].flatMap(({ gridlines }) => gridlines); } @@ -574,8 +602,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getChartValues() { - if (await this.isVisTypeXYChart()) { - const barSeries = (await this.getDebugState())?.bars ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const barSeries = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; return barSeries.filter(({ visible }) => visible).flatMap((bars) => bars.labels); } diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index 59e93bd1f5700..47cbc8c5e3ea3 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -327,6 +327,14 @@ export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrP await testSubjects.click('visualizeEditorAutoButton'); } + public async togglePieLegend() { + await testSubjects.click('visTypePieAddLegendSwitch'); + } + + public async togglePieNestedLegend() { + await testSubjects.click('visTypePieNestedLegendSwitch'); + } + public async isApplyEnabled() { const applyButton = await testSubjects.find('visualizeEditorRenderButton'); return await applyButton.isEnabled(); diff --git a/test/functional/services/visualizations/pie_chart.ts b/test/functional/services/visualizations/pie_chart.ts index cac4e8fe64c5e..f51492d29b450 100644 --- a/test/functional/services/visualizations/pie_chart.ts +++ b/test/functional/services/visualizations/pie_chart.ts @@ -9,6 +9,8 @@ import expect from '@kbn/expect'; import { FtrService } from '../../ftr_provider_context'; +const pieChartSelector = 'visTypePieChart'; + export class PieChartService extends FtrService { private readonly log = this.ctx.getService('log'); private readonly retry = this.ctx.getService('retry'); @@ -18,20 +20,42 @@ export class PieChartService extends FtrService { private readonly find = this.ctx.getService('find'); private readonly panelActions = this.ctx.getService('dashboardPanelActions'); private readonly defaultFindTimeout = this.config.get('timeouts.find'); + private readonly pageObjects = this.ctx.getPageObjects(['visChart']); private readonly filterActionText = 'Apply filter to current view'; async clickOnPieSlice(name?: string) { this.log.debug(`PieChart.clickOnPieSlice(${name})`); - if (name) { - await this.testSubjects.click(`pieSlice-${name.split(' ').join('-')}`); + if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + let sliceLabel = name || slices[0].name; + if (name === 'Other') { + sliceLabel = '__other__'; + } + const pieSlice = slices.find((slice) => slice.name === sliceLabel); + const pie = await this.testSubjects.find(pieChartSelector); + if (pieSlice) { + const pieSize = await pie.getSize(); + const pieHeight = pieSize.height; + const pieWidth = pieSize.width; + await pie.clickMouseButton({ + xOffset: pieSlice.coords[0] - Math.floor(pieWidth / 2), + yOffset: Math.floor(pieHeight / 2) - pieSlice.coords[1], + }); + } } else { - // If no pie slice has been provided, find the first one available. - await this.retry.try(async () => { - const slices = await this.find.allByCssSelector('svg > g > g.arcs > path.slice'); - this.log.debug('Slices found:' + slices.length); - return slices[0].click(); - }); + if (name) { + await this.testSubjects.click(`pieSlice-${name.split(' ').join('-')}`); + } else { + // If no pie slice has been provided, find the first one available. + await this.retry.try(async () => { + const slices = await this.find.allByCssSelector('svg > g > g.arcs > path.slice'); + this.log.debug('Slices found:' + slices.length); + return slices[0].click(); + }); + } } } @@ -63,12 +87,30 @@ export class PieChartService extends FtrService { async getPieSliceStyle(name: string) { this.log.debug(`VisualizePage.getPieSliceStyle(${name})`); + if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + const selectedSlice = slices.filter((slice) => { + return slice.name.toString() === name.replace(',', ''); + }); + return selectedSlice[0].color; + } const pieSlice = await this.getPieSlice(name); return await pieSlice.getAttribute('style'); } async getAllPieSliceStyles(name: string) { this.log.debug(`VisualizePage.getAllPieSliceStyles(${name})`); + if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + const selectedSlice = slices.filter((slice) => { + return slice.name.toString() === name.replace(',', ''); + }); + return selectedSlice.map((slice) => slice.color); + } const pieSlices = await this.getAllPieSlices(name); return await Promise.all( pieSlices.map(async (pieSlice) => await pieSlice.getAttribute('style')) @@ -87,6 +129,24 @@ export class PieChartService extends FtrService { } async getPieChartLabels() { + if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + return slices.map((slice) => { + if (slice.name === '__missing__') { + return 'Missing'; + } else if (slice.name === '__other__') { + return 'Other'; + } else if (typeof slice.name === 'number') { + // debugState of escharts returns the numbers without comma + const val = slice.name as number; + return val.toString().replace(/\B(? await chart.getAttribute('data-label')) @@ -95,10 +155,23 @@ export class PieChartService extends FtrService { async getPieSliceCount() { this.log.debug('PieChart.getPieSliceCount'); + if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + return slices?.length; + } const slices = await this.find.allByCssSelector('svg > g > g.arcs > path.slice'); return slices.length; } + async expectPieSliceCountEsCharts(expectedCount: number) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + expect(slices.length).to.be(expectedCount); + } + async expectPieSliceCount(expectedCount: number) { this.log.debug(`PieChart.expectPieSliceCount(${expectedCount})`); await this.retry.try(async () => { @@ -111,7 +184,7 @@ export class PieChartService extends FtrService { this.log.debug(`PieChart.expectPieChartLabels(${expectedLabels.join(',')})`); await this.retry.try(async () => { const pieData = await this.getPieChartLabels(); - expect(pieData).to.eql(expectedLabels); + expect(pieData.sort()).to.eql(expectedLabels); }); } } diff --git a/tsconfig.json b/tsconfig.json index 37fc9ee05a29b..c91f7b768a5c4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -65,6 +65,7 @@ { "path": "./src/plugins/vis_type_vislib/tsconfig.json" }, { "path": "./src/plugins/vis_type_vega/tsconfig.json" }, { "path": "./src/plugins/vis_type_xy/tsconfig.json" }, + { "path": "./src/plugins/vis_type_pie/tsconfig.json" }, { "path": "./src/plugins/visualizations/tsconfig.json" }, { "path": "./src/plugins/visualize/tsconfig.json" }, { "path": "./src/plugins/index_pattern_management/tsconfig.json" }, diff --git a/tsconfig.refs.json b/tsconfig.refs.json index 1b8a76d601e38..9aa41cb9bc755 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -52,6 +52,7 @@ { "path": "./src/plugins/vis_type_vislib/tsconfig.json" }, { "path": "./src/plugins/vis_type_vega/tsconfig.json" }, { "path": "./src/plugins/vis_type_xy/tsconfig.json" }, + { "path": "./src/plugins/vis_type_pie/tsconfig.json" }, { "path": "./src/plugins/visualizations/tsconfig.json" }, { "path": "./src/plugins/visualize/tsconfig.json" }, { "path": "./src/plugins/index_pattern_management/tsconfig.json" }, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d1e6835b486b2..c2cad05ff9e30 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4902,12 +4902,6 @@ "visTypeVislib.editors.heatmap.heatmapSettingsTitle": "ヒートマップ設定", "visTypeVislib.editors.heatmap.highlightLabel": "ハイライト範囲", "visTypeVislib.editors.heatmap.highlightLabelTooltip": "チャートのカーソルを当てた部分と凡例の対応するラベルをハイライトします。", - "visTypeVislib.editors.pie.donutLabel": "ドーナッツ", - "visTypeVislib.editors.pie.labelsSettingsTitle": "ラベル設定", - "visTypeVislib.editors.pie.pieSettingsTitle": "パイ設定", - "visTypeVislib.editors.pie.showLabelsLabel": "ラベルを表示", - "visTypeVislib.editors.pie.showTopLevelOnlyLabel": "トップレベルのみ表示", - "visTypeVislib.editors.pie.showValuesLabel": "値を表示", "visTypeVislib.functions.pie.help": "パイビジュアライゼーション", "visTypeVislib.functions.vislib.help": "Vislib ビジュアライゼーション", "visTypeVislib.gauge.alignmentAutomaticTitle": "自動", @@ -4929,11 +4923,17 @@ "visTypeVislib.heatmap.metricTitle": "値", "visTypeVislib.heatmap.segmentTitle": "X 軸", "visTypeVislib.heatmap.splitTitle": "チャートを分割", - "visTypeVislib.pie.metricTitle": "スライスサイズ", - "visTypeVislib.pie.pieDescription": "全体に対する比率でデータを比較します。", - "visTypeVislib.pie.pieTitle": "円", - "visTypeVislib.pie.segmentTitle": "スライスの分割", - "visTypeVislib.pie.splitTitle": "チャートを分割", + "visTypePie.pie.metricTitle": "スライスサイズ", + "visTypePie.pie.pieDescription": "全体に対する比率でデータを比較します。", + "visTypePie.pie.pieTitle": "円", + "visTypePie.pie.segmentTitle": "スライスの分割", + "visTypePie.pie.splitTitle": "チャートを分割", + "visTypePie.editors.pie.donutLabel": "ドーナッツ", + "visTypePie.editors.pie.labelsSettingsTitle": "ラベル設定", + "visTypePie.editors.pie.pieSettingsTitle": "パイ設定", + "visTypePie.editors.pie.showLabelsLabel": "ラベルを表示", + "visTypePie.editors.pie.showTopLevelOnlyLabel": "トップレベルのみ表示", + "visTypePie.editors.pie.showValuesLabel": "値を表示", "visTypeVislib.vislib.errors.noResultsFoundTitle": "結果が見つかりませんでした", "visTypeVislib.vislib.heatmap.maxBucketsText": "定義された数列が多すぎます ({nr}) 。構成されている最大値は {max} です。", "visTypeVislib.vislib.legend.filterForValueButtonAriaLabel": "値 {legendDataLabel} でフィルタリング", @@ -4945,8 +4945,8 @@ "visTypeVislib.vislib.legend.toggleOptionsButtonAriaLabel": "{legendDataLabel}、トグルオプション", "visTypeVislib.vislib.tooltip.fieldLabel": "フィールド", "visTypeVislib.vislib.tooltip.valueLabel": "値", - "visTypeXy.advancedSettings.visualization.legacyChartsLibrary.description": "Visualizeでエリア、折れ線、棒グラフのレガシーグラフライブラリを有効にします。", - "visTypeXy.advancedSettings.visualization.legacyChartsLibrary.name": "レガシーグラフライブラリ", + "visualizations.advancedSettings.visualization.legacyChartsLibrary.description": "Visualizeでエリア、折れ線、棒グラフのレガシーグラフライブラリを有効にします。", + "visualizations.advancedSettings.visualization.legacyChartsLibrary.name": "レガシーグラフライブラリ", "visTypeXy.aggResponse.allDocsTitle": "すべてのドキュメント", "visTypeXy.area.areaDescription": "軸と線の間のデータを強調します。", "visTypeXy.area.areaTitle": "エリア", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 97f3ebdb73396..d3a3d9ae30c37 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4929,12 +4929,6 @@ "visTypeVislib.editors.heatmap.heatmapSettingsTitle": "热图设置", "visTypeVislib.editors.heatmap.highlightLabel": "高亮范围", "visTypeVislib.editors.heatmap.highlightLabelTooltip": "高亮显示图表中鼠标悬停的范围以及图例中对应的标签。", - "visTypeVislib.editors.pie.donutLabel": "圆环图", - "visTypeVislib.editors.pie.labelsSettingsTitle": "标签设置", - "visTypeVislib.editors.pie.pieSettingsTitle": "饼图设置", - "visTypeVislib.editors.pie.showLabelsLabel": "显示标签", - "visTypeVislib.editors.pie.showTopLevelOnlyLabel": "仅显示顶级", - "visTypeVislib.editors.pie.showValuesLabel": "显示值", "visTypeVislib.functions.pie.help": "饼图可视化", "visTypeVislib.functions.vislib.help": "Vislib 可视化", "visTypeVislib.gauge.alignmentAutomaticTitle": "自动", @@ -4956,11 +4950,17 @@ "visTypeVislib.heatmap.metricTitle": "值", "visTypeVislib.heatmap.segmentTitle": "X 轴", "visTypeVislib.heatmap.splitTitle": "拆分图表", - "visTypeVislib.pie.metricTitle": "切片大小", - "visTypeVislib.pie.pieDescription": "以整体的比例比较数据。", - "visTypeVislib.pie.pieTitle": "饼图", - "visTypeVislib.pie.segmentTitle": "拆分切片", - "visTypeVislib.pie.splitTitle": "拆分图表", + "visTypePie.pie.metricTitle": "切片大小", + "visTypePie.pie.pieDescription": "以整体的比例比较数据。", + "visTypePie.pie.pieTitle": "饼图", + "visTypePie.pie.segmentTitle": "拆分切片", + "visTypePie.pie.splitTitle": "拆分图表", + "visTypePie.editors.pie.donutLabel": "圆环图", + "visTypePie.editors.pie.labelsSettingsTitle": "标签设置", + "visTypePie.editors.pie.pieSettingsTitle": "饼图设置", + "visTypePie.editors.pie.showLabelsLabel": "显示标签", + "visTypePie.editors.pie.showTopLevelOnlyLabel": "仅显示顶级", + "visTypePie.editors.pie.showValuesLabel": "显示值", "visTypeVislib.vislib.errors.noResultsFoundTitle": "找不到结果", "visTypeVislib.vislib.heatmap.maxBucketsText": "定义了过多的序列 ({nr})。配置的最大值为 {max}。", "visTypeVislib.vislib.legend.filterForValueButtonAriaLabel": "筛留值 {legendDataLabel}", @@ -4972,8 +4972,8 @@ "visTypeVislib.vislib.legend.toggleOptionsButtonAriaLabel": "{legendDataLabel}, 切换选项", "visTypeVislib.vislib.tooltip.fieldLabel": "字段", "visTypeVislib.vislib.tooltip.valueLabel": "值", - "visTypeXy.advancedSettings.visualization.legacyChartsLibrary.description": "在 Visualize 中启用面积图、折线图和条形图的旧版图表库。", - "visTypeXy.advancedSettings.visualization.legacyChartsLibrary.name": "旧版图表库", + "visualizations.advancedSettings.visualization.legacyChartsLibrary.description": "在 Visualize 中启用面积图、折线图和条形图的旧版图表库。", + "visualizations.advancedSettings.visualization.legacyChartsLibrary.name": "旧版图表库", "visTypeXy.aggResponse.allDocsTitle": "所有文档", "visTypeXy.area.areaDescription": "突出轴与线之间的数据。", "visTypeXy.area.areaTitle": "面积图", diff --git a/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts index b891d3cce3ba0..1660bbff10d37 100644 --- a/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts +++ b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts @@ -24,6 +24,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'settings', 'copySavedObjectsToSpace', ]); + const queryBar = getService('queryBar'); const pieChart = getService('pieChart'); const log = getService('log'); const browser = getService('browser'); @@ -31,6 +32,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const filterBar = getService('filterBar'); const security = getService('security'); const spaces = getService('spaces'); + const elasticChart = getService('elasticChart'); describe('Dashboard to dashboard drilldown', function () { describe('Create & use drilldowns', () => { @@ -211,7 +213,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await navigateWithinDashboard(async () => { await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_PIE_CHART_NAME); }); - await pieChart.expectPieSliceCount(10); + await elasticChart.setNewChartUiDebugFlag(); + await queryBar.submitQuery(); + await pieChart.expectPieSliceCountEsCharts(10); }); }); }); From a0c20ac7aaf5b5667b4bb78d270825e039995431 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Thu, 3 Jun 2021 11:58:25 -0400 Subject: [PATCH 28/35] [Dashboard] Fix Copy To Permission & Unskip RBAC tests (#100616) * slightly better typing for dashboard permissions. Fixed typo, unskipped functional tests --- src/plugins/dashboard/common/types.ts | 8 ++++++ .../application/dashboard_app_functions.ts | 4 +-- .../embeddable/dashboard_container.tsx | 6 ++--- .../dashboard_empty_screen.test.tsx.snap | 4 +++ .../empty_screen/dashboard_empty_screen.tsx | 6 ++++- .../hooks/use_dashboard_container.test.tsx | 4 +-- .../listing/dashboard_listing.test.tsx | 4 +-- .../application/top_nav/show_share_modal.tsx | 6 ++--- .../dashboard/public/application/types.ts | 4 +-- src/plugins/dashboard/public/plugin.tsx | 8 ++++-- .../dashboard/server/capabilities_provider.ts | 6 ++++- .../feature_controls/dashboard_security.ts | 26 +++++++++++-------- .../time_to_visualize_security.ts | 3 +-- 13 files changed, 58 insertions(+), 31 deletions(-) diff --git a/src/plugins/dashboard/common/types.ts b/src/plugins/dashboard/common/types.ts index 9a6d185ef2ac1..5851ffa045bc7 100644 --- a/src/plugins/dashboard/common/types.ts +++ b/src/plugins/dashboard/common/types.ts @@ -32,6 +32,14 @@ export interface DashboardPanelState< panelRefName?: string; } +export interface DashboardCapabilities { + showWriteControls: boolean; + saveQuery: boolean; + createNew: boolean; + show: boolean; + [key: string]: boolean; +} + /** * This should always represent the latest dashboard panel shape, after all possible migrations. */ diff --git a/src/plugins/dashboard/public/application/dashboard_app_functions.ts b/src/plugins/dashboard/public/application/dashboard_app_functions.ts index 6d51422d4bd23..895a56242bf96 100644 --- a/src/plugins/dashboard/public/application/dashboard_app_functions.ts +++ b/src/plugins/dashboard/public/application/dashboard_app_functions.ts @@ -21,7 +21,7 @@ import { switchMap, } from 'rxjs/operators'; -import { DashboardCapabilities } from './types'; +import { DashboardAppCapabilities } from './types'; import { DashboardConstants } from '../dashboard_constants'; import { DashboardStateManager } from './dashboard_state_manager'; import { convertSavedDashboardPanelToPanelState } from '../../common/embeddable/embeddable_saved_object_converters'; @@ -103,7 +103,7 @@ export const getDashboardContainerInput = ({ dashboardStateManager, dashboardCapabilities, }: { - dashboardCapabilities: DashboardCapabilities; + dashboardCapabilities: DashboardAppCapabilities; dashboardStateManager: DashboardStateManager; incomingEmbeddable?: EmbeddablePackageState; lastReloadRequestTime?: number; diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 92b0727d2458c..847a190a6e083 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -37,11 +37,11 @@ import { } from '../../services/kibana_react'; import { PLACEHOLDER_EMBEDDABLE } from './placeholder'; import { PanelPlacementMethod, IPanelPlacementArgs } from './panel/dashboard_panel_placement'; -import { DashboardCapabilities } from '../types'; +import { DashboardAppCapabilities } from '../types'; import { PresentationUtilPluginStart } from '../../services/presentation_util'; export interface DashboardContainerInput extends ContainerInput { - dashboardCapabilities?: DashboardCapabilities; + dashboardCapabilities?: DashboardAppCapabilities; refreshConfig?: RefreshInterval; isEmbeddedExternally?: boolean; isFullScreenMode: boolean; @@ -91,7 +91,7 @@ export interface InheritedChildInput extends IndexSignature { export type DashboardReactContextValue = KibanaReactContextValue; export type DashboardReactContext = KibanaReactContext; -const defaultCapabilities: DashboardCapabilities = { +const defaultCapabilities: DashboardAppCapabilities = { show: false, createNew: false, saveQuery: false, diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index 44beed5e4a89b..ae8943e9f6b3e 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -590,10 +590,12 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = `
{ return ( - + toasts: coreMock.createStart().notifications.toasts, }); -const defaultCapabilities: DashboardCapabilities = { +const defaultCapabilities: DashboardAppCapabilities = { show: false, createNew: false, saveQuery: false, diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx index 022c830b180b6..febb03d58d934 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx @@ -24,7 +24,7 @@ import { savedObjectsPluginMock } from '../../../../saved_objects/public/mocks'; import { DashboardListing, DashboardListingProps } from './dashboard_listing'; import { embeddablePluginMock } from '../../../../embeddable/public/mocks'; import { visualizationsPluginMock } from '../../../../visualizations/public/mocks'; -import { DashboardAppServices, DashboardCapabilities } from '../types'; +import { DashboardAppServices, DashboardAppCapabilities } from '../types'; import { dataPluginMock } from '../../../../data/public/mocks'; import { chromeServiceMock, coreMock } from '../../../../../core/public/mocks'; import { I18nProvider } from '@kbn/i18n/react'; @@ -59,7 +59,7 @@ function makeDefaultServices(): DashboardAppServices { return { savedObjects: savedObjectsPluginMock.createStartContract(), embeddable: embeddablePluginMock.createInstance().doStart(), - dashboardCapabilities: {} as DashboardCapabilities, + dashboardCapabilities: {} as DashboardAppCapabilities, initializerContext: {} as PluginInitializerContext, chrome: chromeServiceMock.createStartContract(), navigation: {} as NavigationPublicPluginStart, diff --git a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx index 56823adf6bc14..a96b1ebd4f1ff 100644 --- a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx +++ b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx @@ -16,7 +16,7 @@ import { SharePluginStart } from '../../services/share'; import { dashboardUrlParams } from '../dashboard_router'; import { DashboardStateManager } from '../dashboard_state_manager'; import { shareModalStrings } from '../../dashboard_strings'; -import { DashboardCapabilities } from '../types'; +import { DashboardAppCapabilities } from '../types'; const showFilterBarId = 'showFilterBar'; @@ -24,14 +24,14 @@ interface ShowShareModalProps { share: SharePluginStart; anchorElement: HTMLElement; savedDashboard: DashboardSavedObject; - dashboardCapabilities: DashboardCapabilities; + dashboardCapabilities: DashboardAppCapabilities; dashboardStateManager: DashboardStateManager; } export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => { if (!anonymousUserCapabilities.dashboard) return false; - const dashboard = (anonymousUserCapabilities.dashboard as unknown) as DashboardCapabilities; + const dashboard = (anonymousUserCapabilities.dashboard as unknown) as DashboardAppCapabilities; return !!dashboard.show; }; diff --git a/src/plugins/dashboard/public/application/types.ts b/src/plugins/dashboard/public/application/types.ts index dd291291ce9d6..aae8a1f6eca54 100644 --- a/src/plugins/dashboard/public/application/types.ts +++ b/src/plugins/dashboard/public/application/types.ts @@ -49,7 +49,7 @@ export interface DashboardSaveOptions { isTitleDuplicateConfirmed: boolean; } -export interface DashboardCapabilities { +export interface DashboardAppCapabilities { visualizeCapabilities: { save: boolean }; mapsCapabilities: { save: boolean }; hideWriteControls: boolean; @@ -77,7 +77,7 @@ export interface DashboardAppServices { usageCollection?: UsageCollectionSetup; navigation: NavigationPublicPluginStart; dashboardPanelStorage: DashboardPanelStorage; - dashboardCapabilities: DashboardCapabilities; + dashboardCapabilities: DashboardAppCapabilities; initializerContext: PluginInitializerContext; onAppLeave: AppMountParameters['onAppLeave']; savedObjectsTagging?: SavedObjectsTaggingApi; diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 230918399d88f..b73fe5f2ba410 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -65,6 +65,7 @@ import { AddToLibraryAction, LibraryNotificationAction, CopyToDashboardAction, + DashboardCapabilities, } from './application'; import { createDashboardUrlGenerator, @@ -351,6 +352,9 @@ export class DashboardPlugin const { notifications, overlays, application } = core; const { uiActions, data, share, presentationUtil, embeddable } = plugins; + const dashboardCapabilities: Readonly = application.capabilities + .dashboard as DashboardCapabilities; + const SavedObjectFinder = getSavedObjectFinder(core.savedObjects, core.uiSettings); const expandPanelAction = new ExpandPanelAction(); @@ -395,8 +399,8 @@ export class DashboardPlugin overlays, embeddable.getStateTransfer(), { - canCreateNew: Boolean(application.capabilities.dashboard.createNew), - canEditExisting: !Boolean(application.capabilities.dashboard.hideWriteControls), + canCreateNew: Boolean(dashboardCapabilities.createNew), + canEditExisting: Boolean(dashboardCapabilities.showWriteControls), }, presentationUtil.ContextProvider ); diff --git a/src/plugins/dashboard/server/capabilities_provider.ts b/src/plugins/dashboard/server/capabilities_provider.ts index 25457c1a487d9..c5b740c581294 100644 --- a/src/plugins/dashboard/server/capabilities_provider.ts +++ b/src/plugins/dashboard/server/capabilities_provider.ts @@ -6,7 +6,11 @@ * Side Public License, v 1. */ -export const capabilitiesProvider = () => ({ +import { DashboardCapabilities } from '../common/types'; + +export const capabilitiesProvider = (): { + dashboard: DashboardCapabilities; +} => ({ dashboard: { createNew: true, show: true, diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts index bdbfb5050a32f..94a0eedd07c54 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts @@ -31,8 +31,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const queryBar = getService('queryBar'); const savedQueryManagementComponent = getService('savedQueryManagementComponent'); - // FLAKY: https://github.com/elastic/kibana/issues/86950 - describe.skip('dashboard feature controls security', () => { + describe('dashboard feature controls security', () => { before(async () => { await esArchiver.load('dashboard/feature_controls/security'); await esArchiver.loadIfNeeded('logstash_functional'); @@ -86,7 +85,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('only shows the dashboard navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link) => link.text)).to.eql(['Overview', 'Dashboard']); + expect(navLinks.map((link) => link.text)).to.eql([ + 'Overview', + 'Dashboard', + 'Stack Management', + ]); }); it(`landing page shows "Create new Dashboard" button`, async () => { @@ -108,8 +111,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await globalNav.badgeMissingOrFail(); }); - // Can't figure out how to get this test to pass - it.skip(`create new dashboard shows addNew button`, async () => { + it(`create new dashboard shows addNew button`, async () => { await PageObjects.common.navigateToActualUrl( 'dashboard', DashboardConstants.CREATE_NEW_DASHBOARD_URL, @@ -320,8 +322,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await globalNav.badgeExistsOrFail('Read only'); }); - // Has this behavior changed? - it.skip(`create new dashboard redirects to the home page`, async () => { + it(`create new dashboard shows the read only warning`, async () => { await PageObjects.common.navigateToActualUrl( 'dashboard', DashboardConstants.CREATE_NEW_DASHBOARD_URL, @@ -330,7 +331,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldLoginIfPrompted: false, } ); - await testSubjects.existOrFail('homeApp', { timeout: 20000 }); + await testSubjects.existOrFail('dashboardEmptyReadOnly', { timeout: 20000 }); }); it(`can view existing Dashboard`, async () => { @@ -347,6 +348,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); + it('does not allow copy to dashboard behaviour', async () => { + await panelActions.expectMissingPanelAction('embeddablePanelAction-copyToDashboard'); + }); + it(`Permalinks doesn't show create short-url button`, async () => { await PageObjects.share.openShareMenuItem('Permalinks'); await PageObjects.share.createShortUrlMissingOrFail(); @@ -438,8 +443,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await globalNav.badgeExistsOrFail('Read only'); }); - // Has this behavior changed? - it.skip(`create new dashboard redirects to the home page`, async () => { + it(`create new dashboard shows the read only warning`, async () => { await PageObjects.common.navigateToActualUrl( 'dashboard', DashboardConstants.CREATE_NEW_DASHBOARD_URL, @@ -448,7 +452,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldLoginIfPrompted: false, } ); - await testSubjects.existOrFail('homeApp', { timeout: 20000 }); + await testSubjects.existOrFail('dashboardEmptyReadOnly', { timeout: 20000 }); }); it(`can view existing Dashboard`, async () => { diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts index 2c151235518e0..730c00a8d5e4f 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts @@ -29,8 +29,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const security = getService('security'); const find = getService('find'); - // flaky https://github.com/elastic/kibana/issues/98249 - describe.skip('dashboard time to visualize security', () => { + describe('dashboard time to visualize security', () => { before(async () => { await esArchiver.load('dashboard/feature_controls/security'); await esArchiver.loadIfNeeded('logstash_functional'); From 0312839e34ba3e8519abd1fab7d7f94cfef98ba1 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Thu, 3 Jun 2021 12:27:06 -0400 Subject: [PATCH 29/35] [Security Solution][Endpoint][Host Isolation] Unisolate host minor refactors (#100889) --- .../common/endpoint/constants.ts | 25 ++++++----- .../host_isolation/isolate_success.tsx | 35 +++++++-------- .../utils/resolve_path_variables.test.ts | 45 +++++++++++++++++++ .../common/utils/resolve_path_variables.ts | 11 +++++ .../components/host_isolation/index.tsx | 8 ---- .../components/host_isolation/isolate.tsx | 13 +----- .../components/host_isolation/translations.ts | 3 +- .../components/host_isolation/unisolate.tsx | 13 +----- .../containers/detection_engine/alerts/api.ts | 6 +-- .../public/management/common/utils.test.ts | 37 +-------------- .../public/management/common/utils.ts | 5 --- .../pages/endpoint_hosts/store/middleware.ts | 14 +++--- .../pages/trusted_apps/service/index.ts | 2 +- .../view/trusted_apps_page.test.tsx | 2 +- .../server/endpoint/routes/metadata/index.ts | 12 ++--- .../endpoint/routes/metadata/metadata.test.ts | 27 ++++++----- .../apis/metadata.ts | 30 ++++++------- 17 files changed, 141 insertions(+), 147 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index c85778f2f38fa..cdfc34c2e9cda 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -15,23 +15,24 @@ export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; export const LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG = 'endpoint:limited-concurrency'; export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100; -export const HOST_METADATA_LIST_API = '/api/endpoint/metadata'; -export const HOST_METADATA_GET_API = '/api/endpoint/metadata/{id}'; +export const BASE_ENDPOINT_ROUTE = '/api/endpoint'; +export const HOST_METADATA_LIST_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata`; +export const HOST_METADATA_GET_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata/{id}`; -export const TRUSTED_APPS_GET_API = '/api/endpoint/trusted_apps/{id}'; -export const TRUSTED_APPS_LIST_API = '/api/endpoint/trusted_apps'; -export const TRUSTED_APPS_CREATE_API = '/api/endpoint/trusted_apps'; -export const TRUSTED_APPS_UPDATE_API = '/api/endpoint/trusted_apps/{id}'; -export const TRUSTED_APPS_DELETE_API = '/api/endpoint/trusted_apps/{id}'; -export const TRUSTED_APPS_SUMMARY_API = '/api/endpoint/trusted_apps/summary'; +export const TRUSTED_APPS_GET_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps/{id}`; +export const TRUSTED_APPS_LIST_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps`; +export const TRUSTED_APPS_CREATE_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps`; +export const TRUSTED_APPS_UPDATE_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps/{id}`; +export const TRUSTED_APPS_DELETE_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps/{id}`; +export const TRUSTED_APPS_SUMMARY_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps/summary`; -export const BASE_POLICY_RESPONSE_ROUTE = `/api/endpoint/policy_response`; -export const BASE_POLICY_ROUTE = `/api/endpoint/policy`; +export const BASE_POLICY_RESPONSE_ROUTE = `${BASE_ENDPOINT_ROUTE}/policy_response`; +export const BASE_POLICY_ROUTE = `${BASE_ENDPOINT_ROUTE}/policy`; export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`; /** Host Isolation Routes */ -export const ISOLATE_HOST_ROUTE = `/api/endpoint/isolate`; -export const UNISOLATE_HOST_ROUTE = `/api/endpoint/unisolate`; +export const ISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/isolate`; +export const UNISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/unisolate`; /** Endpoint Actions Log Routes */ export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`; diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx index f822b3c287a02..3459da068b282 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx @@ -27,25 +27,22 @@ export const EndpointIsolateSuccess = memo( }) => { return ( <> - {isolateAction === 'isolateHost' ? ( - - {additionalInfo} - - ) : ( - - {additionalInfo} - - )} - + + {additionalInfo} + { + describe('resolvePathVariables', () => { + it('should resolve defined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( + '/segment1/value1/segment2' + ); + }); + + it('should not resolve undefined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should ignore unused variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should replace multiple variable occurences', () => { + expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( + '/value1/segment1/value1' + ); + }); + + it('should replace multiple variables', () => { + const path = resolvePathVariables('/{var1}/segment1/{var2}', { + var1: 'value1', + var2: 'value2', + }); + + expect(path).toBe('/value1/segment1/value2'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.ts b/x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.ts new file mode 100644 index 0000000000000..89067e575665d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => + Object.keys(variables).reduce((acc, paramName) => { + return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); + }, path); diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx index bb1585b5392bd..2ca8416841497 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx @@ -36,12 +36,6 @@ export const HostIsolationPanel = React.memo( return findHostName ? findHostName[0] : ''; }, [details]); - const alertRule = useMemo(() => { - const findAlertRule = find({ category: 'signal', field: 'signal.rule.name' }, details) - ?.values; - return findAlertRule ? findAlertRule[0] : ''; - }, [details]); - const alertId = useMemo(() => { const findAlertId = find({ category: '_id', field: '_id' }, details)?.values; return findAlertId ? findAlertId[0] : ''; @@ -95,7 +89,6 @@ export const HostIsolationPanel = React.memo( void; @@ -80,15 +78,9 @@ export const IsolateHost = React.memo( messageAppend={ - {caseCount} - {CASES_ASSOCIATED_WITH_ALERT(caseCount)} - {alertRule} - - ), + cases: {CASES_ASSOCIATED_WITH_ALERT(caseCount)}, }} /> } @@ -103,7 +95,6 @@ export const IsolateHost = React.memo( comment, loading, caseCount, - alertRule, ]); return isIsolated ? hostIsolatedSuccess : hostNotIsolated; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts index 449a09b932cd3..98b74817cabb6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts @@ -25,7 +25,8 @@ export const CASES_ASSOCIATED_WITH_ALERT = (caseCount: number): string => i18n.translate( 'xpack.securitySolution.endpoint.hostIsolation.isolateHost.casesAssociatedWithAlert', { - defaultMessage: ' {caseCount, plural, one {case} other {cases}} associated with the rule ', + defaultMessage: + '{caseCount} {caseCount, plural, one {case} other {cases}} associated with this host', values: { caseCount }, } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx index 74149f2a692d3..e72a0d2de61bc 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx @@ -20,14 +20,12 @@ export const UnisolateHost = React.memo( ({ agentId, hostName, - alertRule, cases, caseIds, cancelCallback, }: { agentId: string; hostName: string; - alertRule: string; cases: ReactNode; caseIds: string[]; cancelCallback: () => void; @@ -80,15 +78,9 @@ export const UnisolateHost = React.memo( messageAppend={ - {caseCount} - {CASES_ASSOCIATED_WITH_ALERT(caseCount)} - {alertRule} - - ), + cases: {CASES_ASSOCIATED_WITH_ALERT(caseCount)}, }} /> } @@ -103,7 +95,6 @@ export const UnisolateHost = React.memo( comment, loading, caseCount, - alertRule, ]); return isUnIsolated ? hostUnisolatedSuccess : hostNotUnisolated; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index a7bd42c6af5ee..cd596ef76ce0a 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -14,7 +14,7 @@ import { DETECTION_ENGINE_INDEX_URL, DETECTION_ENGINE_PRIVILEGES_URL, } from '../../../../../common/constants'; -import { HOST_METADATA_GET_API } from '../../../../../common/endpoint/constants'; +import { HOST_METADATA_GET_ROUTE } from '../../../../../common/endpoint/constants'; import { KibanaServices } from '../../../../common/lib/kibana'; import { BasicSignals, @@ -25,8 +25,8 @@ import { UpdateAlertStatusProps, CasesFromAlertsResponse, } from './types'; -import { resolvePathVariables } from '../../../../management/common/utils'; import { isolateHost, unIsolateHost } from '../../../../common/lib/host_isolation'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; /** * Fetch Alerts by providing a query @@ -181,6 +181,6 @@ export const getHostMetadata = async ({ agentId: string; }): Promise => KibanaServices.get().http.fetch( - resolvePathVariables(HOST_METADATA_GET_API, { id: agentId }), + resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: agentId }), { method: 'get' } ); diff --git a/x-pack/plugins/security_solution/public/management/common/utils.test.ts b/x-pack/plugins/security_solution/public/management/common/utils.test.ts index 8918261b6a436..59455ccd6bb04 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.test.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { parseQueryFilterToKQL, resolvePathVariables } from './utils'; +import { parseQueryFilterToKQL } from './utils'; describe('utils', () => { const searchableFields = [`name`, `description`, `entries.value`, `entries.entries.value`]; @@ -39,39 +39,4 @@ describe('utils', () => { ); }); }); - - describe('resolvePathVariables', () => { - it('should resolve defined variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( - '/segment1/value1/segment2' - ); - }); - - it('should not resolve undefined variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( - '/segment1/{var1}/segment2' - ); - }); - - it('should ignore unused variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( - '/segment1/{var1}/segment2' - ); - }); - - it('should replace multiple variable occurences', () => { - expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( - '/value1/segment1/value1' - ); - }); - - it('should replace multiple variables', () => { - const path = resolvePathVariables('/{var1}/segment1/{var2}', { - var1: 'value1', - var2: 'value2', - }); - - expect(path).toBe('/value1/segment1/value2'); - }); - }); }); diff --git a/x-pack/plugins/security_solution/public/management/common/utils.ts b/x-pack/plugins/security_solution/public/management/common/utils.ts index 78a95eb4d6f81..c8cf761ccaf86 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.ts @@ -19,8 +19,3 @@ export const parseQueryFilterToKQL = (filter: string, fields: Readonly return kuery; }; - -export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => - Object.keys(variables).reduce((acc, paramName) => { - return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); - }, path); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 90427d5003384..911a902bd2029 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -41,12 +41,11 @@ import { import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../fleet/common'; import { ENDPOINT_ACTION_LOG_ROUTE, - HOST_METADATA_GET_API, - HOST_METADATA_LIST_API, + HOST_METADATA_GET_ROUTE, + HOST_METADATA_LIST_ROUTE, metadataCurrentIndexPattern, } from '../../../../../common/endpoint/constants'; import { IIndexPattern, Query } from '../../../../../../../../src/plugins/data/public'; -import { resolvePathVariables } from '../../../common/utils'; import { createFailedResourceState, createLoadedResourceState, @@ -54,6 +53,7 @@ import { } from '../../../state'; import { isolateHost } from '../../../../common/lib/host_isolation'; import { AppAction } from '../../../../common/store/actions'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; type EndpointPageStore = ImmutableMiddlewareAPI; @@ -104,7 +104,7 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory(HOST_METADATA_LIST_API, { + endpointResponse = await coreStart.http.post(HOST_METADATA_LIST_ROUTE, { body: JSON.stringify({ paging_properties: [{ page_index: pageIndex }, { page_size: pageSize }], filters: { kql: decodedQuery.query }, @@ -253,7 +253,7 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory( - resolvePathVariables(HOST_METADATA_GET_API, { id: selectedEndpoint as string }) + resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: selectedEndpoint as string }) ); dispatch({ type: 'serverReturnedEndpointDetails', @@ -458,7 +458,7 @@ const getAgentAndPoliciesForEndpointsList = async ( const endpointsTotal = async (http: HttpStart): Promise => { try { return ( - await http.post(HOST_METADATA_LIST_API, { + await http.post(HOST_METADATA_LIST_ROUTE, { body: JSON.stringify({ paging_properties: [{ page_index: 0 }, { page_size: 1 }], }), diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts index 01bccc81b5063..9d39ecd05ad8a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts @@ -29,8 +29,8 @@ import { GetOneTrustedAppRequestParams, GetOneTrustedAppResponse, } from '../../../../../common/endpoint/types/trusted_apps'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; -import { resolvePathVariables } from '../../../common/utils'; import { sendGetEndpointSpecificPackagePolicies } from '../../policy/store/services/ingest'; export interface TrustedAppsService { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index dc0032243312f..fac9fb1e5bf6e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -31,9 +31,9 @@ import { import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { isFailedResourceState, isLoadedResourceState } from '../state'; import { forceHTMLElementOffsetWidth } from './components/effected_policy_select/test_utils'; -import { resolvePathVariables } from '../../../common/utils'; import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts index 44db86f85cf5f..b4784c1ff5ed4 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts @@ -11,12 +11,14 @@ import { HostStatus, MetadataQueryStrategyVersions } from '../../../../common/en import { EndpointAppContext } from '../../types'; import { getLogger, getMetadataListRequestHandler, getMetadataRequestHandler } from './handlers'; import type { SecuritySolutionPluginRouter } from '../../../types'; +import { + BASE_ENDPOINT_ROUTE, + HOST_METADATA_GET_ROUTE, + HOST_METADATA_LIST_ROUTE, +} from '../../../../common/endpoint/constants'; -export const BASE_ENDPOINT_ROUTE = '/api/endpoint'; export const METADATA_REQUEST_V1_ROUTE = `${BASE_ENDPOINT_ROUTE}/v1/metadata`; export const GET_METADATA_REQUEST_V1_ROUTE = `${METADATA_REQUEST_V1_ROUTE}/{id}`; -export const METADATA_REQUEST_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata`; -export const GET_METADATA_REQUEST_ROUTE = `${METADATA_REQUEST_ROUTE}/{id}`; /* Filters that can be applied to the endpoint fetch route */ export const endpointFilters = schema.object({ @@ -82,7 +84,7 @@ export function registerEndpointRoutes( router.post( { - path: `${METADATA_REQUEST_ROUTE}`, + path: `${HOST_METADATA_LIST_ROUTE}`, validate: GetMetadataListRequestSchema, options: { authRequired: true, tags: ['access:securitySolution'] }, }, @@ -100,7 +102,7 @@ export function registerEndpointRoutes( router.get( { - path: `${GET_METADATA_REQUEST_ROUTE}`, + path: `${HOST_METADATA_GET_ROUTE}`, validate: GetMetadataRequestSchema, options: { authRequired: true, tags: ['access:securitySolution'] }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index b916ec19da17f..e6d6879ba1845 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -26,7 +26,7 @@ import { MetadataQueryStrategyVersions, } from '../../../../common/endpoint/types'; import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; -import { registerEndpointRoutes, METADATA_REQUEST_ROUTE } from './index'; +import { registerEndpointRoutes } from './index'; import { createMockEndpointAppContextServiceStartContract, createMockPackageService, @@ -45,7 +45,10 @@ import { } from '../../../../../fleet/common/types/models'; import { createV1SearchResponse, createV2SearchResponse } from './support/test_support'; import { PackageService } from '../../../../../fleet/server/services'; -import { metadataTransformPrefix } from '../../../../common/endpoint/constants'; +import { + HOST_METADATA_LIST_ROUTE, + metadataTransformPrefix, +} from '../../../../common/endpoint/constants'; import type { SecuritySolutionPluginRouter } from '../../../types'; import { PackagePolicyServiceInterface } from '../../../../../fleet/server'; import { @@ -126,7 +129,7 @@ describe('test endpoint route', () => { Promise.resolve({ body: response }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); @@ -167,7 +170,7 @@ describe('test endpoint route', () => { ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -225,7 +228,7 @@ describe('test endpoint route', () => { Promise.resolve({ body: response }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); @@ -273,7 +276,7 @@ describe('test endpoint route', () => { }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -332,7 +335,7 @@ describe('test endpoint route', () => { }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -416,7 +419,7 @@ describe('test endpoint route', () => { } as unknown) as Agent); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), @@ -449,7 +452,7 @@ describe('test endpoint route', () => { ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -490,7 +493,7 @@ describe('test endpoint route', () => { ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -525,7 +528,7 @@ describe('test endpoint route', () => { ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -558,7 +561,7 @@ describe('test endpoint route', () => { } as unknown) as Agent); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index 8dd5adba43edb..13a19e55ab588 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -12,8 +12,8 @@ import { deleteAllDocsFromMetadataIndex, deleteMetadataStream, } from './data_stream_helper'; -import { METADATA_REQUEST_ROUTE } from '../../../plugins/security_solution/server/endpoint/routes/metadata'; import { MetadataQueryStrategyVersions } from '../../../plugins/security_solution/common/endpoint/types'; +import { HOST_METADATA_LIST_ROUTE } from '../../../plugins/security_solution/common/endpoint/constants'; /** * The number of host documents in the es archive. @@ -25,13 +25,13 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('test metadata api', () => { - describe(`POST ${METADATA_REQUEST_ROUTE} when index is empty`, () => { + describe(`POST ${HOST_METADATA_LIST_ROUTE} when index is empty`, () => { it('metadata api should return empty result when index is empty', async () => { await deleteMetadataStream(getService); await deleteAllDocsFromMetadataIndex(getService); await deleteAllDocsFromMetadataCurrentIndex(getService); const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send() .expect(200); @@ -42,7 +42,7 @@ export default function ({ getService }: FtrProviderContext) { }); }); - describe(`POST ${METADATA_REQUEST_ROUTE} when index is not empty`, () => { + describe(`POST ${HOST_METADATA_LIST_ROUTE} when index is not empty`, () => { before(async () => { await esArchiver.load('endpoint/metadata/api_feature', { useCreate: true }); // wait for transform @@ -57,7 +57,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('metadata api should return one entry for each host with default paging', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send() .expect(200); @@ -69,7 +69,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on paging properties passed.', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -94,7 +94,7 @@ export default function ({ getService }: FtrProviderContext) { */ it('metadata api should return accurate total metadata if page index produces no result', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -116,7 +116,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return 400 when pagingProperties is below boundaries.', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -134,7 +134,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on filters passed.', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -152,7 +152,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on filters and paging passed.', async () => { const notIncludedIp = '10.46.229.234'; const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -190,7 +190,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on host.os.Ext.variant filter.', async () => { const variantValue = 'Windows Pro'; const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -212,7 +212,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return the latest event for all the events for an endpoint', async () => { const targetEndpointIp = '10.46.229.234'; const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -234,7 +234,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return the latest event for all the events where policy status is not success', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -255,7 +255,7 @@ export default function ({ getService }: FtrProviderContext) { const targetEndpointId = 'fc0ff548-feba-41b6-8367-65e8790d0eaf'; const targetElasticAgentId = '023fa40c-411d-4188-a941-4147bfadd095'; const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -278,7 +278,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return all hosts when filter is empty string', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { From 3b1e8b03f162244c88452e5da4df00eb73741f4a Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 3 Jun 2021 12:27:29 -0400 Subject: [PATCH 30/35] [Fleet] Install final pipeline (#100973) --- .../ingest_pipeline/final_pipeline.ts | 107 ++++++++++ .../elasticsearch/ingest_pipeline/install.ts | 23 +++ .../__snapshots__/template.test.ts.snap | 9 +- .../epm/elasticsearch/template/template.ts | 6 + x-pack/plugins/fleet/server/services/setup.ts | 3 + .../apis/epm/final_pipeline.ts | 187 ++++++++++++++++++ .../fleet_api_integration/apis/epm/index.js | 1 + 7 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts create mode 100644 x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts new file mode 100644 index 0000000000000..4c0484c058abf --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const FINAL_PIPELINE_ID = '.fleet_final_pipeline'; + +export const FINAL_PIPELINE = `--- +description: > + Final pipeline for processing all incoming Fleet Agent documents. +processors: + - set: + description: Add time when event was ingested. + field: event.ingested + value: '{{{_ingest.timestamp}}}' + - remove: + description: Remove any pre-existing untrusted values. + field: + - event.agent_id_status + - _security + ignore_missing: true + - set_security_user: + field: _security + properties: + - authentication_type + - username + - realm + - api_key + - script: + description: > + Add event.agent_id_status based on the API key metadata and the + agent.id contained in the event. + tag: agent-id-status + source: |- + boolean is_user_trusted(def ctx, def users) { + if (ctx?._security?.username == null) { + return false; + } + + def user = null; + for (def item : users) { + if (item?.username == ctx._security.username) { + user = item; + break; + } + } + + if (user == null || user?.realm == null || ctx?._security?.realm?.name == null) { + return false; + } + + if (ctx._security.realm.name != user.realm) { + return false; + } + + return true; + } + + String verified(def ctx, def params) { + // Agents only use API keys. + if (ctx?._security?.authentication_type == null || ctx._security.authentication_type != 'API_KEY') { + return "no_api_key"; + } + + // Verify the API key owner before trusting any metadata it contains. + if (!is_user_trusted(ctx, params.trusted_users)) { + return "untrusted_user"; + } + + // API keys created by Fleet include metadata about the agent they were issued to. + if (ctx?._security?.api_key?.metadata?.agent_id == null || ctx?.agent?.id == null) { + return "missing_metadata"; + } + + // The API key can only be used represent the agent.id it was issued to. + if (ctx._security.api_key.metadata.agent_id != ctx.agent.id) { + // Potential masquerade attempt. + return "agent_id_mismatch"; + } + + return "verified"; + } + + if (ctx?.event == null) { + ctx.event = [:]; + } + + ctx.event.agent_id_status = verified(ctx, params); + params: + # List of users responsible for creating Fleet output API keys. + trusted_users: + - username: elastic + realm: reserved + - remove: + field: _security + ignore_missing: true +on_failure: + - remove: + field: _security + ignore_missing: true + ignore_failure: true + - append: + field: error.message + value: + - 'failed in Fleet agent final_pipeline: {{ _ingest.on_failure_message }}'`; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts index ac5aca7ab1c14..1d212f188120f 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts @@ -16,6 +16,7 @@ import { saveInstalledEsRefs } from '../../packages/install'; import { getInstallationObject } from '../../packages'; import { deletePipelineRefs } from './remove'; +import { FINAL_PIPELINE, FINAL_PIPELINE_ID } from './final_pipeline'; interface RewriteSubstitution { source: string; @@ -185,6 +186,28 @@ async function installPipeline({ return { id: pipeline.nameForInstallation, type: ElasticsearchAssetType.ingestPipeline }; } +export async function ensureFleetFinalPipelineIsInstalled(esClient: ElasticsearchClient) { + const esClientRequestOptions: TransportRequestOptions = { + ignore: [404], + }; + const res = await esClient.ingest.getPipeline({ id: FINAL_PIPELINE_ID }, esClientRequestOptions); + + if (res.statusCode === 404) { + await esClient.ingest.putPipeline( + // @ts-ignore pipeline is define in yaml + { id: FINAL_PIPELINE_ID, body: FINAL_PIPELINE }, + { + headers: { + // pipeline is YAML + 'Content-Type': 'application/yaml', + // but we want JSON responses (to extract error messages, status code, or other metadata) + Accept: 'application/json', + }, + } + ); + } +} + const isDirectory = ({ path }: ArchiveEntry) => path.endsWith('/'); const isDataStreamPipeline = (path: string, dataStreamDataset: string) => { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap index 65eec939d5850..acf8ae742bf8f 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap @@ -25,7 +25,8 @@ exports[`EPM template tests loading base.yml: base.yml 1`] = ` "default_field": [ "long.nested.foo" ] - } + }, + "final_pipeline": ".fleet_final_pipeline" } }, "mappings": { @@ -139,7 +140,8 @@ exports[`EPM template tests loading coredns.logs.yml: coredns.logs.yml 1`] = ` "coredns.response.code", "coredns.response.flags" ] - } + }, + "final_pipeline": ".fleet_final_pipeline" } }, "mappings": { @@ -281,7 +283,8 @@ exports[`EPM template tests loading system.yml: system.yml 1`] = ` "system.users.scope", "system.users.remote_host" ] - } + }, + "final_pipeline": ".fleet_final_pipeline" } }, "mappings": { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 64261226a7944..5dd2755390ecb 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -16,6 +16,7 @@ import type { } from '../../../../types'; import { appContextService } from '../../../'; import { getRegistryDataStreamAssetBaseName } from '../index'; +import { FINAL_PIPELINE_ID } from '../ingest_pipeline/final_pipeline'; interface Properties { [key: string]: any; @@ -86,6 +87,11 @@ export function getTemplate({ if (pipelineName) { template.template.settings.index.default_pipeline = pipelineName; } + if (template.template.settings.index.final_pipeline) { + throw new Error(`Error template for ${templateIndexPattern} contains a final_pipeline`); + } + template.template.settings.index.final_pipeline = FINAL_PIPELINE_ID; + return template; } diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index 28deec8a89028..7f4219799e511 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -20,6 +20,7 @@ import { settingsService } from '.'; import { awaitIfPending } from './setup_utils'; import { ensureAgentActionPolicyChangeExists } from './agents'; import { awaitIfFleetServerSetupPending } from './fleet_server'; +import { ensureFleetFinalPipelineIsInstalled } from './epm/elasticsearch/ingest_pipeline/install'; export interface SetupStatus { isInitialized: boolean; @@ -42,6 +43,8 @@ async function createSetupSideEffects( settingsService.settingsSetup(soClient), ]); + await ensureFleetFinalPipelineIsInstalled(esClient); + await awaitIfFleetServerSetupPending(); const { agentPolicies: policiesOrUndefined, packages: packagesOrUndefined } = diff --git a/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts b/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts new file mode 100644 index 0000000000000..1ab7b00da5d76 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { setupFleetAndAgents } from '../agents/services'; +import { skipIfNoDockerRegistry } from '../../helpers'; + +const TEST_INDEX = 'logs-log.log-test'; + +const FINAL_PIPELINE_ID = '.fleet_final_pipeline'; + +let pkgKey: string; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const supertest = getService('supertest'); + const es = getService('es'); + const esArchiver = getService('esArchiver'); + + function indexUsingApiKey(body: any, apiKey: string): Promise<{ body: Record }> { + const supertestWithoutAuth = getService('esSupertestWithoutAuth'); + return supertestWithoutAuth + .post(`/${TEST_INDEX}/_doc`) + .set('Authorization', `ApiKey ${apiKey}`) + .send(body) + .expect(201); + } + + describe('fleet_final_pipeline', () => { + skipIfNoDockerRegistry(providerContext); + before(async () => { + await esArchiver.load('fleet/empty_fleet_server'); + }); + setupFleetAndAgents(providerContext); + + // Use the custom log package to test the fleet final pipeline + before(async () => { + const { body: getPackagesRes } = await supertest.get( + `/api/fleet/epm/packages?experimental=true` + ); + + const logPackage = getPackagesRes.response.find((p: any) => p.name === 'log'); + if (!logPackage) { + throw new Error('No log package'); + } + + pkgKey = `log-${logPackage.version}`; + + await supertest + .post(`/api/fleet/epm/packages/${pkgKey}`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true }) + .expect(200); + }); + after(async () => { + await supertest + .delete(`/api/fleet/epm/packages/${pkgKey}`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true }) + .expect(200); + }); + + after(async () => { + await esArchiver.unload('fleet/empty_fleet_server'); + }); + + after(async () => { + const res = await es.search({ + index: TEST_INDEX, + }); + + for (const hit of res.body.hits.hits) { + await es.delete({ + id: hit._id, + index: hit._index, + }); + } + }); + + it('should correctly setup the final pipeline and apply to fleet managed index template', async () => { + const pipelineRes = await es.ingest.getPipeline({ id: FINAL_PIPELINE_ID }); + expect(pipelineRes.body).to.have.property(FINAL_PIPELINE_ID); + + const res = await es.indices.getIndexTemplate({ name: 'logs-log.log' }); + expect(res.body.index_templates.length).to.be(1); + expect( + res.body.index_templates[0]?.index_template?.template?.settings?.index?.final_pipeline + ).to.be(FINAL_PIPELINE_ID); + }); + + it('For a doc written without api key should write the correct api key status', async () => { + const res = await es.index({ + index: 'logs-log.log-test', + body: { + message: 'message-test-1', + '@timestamp': '2020-01-01T09:09:00', + agent: { + id: 'agent1', + }, + }, + }); + + const { body: doc } = await es.get({ + id: res.body._id, + index: res.body._index, + }); + // @ts-expect-error + const event = doc._source.event; + + expect(event.agent_id_status).to.be('no_api_key'); + expect(event).to.have.property('ingested'); + }); + + const scenarios = [ + { + name: 'API key without metadata', + expectedStatus: 'missing_metadata', + event: { agent: { id: 'agent1' } }, + }, + { + name: 'API key with agent id metadata', + expectedStatus: 'verified', + apiKey: { + metadata: { + agent_id: 'agent1', + }, + }, + event: { agent: { id: 'agent1' } }, + }, + { + name: 'API key with agent id metadata and no agent id in event', + expectedStatus: 'missing_metadata', + apiKey: { + metadata: { + agent_id: 'agent1', + }, + }, + }, + { + name: 'API key with agent id metadata and tampered agent id in event', + expectedStatus: 'agent_id_mismatch', + apiKey: { + metadata: { + agent_id: 'agent2', + }, + }, + event: { agent: { id: 'agent1' } }, + }, + ]; + + for (const scenario of scenarios) { + it(`Should write the correct event.agent_id_status for ${scenario.name}`, async () => { + // Create an API key + const { body: apiKeyRes } = await es.security.createApiKey({ + body: { + name: `test api key`, + ...(scenario.apiKey || {}), + }, + }); + + const res = await indexUsingApiKey( + { + message: 'message-test-1', + '@timestamp': '2020-01-01T09:09:00', + ...(scenario.event || {}), + }, + Buffer.from(`${apiKeyRes.id}:${apiKeyRes.api_key}`).toString('base64') + ); + + const { body: doc } = await es.get({ + id: res.body._id as string, + index: res.body._index as string, + }); + // @ts-expect-error + const event = doc._source.event; + + expect(event.agent_id_status).to.be(scenario.expectedStatus); + expect(event).to.have.property('ingested'); + }); + } + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/epm/index.js b/x-pack/test/fleet_api_integration/apis/epm/index.js index 445d9706bb9a9..b6a1fd5d7346d 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/index.js +++ b/x-pack/test/fleet_api_integration/apis/epm/index.js @@ -25,5 +25,6 @@ export default function loadTests({ loadTestFile }) { loadTestFile(require.resolve('./data_stream')); loadTestFile(require.resolve('./package_install_complete')); loadTestFile(require.resolve('./install_error_rollback')); + loadTestFile(require.resolve('./final_pipeline')); }); } From 07ce6374ef4996452eabad56ffa1022c8fcc7471 Mon Sep 17 00:00:00 2001 From: Thom Heymann <190132+thomheymann@users.noreply.github.com> Date: Thu, 3 Jun 2021 19:29:57 +0300 Subject: [PATCH 31/35] Bump packages (#101167) * Bump mini-css-extract-plugin * Removed unused dependency * Remove unecessary types * Don't match _meta field --- package.json | 4 +- .../index_lifecycle_management/policies.js | 3 + yarn.lock | 95 ++----------------- 3 files changed, 11 insertions(+), 91 deletions(-) diff --git a/package.json b/package.json index e0bebcaacd7ea..fd81b86c7da6e 100644 --- a/package.json +++ b/package.json @@ -241,7 +241,6 @@ "get-port": "^5.0.0", "getopts": "^2.2.5", "getos": "^3.1.0", - "git-url-parse": "11.1.2", "github-markdown-css": "^2.10.0", "glob": "^7.1.2", "glob-all": "^3.2.1", @@ -294,7 +293,7 @@ "memoize-one": "^5.0.0", "mime": "^2.4.4", "mime-types": "^2.1.27", - "mini-css-extract-plugin": "0.8.0", + "mini-css-extract-plugin": "1.1.0", "minimatch": "^3.0.4", "moment": "^2.24.0", "moment-duration-format": "^2.3.2", @@ -533,7 +532,6 @@ "@types/geojson": "7946.0.7", "@types/getopts": "^2.0.1", "@types/getos": "^3.0.0", - "@types/git-url-parse": "^9.0.0", "@types/glob": "^7.1.2", "@types/gulp": "^4.0.6", "@types/gulp-zip": "^4.0.1", diff --git a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js index 8f40f5826c537..a07b966668545 100644 --- a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js +++ b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js @@ -45,6 +45,9 @@ export default function ({ getService }) { const modifiedDate = '2019-04-30T14:30:00.000Z'; policy.modified_date = modifiedDate; + // We don't want to match `_meta` field since it can change between Elasticsearch versions + delete policy.policy._meta; + expect(policy).to.eql({ version: 1, modified_date: modifiedDate, diff --git a/yarn.lock b/yarn.lock index 032c5255130d9..8b47284516978 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4953,11 +4953,6 @@ resolved "https://registry.yarnpkg.com/@types/getos/-/getos-3.0.0.tgz#582c758e99e9d634f31f471faf7ce59cf1c39a71" integrity sha512-g5O9kykBPMaK5USwU+zM5AyXaztqbvHjSQ7HaBjqgO3f5lKGChkRhLP58Z/Nrr4RBGNNPrBcJkWZwnmbmi9YjQ== -"@types/git-url-parse@^9.0.0": - version "9.0.0" - resolved "https://registry.yarnpkg.com/@types/git-url-parse/-/git-url-parse-9.0.0.tgz#aac1315a44fa4ed5a52c3820f6c3c2fb79cbd12d" - integrity sha512-kA2RxBT/r/ZuDDKwMl+vFWn1Z0lfm1/Ik6Qb91wnSzyzCDa/fkM8gIOq6ruB7xfr37n6Mj5dyivileUVKsidlg== - "@types/glob-base@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@types/glob-base/-/glob-base-0.3.0.tgz#a581d688347e10e50dd7c17d6f2880a10354319d" @@ -14372,21 +14367,6 @@ gifwrap@^0.9.2: image-q "^1.1.1" omggif "^1.0.10" -git-up@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/git-up/-/git-up-4.0.1.tgz#cb2ef086653640e721d2042fe3104857d89007c0" - integrity sha512-LFTZZrBlrCrGCG07/dm1aCjjpL1z9L3+5aEeI9SBhAqSc+kiA9Or1bgZhQFNppJX6h/f5McrvJt1mQXTFm6Qrw== - dependencies: - is-ssh "^1.3.0" - parse-url "^5.0.0" - -git-url-parse@11.1.2: - version "11.1.2" - resolved "https://registry.yarnpkg.com/git-url-parse/-/git-url-parse-11.1.2.tgz#aff1a897c36cc93699270587bea3dbcbbb95de67" - integrity sha512-gZeLVGY8QVKMIkckncX+iCq2/L8PlwncvDFKiWkBn9EtCfYDbliRTTp6qzyQ1VMdITUfq7293zDzfpjdiGASSQ== - dependencies: - git-up "^4.0.0" - github-markdown-css@^2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/github-markdown-css/-/github-markdown-css-2.10.0.tgz#0612fed22816b33b282f37ef8def7a4ecabfe993" @@ -16546,13 +16526,6 @@ is-set@^2.0.1: resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.1.tgz#d1604afdab1724986d30091575f54945da7e5f43" integrity sha512-eJEzOtVyenDs1TMzSQ3kU3K+E0GUS9sno+F0OBT97xsgcJsF9nXMBtkT9/kut5JEpM7oL7X/0qxR17K3mcwIAA== -is-ssh@^1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/is-ssh/-/is-ssh-1.3.1.tgz#f349a8cadd24e65298037a522cf7520f2e81a0f3" - integrity sha512-0eRIASHZt1E68/ixClI8bp2YK2wmBPVWEismTs6M+M099jKgrzl/3E976zIbImSIob48N2/XGe9y7ZiYdImSlg== - dependencies: - protocols "^1.1.0" - is-stream@^1.0.0, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" @@ -19306,14 +19279,13 @@ mini-create-react-context@^0.4.0: "@babel/runtime" "^7.5.5" tiny-warning "^1.0.3" -mini-css-extract-plugin@0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.8.0.tgz#81d41ec4fe58c713a96ad7c723cdb2d0bd4d70e1" - integrity sha512-MNpRGbNA52q6U92i0qbVpQNsgk7LExy41MdAlG84FeytfDOtRIf/mCHdEgG8rpTKOaNKiqUnZdlptF469hxqOw== +mini-css-extract-plugin@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-1.1.0.tgz#dcc2f0bfbec660c0bd1200ff7c8f82deec2cc8a6" + integrity sha512-0bTS+Fg2tGe3dFAgfiN7+YRO37oyQM7/vjFvZF1nXSCJ/sy0tGpeme8MbT4BCpUuUphKwTh9LH/uuTcWRr9DPA== dependencies: - loader-utils "^1.1.0" - normalize-url "1.9.1" - schema-utils "^1.0.0" + loader-utils "^2.0.0" + schema-utils "^3.0.0" webpack-sources "^1.1.0" minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: @@ -20260,17 +20232,7 @@ normalize-selector@^0.2.0: resolved "https://registry.yarnpkg.com/normalize-selector/-/normalize-selector-0.2.0.tgz#d0b145eb691189c63a78d201dc4fdb1293ef0c03" integrity sha1-0LFF62kRicY6eNIB3E/bEpPvDAM= -normalize-url@1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c" - integrity sha1-LMDWazHqIwNkWENuNiDYWVTGbDw= - dependencies: - object-assign "^4.0.1" - prepend-http "^1.0.0" - query-string "^4.1.0" - sort-keys "^1.0.0" - -normalize-url@^3.0.0, normalize-url@^3.3.0: +normalize-url@^3.0.0: version "3.3.0" resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== @@ -21117,24 +21079,6 @@ parse-passwd@^1.0.0: resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY= -parse-path@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/parse-path/-/parse-path-4.0.1.tgz#0ec769704949778cb3b8eda5e994c32073a1adff" - integrity sha512-d7yhga0Oc+PwNXDvQ0Jv1BuWkLVPXcAoQ/WREgd6vNNoKYaW52KI+RdOFjI63wjkmps9yUE8VS4veP+AgpQ/hA== - dependencies: - is-ssh "^1.3.0" - protocols "^1.4.0" - -parse-url@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/parse-url/-/parse-url-5.0.1.tgz#99c4084fc11be14141efa41b3d117a96fcb9527f" - integrity sha512-flNUPP27r3vJpROi0/R3/2efgKkyXqnXwyP1KQ2U0SfFRgdizOdWfvrrvJg1LuOoxs7GQhmxJlq23IpQ/BkByg== - dependencies: - is-ssh "^1.3.0" - normalize-url "^3.3.0" - parse-path "^4.0.0" - protocols "^1.4.0" - parse5@5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2" @@ -22277,11 +22221,6 @@ protocol-buffers-schema@^3.3.1: resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.3.2.tgz#00434f608b4e8df54c59e070efeefc37fb4bb859" integrity sha512-Xdayp8sB/mU+sUV4G7ws8xtYMGdQnxbeIfLjyO9TZZRJdztBGhlmbI5x1qcY4TG5hBkIKGnc28i7nXxaugu88w== -protocols@^1.1.0, protocols@^1.4.0: - version "1.4.7" - resolved "https://registry.yarnpkg.com/protocols/-/protocols-1.4.7.tgz#95f788a4f0e979b291ffefcf5636ad113d037d32" - integrity sha512-Fx65lf9/YDn3hUX08XUc0J8rSux36rEsyiv21ZGUC1mOyeM3lTRpZLcrm8aAolzS4itwVfm7TAPyxC2E5zd6xg== - proxy-addr@~2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34" @@ -22455,14 +22394,6 @@ qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== -query-string@^4.1.0: - version "4.3.4" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb" - integrity sha1-u7aTucqRXCMlFbIosaArYJBD2+s= - dependencies: - object-assign "^4.1.0" - strict-uri-encode "^1.0.0" - query-string@^6.13.2: version "6.13.2" resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.13.2.tgz#3585aa9412c957cbd358fd5eaca7466f05586dda" @@ -25242,13 +25173,6 @@ sonic-boom@^1.0.2: atomic-sleep "^1.0.0" flatstr "^1.0.12" -sort-keys@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" - integrity sha1-RBttTTRnmPG05J6JIK37oOVD+a0= - dependencies: - is-plain-obj "^1.0.0" - sort-keys@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-2.0.0.tgz#658535584861ec97d730d6cf41822e1f56684128" @@ -25793,11 +25717,6 @@ stream-to-async-iterator@^0.2.0: resolved "https://registry.yarnpkg.com/stream-to-async-iterator/-/stream-to-async-iterator-0.2.0.tgz#bef5c885e9524f98b2fa5effecc357bd58483780" integrity sha1-vvXIhelST5iy+l7/7MNXvVhIN4A= -strict-uri-encode@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" - integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= - strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" From f69d63e8be8052d8400021fa91c440bf79c05925 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 3 Jun 2021 17:53:39 +0100 Subject: [PATCH 32/35] fix(NA): windows ts_project outside sandbox compilation (#100947) * fix(NA): windows ts_project outside sandbox compilation adding tsconfig paths for packages * chore(NA): missing @kbn paths for node_modules so types can work * chore(NA): missing @kbn paths for node_modules so types can work * chore(NA): organizing deps on non ts_project packages * chore(NA): change order to find @kbn packages on node_modules first * chore(NA): add @kbn/expect typings setting on package.json * chore(NA): fix typechecking * chore(NA): add missing change on tsconfig file * chore(NA): unblock windows build by not depending on the pkg_npm rule symlink in the package.json * chore(NA): add missing depedencies on BUILD.bazel file for io-ts-list-types * chore(NA): remove rootDirs configs * chore(NA): change kbn/monaco targets order * chore(NA): update kbn-monaco build Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 82 +++++++++---------- packages/kbn-babel-code-parser/BUILD.bazel | 4 +- packages/kbn-es/BUILD.bazel | 4 +- packages/kbn-expect/BUILD.bazel | 2 +- .../{expect.js.d.ts => expect.d.ts} | 0 packages/kbn-expect/package.json | 1 + packages/kbn-expect/tsconfig.json | 2 +- packages/kbn-monaco/BUILD.bazel | 4 +- packages/kbn-monaco/webpack.config.js | 1 - packages/kbn-pm/dist/index.js | 4 +- packages/kbn-pm/src/utils/package_json.ts | 2 +- packages/kbn-pm/src/utils/project.ts | 2 +- .../BUILD.bazel | 3 +- test/functional/page_objects/error_page.ts | 2 +- .../page_objects/visualize_editor_page.ts | 2 +- tsconfig.base.json | 7 ++ .../spaces_only/tests/alerting/update.ts | 2 +- .../apis/lists/create_exception_list_item.ts | 2 +- .../api_integration/apis/security/api_keys.ts | 2 +- .../apis/security/builtin_es_privileges.ts | 2 +- .../apis/security/index_fields.ts | 2 +- .../apis/security/license_downgrade.ts | 2 +- .../apis/security/privileges.ts | 2 +- .../apis/spaces/saved_objects.ts | 2 +- .../apis/agents/upgrade.ts | 2 +- .../page_objects/infra_home_page.ts | 2 +- .../functional/services/uptime/monitor.ts | 2 +- .../event_log/public_api_integration.ts | 2 +- .../event_log/service_api_integration.ts | 2 +- .../common/suites/delete.ts | 2 +- .../tests/session_idle/extension.ts | 2 +- .../apis/metadata.ts | 2 +- .../apis/metadata_v1.ts | 2 +- .../apis/policy.ts | 2 +- yarn.lock | 82 +++++++++---------- 35 files changed, 124 insertions(+), 116 deletions(-) rename packages/kbn-expect/{expect.js.d.ts => expect.d.ts} (100%) diff --git a/package.json b/package.json index fd81b86c7da6e..f0803b3b44056 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", "@elastic/charts": "29.2.0", - "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath/npm_module", + "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.4", "@elastic/ems-client": "7.13.0", "@elastic/eui": "33.0.0", @@ -111,7 +111,7 @@ "@elastic/numeral": "^2.5.1", "@elastic/react-search-ui": "^1.5.1", "@elastic/request-crypto": "1.1.4", - "@elastic/safer-lodash-set": "link:bazel-bin/packages/elastic-safer-lodash-set/npm_module", + "@elastic/safer-lodash-set": "link:bazel-bin/packages/elastic-safer-lodash-set", "@elastic/search-ui-app-search-connector": "^1.5.0", "@elastic/ui-ace": "0.2.3", "@hapi/accept": "^5.0.2", @@ -124,39 +124,39 @@ "@hapi/inert": "^6.0.3", "@hapi/podium": "^4.1.1", "@hapi/wreck": "^17.1.0", - "@kbn/ace": "link:bazel-bin/packages/kbn-ace/npm_module", - "@kbn/analytics": "link:bazel-bin/packages/kbn-analytics/npm_module", - "@kbn/apm-config-loader": "link:bazel-bin/packages/kbn-apm-config-loader/npm_module", - "@kbn/apm-utils": "link:bazel-bin/packages/kbn-apm-utils/npm_module", - "@kbn/config": "link:bazel-bin/packages/kbn-config/npm_module", - "@kbn/config-schema": "link:bazel-bin/packages/kbn-config-schema/npm_module", - "@kbn/crypto": "link:bazel-bin/packages/kbn-crypto/npm_module", - "@kbn/i18n": "link:bazel-bin/packages/kbn-i18n/npm_module", + "@kbn/ace": "link:bazel-bin/packages/kbn-ace", + "@kbn/analytics": "link:bazel-bin/packages/kbn-analytics", + "@kbn/apm-config-loader": "link:bazel-bin/packages/kbn-apm-config-loader", + "@kbn/apm-utils": "link:bazel-bin/packages/kbn-apm-utils", + "@kbn/config": "link:bazel-bin/packages/kbn-config", + "@kbn/config-schema": "link:bazel-bin/packages/kbn-config-schema", + "@kbn/crypto": "link:bazel-bin/packages/kbn-crypto", + "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl", + "@kbn/i18n": "link:bazel-bin/packages/kbn-i18n", "@kbn/interpreter": "link:packages/kbn-interpreter", - "@kbn/io-ts-utils": "link:bazel-bin/packages/kbn-io-ts-utils/npm_module", - "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging/npm_module", - "@kbn/logging": "link:bazel-bin/packages/kbn-logging/npm_module", - "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl/npm_module", - "@kbn/monaco": "link:bazel-bin/packages/kbn-monaco/npm_module", + "@kbn/io-ts-utils": "link:bazel-bin/packages/kbn-io-ts-utils", + "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging", + "@kbn/logging": "link:bazel-bin/packages/kbn-logging", + "@kbn/monaco": "link:bazel-bin/packages/kbn-monaco", "@kbn/rule-data-utils": "link:packages/kbn-rule-data-utils", - "@kbn/securitysolution-es-utils": "link:bazel-bin/packages/kbn-securitysolution-es-utils/npm_module", - "@kbn/securitysolution-io-ts-alerting-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types/npm_module", - "@kbn/securitysolution-io-ts-list-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types/npm_module", - "@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types/npm_module", - "@kbn/securitysolution-io-ts-utils": "link:bazel-bin/packages/kbn-securitysolution-io-ts-utils/npm_module", - "@kbn/securitysolution-list-api": "link:bazel-bin/packages/kbn-securitysolution-list-api/npm_module", - "@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants/npm_module", - "@kbn/securitysolution-list-hooks": "link:bazel-bin/packages/kbn-securitysolution-list-hooks/npm_module", - "@kbn/securitysolution-list-utils": "link:bazel-bin/packages/kbn-securitysolution-list-utils/npm_module", - "@kbn/securitysolution-utils": "link:bazel-bin/packages/kbn-securitysolution-utils/npm_module", - "@kbn/server-http-tools": "link:bazel-bin/packages/kbn-server-http-tools/npm_module", + "@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants", + "@kbn/securitysolution-es-utils": "link:bazel-bin/packages/kbn-securitysolution-es-utils", + "@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types", + "@kbn/securitysolution-io-ts-alerting-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types", + "@kbn/securitysolution-io-ts-list-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types", + "@kbn/securitysolution-io-ts-utils": "link:bazel-bin/packages/kbn-securitysolution-io-ts-utils", + "@kbn/securitysolution-list-api": "link:bazel-bin/packages/kbn-securitysolution-list-api", + "@kbn/securitysolution-list-hooks": "link:bazel-bin/packages/kbn-securitysolution-list-hooks", + "@kbn/securitysolution-list-utils": "link:bazel-bin/packages/kbn-securitysolution-list-utils", + "@kbn/securitysolution-utils": "link:bazel-bin/packages/kbn-securitysolution-utils", + "@kbn/server-http-tools": "link:bazel-bin/packages/kbn-server-http-tools", "@kbn/server-route-repository": "link:packages/kbn-server-route-repository", - "@kbn/std": "link:bazel-bin/packages/kbn-std/npm_module", - "@kbn/tinymath": "link:bazel-bin/packages/kbn-tinymath/npm_module", + "@kbn/std": "link:bazel-bin/packages/kbn-std", + "@kbn/tinymath": "link:bazel-bin/packages/kbn-tinymath", "@kbn/ui-framework": "link:packages/kbn-ui-framework", "@kbn/ui-shared-deps": "link:packages/kbn-ui-shared-deps", - "@kbn/utility-types": "link:bazel-bin/packages/kbn-utility-types/npm_module", - "@kbn/utils": "link:bazel-bin/packages/kbn-utils/npm_module", + "@kbn/utility-types": "link:bazel-bin/packages/kbn-utility-types", + "@kbn/utils": "link:bazel-bin/packages/kbn-utils", "@loaders.gl/core": "^2.3.1", "@loaders.gl/json": "^2.3.1", "@mapbox/geojson-rewind": "^0.5.0", @@ -446,28 +446,28 @@ "@cypress/webpack-preprocessor": "^5.6.0", "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana/npm_module", + "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", "@elastic/makelogs": "^6.0.0", "@istanbuljs/schema": "^0.1.2", "@jest/reporters": "^26.6.2", - "@kbn/babel-code-parser": "link:bazel-bin/packages/kbn-babel-code-parser/npm_module", - "@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset/npm_module", + "@kbn/babel-code-parser": "link:bazel-bin/packages/kbn-babel-code-parser", + "@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset", "@kbn/cli-dev-mode": "link:packages/kbn-cli-dev-mode", - "@kbn/dev-utils": "link:bazel-bin/packages/kbn-dev-utils/npm_module", - "@kbn/docs-utils": "link:bazel-bin/packages/kbn-docs-utils/npm_module", - "@kbn/es": "link:bazel-bin/packages/kbn-es/npm_module", + "@kbn/dev-utils": "link:bazel-bin/packages/kbn-dev-utils", + "@kbn/docs-utils": "link:bazel-bin/packages/kbn-docs-utils", + "@kbn/es": "link:bazel-bin/packages/kbn-es", "@kbn/es-archiver": "link:packages/kbn-es-archiver", - "@kbn/eslint-import-resolver-kibana": "link:bazel-bin/packages/kbn-eslint-import-resolver-kibana/npm_module", - "@kbn/eslint-plugin-eslint": "link:bazel-bin/packages/kbn-eslint-plugin-eslint/npm_module", - "@kbn/expect": "link:bazel-bin/packages/kbn-expect/npm_module", + "@kbn/eslint-import-resolver-kibana": "link:bazel-bin/packages/kbn-eslint-import-resolver-kibana", + "@kbn/eslint-plugin-eslint": "link:bazel-bin/packages/kbn-eslint-plugin-eslint", + "@kbn/expect": "link:bazel-bin/packages/kbn-expect", "@kbn/optimizer": "link:packages/kbn-optimizer", - "@kbn/plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator/npm_module", + "@kbn/plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator", "@kbn/plugin-helpers": "link:packages/kbn-plugin-helpers", "@kbn/pm": "link:packages/kbn-pm", "@kbn/storybook": "link:packages/kbn-storybook", - "@kbn/telemetry-tools": "link:bazel-bin/packages/kbn-telemetry-tools/npm_module", + "@kbn/telemetry-tools": "link:bazel-bin/packages/kbn-telemetry-tools", "@kbn/test": "link:packages/kbn-test", "@kbn/test-subj-selector": "link:packages/kbn-test-subj-selector", "@loaders.gl/polyfills": "^2.3.5", diff --git a/packages/kbn-babel-code-parser/BUILD.bazel b/packages/kbn-babel-code-parser/BUILD.bazel index c1576ce59aa5c..dcdc042d7b802 100644 --- a/packages/kbn-babel-code-parser/BUILD.bazel +++ b/packages/kbn-babel-code-parser/BUILD.bazel @@ -34,10 +34,10 @@ DEPS = [ babel( name = "target", - data = [ + data = DEPS + [ ":srcs", ".babelrc", - ] + DEPS, + ], output_dir = True, # the following arg paths includes $(execpath) as babel runs on the sandbox root args = [ diff --git a/packages/kbn-es/BUILD.bazel b/packages/kbn-es/BUILD.bazel index 6c845996ce5e5..48f0fb58e983f 100644 --- a/packages/kbn-es/BUILD.bazel +++ b/packages/kbn-es/BUILD.bazel @@ -47,10 +47,10 @@ DEPS = [ babel( name = "target", - data = [ + data = DEPS + [ ":srcs", ".babelrc", - ] + DEPS, + ], output_dir = True, # the following arg paths includes $(execpath) as babel runs on the sandbox root args = [ diff --git a/packages/kbn-expect/BUILD.bazel b/packages/kbn-expect/BUILD.bazel index 82e6200e9688a..b7eb91a451b9a 100644 --- a/packages/kbn-expect/BUILD.bazel +++ b/packages/kbn-expect/BUILD.bazel @@ -5,7 +5,7 @@ PKG_REQUIRE_NAME = "@kbn/expect" SOURCE_FILES = glob([ "expect.js", - "expect.js.d.ts", + "expect.d.ts", ]) SRCS = SOURCE_FILES diff --git a/packages/kbn-expect/expect.js.d.ts b/packages/kbn-expect/expect.d.ts similarity index 100% rename from packages/kbn-expect/expect.js.d.ts rename to packages/kbn-expect/expect.d.ts diff --git a/packages/kbn-expect/package.json b/packages/kbn-expect/package.json index 8ca37c7c88673..2040683c539e2 100644 --- a/packages/kbn-expect/package.json +++ b/packages/kbn-expect/package.json @@ -1,6 +1,7 @@ { "name": "@kbn/expect", "main": "./expect.js", + "typings": "./expect.d.ts", "version": "1.0.0", "license": "MIT", "private": true, diff --git a/packages/kbn-expect/tsconfig.json b/packages/kbn-expect/tsconfig.json index 7baae093bc3a9..8c0d9f1e34bd0 100644 --- a/packages/kbn-expect/tsconfig.json +++ b/packages/kbn-expect/tsconfig.json @@ -4,6 +4,6 @@ "incremental": false, }, "include": [ - "expect.js.d.ts" + "expect.d.ts" ] } diff --git a/packages/kbn-monaco/BUILD.bazel b/packages/kbn-monaco/BUILD.bazel index 3a25568dfd811..325187cdebc3a 100644 --- a/packages/kbn-monaco/BUILD.bazel +++ b/packages/kbn-monaco/BUILD.bazel @@ -48,7 +48,7 @@ webpack( name = "target_web", data = DEPS + [ ":src", - ":webpack.config.js", + "webpack.config.js", ], output_dir = True, args = [ @@ -87,7 +87,7 @@ ts_project( js_library( name = PKG_BASE_NAME, srcs = NPM_MODULE_EXTRA_FILES, - deps = DEPS + [":target_web", ":tsc"], + deps = DEPS + [":tsc", ":target_web"], package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], ) diff --git a/packages/kbn-monaco/webpack.config.js b/packages/kbn-monaco/webpack.config.js index d035134565463..ef482cd55159b 100644 --- a/packages/kbn-monaco/webpack.config.js +++ b/packages/kbn-monaco/webpack.config.js @@ -22,7 +22,6 @@ const createLangWorkerConfig = (lang) => { filename: `${lang}.editor.worker.js`, }, resolve: { - modules: ['node_modules'], extensions: ['.js', '.ts', '.tsx'], }, stats: 'errors-only', diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 29c0457c316f0..4c4c0259f066b 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -23045,7 +23045,7 @@ class Project { ensureValidProjectDependency(project) { const relativePathToProject = normalizePath(path__WEBPACK_IMPORTED_MODULE_1___default.a.relative(this.path, project.path)); - const relativePathToProjectIfBazelPkg = normalizePath(path__WEBPACK_IMPORTED_MODULE_1___default.a.relative(this.path, `${__dirname}/../../../bazel-bin/packages/${path__WEBPACK_IMPORTED_MODULE_1___default.a.basename(project.path)}/npm_module`)); + const relativePathToProjectIfBazelPkg = normalizePath(path__WEBPACK_IMPORTED_MODULE_1___default.a.relative(this.path, `${__dirname}/../../../bazel-bin/packages/${path__WEBPACK_IMPORTED_MODULE_1___default.a.basename(project.path)}`)); const versionInPackageJson = this.allDependencies[project.name]; const expectedVersionInPackageJson = `link:${relativePathToProject}`; const expectedVersionInPackageJsonIfBazelPkg = `link:${relativePathToProjectIfBazelPkg}`; // TODO: after introduce bazel to build all the packages and completely remove the support for kbn packages @@ -23234,7 +23234,7 @@ function transformDependencies(dependencies = {}) { } if (isBazelPackageDependency(depVersion)) { - newDeps[name] = depVersion.replace('link:bazel-bin/', 'file:').replace('/npm_module', ''); + newDeps[name] = depVersion.replace('link:bazel-bin/', 'file:'); continue; } diff --git a/packages/kbn-pm/src/utils/package_json.ts b/packages/kbn-pm/src/utils/package_json.ts index e635c2566e65a..a50d8994b5720 100644 --- a/packages/kbn-pm/src/utils/package_json.ts +++ b/packages/kbn-pm/src/utils/package_json.ts @@ -61,7 +61,7 @@ export function transformDependencies(dependencies: IPackageDependencies = {}) { } if (isBazelPackageDependency(depVersion)) { - newDeps[name] = depVersion.replace('link:bazel-bin/', 'file:').replace('/npm_module', ''); + newDeps[name] = depVersion.replace('link:bazel-bin/', 'file:'); continue; } diff --git a/packages/kbn-pm/src/utils/project.ts b/packages/kbn-pm/src/utils/project.ts index 5d2a0547b2577..8e86b111c6a18 100644 --- a/packages/kbn-pm/src/utils/project.ts +++ b/packages/kbn-pm/src/utils/project.ts @@ -94,7 +94,7 @@ export class Project { const relativePathToProjectIfBazelPkg = normalizePath( Path.relative( this.path, - `${__dirname}/../../../bazel-bin/packages/${Path.basename(project.path)}/npm_module` + `${__dirname}/../../../bazel-bin/packages/${Path.basename(project.path)}` ) ); diff --git a/packages/kbn-securitysolution-io-ts-list-types/BUILD.bazel b/packages/kbn-securitysolution-io-ts-list-types/BUILD.bazel index 91e4667c16b4e..99df07c3d8ea8 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/BUILD.bazel +++ b/packages/kbn-securitysolution-io-ts-list-types/BUILD.bazel @@ -27,9 +27,10 @@ NPM_MODULE_EXTRA_FILES = [ ] SRC_DEPS = [ + "//packages/elastic-datemath", "//packages/kbn-securitysolution-io-ts-types", "//packages/kbn-securitysolution-io-ts-utils", - "//packages/elastic-datemath", + "//packages/kbn-securitysolution-list-constants", "@npm//fp-ts", "@npm//io-ts", "@npm//lodash", diff --git a/test/functional/page_objects/error_page.ts b/test/functional/page_objects/error_page.ts index e3d5a7fdf57c2..98096f3179d02 100644 --- a/test/functional/page_objects/error_page.ts +++ b/test/functional/page_objects/error_page.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function ErrorPageProvider({ getPageObjects }: FtrProviderContext) { diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index 47cbc8c5e3ea3..9ba1ab6f85081 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrProviderContext) { diff --git a/tsconfig.base.json b/tsconfig.base.json index eca78e492ff5e..cc8b66848a394 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -2,6 +2,13 @@ "compilerOptions": { "baseUrl": ".", "paths": { + // Setup @kbn paths for Bazel compilations + "@kbn/*": [ + "node_modules/@kbn/*", + "bazel-out/darwin-fastbuild/bin/packages/kbn-*", + "bazel-out/k8-fastbuild/bin/packages/kbn-*", + "bazel-out/x64_windows-fastbuild/bin/packages/kbn-*", + ], // Allows for importing from `kibana` package for the exported types. "kibana": ["./kibana"], "kibana/public": ["src/core/public"], diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts index 318da3e114097..3d98e428fd9ee 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { Spaces } from '../../scenarios'; import { checkAAD, getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts b/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts index db3cdd17a89dc..fb80d81dd242a 100644 --- a/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts +++ b/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/security/api_keys.ts b/x-pack/test/api_integration/apis/security/api_keys.ts index c79c4f3eaa88e..d2614abc9e5f7 100644 --- a/x-pack/test/api_integration/apis/security/api_keys.ts +++ b/x-pack/test/api_integration/apis/security/api_keys.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts b/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts index 9e4f5c8af8b05..c927d095b8889 100644 --- a/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts +++ b/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/index_fields.ts b/x-pack/test/api_integration/apis/security/index_fields.ts index 442740c7666df..3f036bcd7f7ea 100644 --- a/x-pack/test/api_integration/apis/security/index_fields.ts +++ b/x-pack/test/api_integration/apis/security/index_fields.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/license_downgrade.ts b/x-pack/test/api_integration/apis/security/license_downgrade.ts index 583df6ea5ed07..7a5ad1ce64a62 100644 --- a/x-pack/test/api_integration/apis/security/license_downgrade.ts +++ b/x-pack/test/api_integration/apis/security/license_downgrade.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index f08712e015656..d6ad5f6cd387b 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -7,7 +7,7 @@ import util from 'util'; import { isEqual, isEqualWith } from 'lodash'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { RawKibanaPrivileges } from '../../../../plugins/security/common/model'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/spaces/saved_objects.ts b/x-pack/test/api_integration/apis/spaces/saved_objects.ts index b520c374d4f90..20fc3428bb2b1 100644 --- a/x-pack/test/api_integration/apis/spaces/saved_objects.ts +++ b/x-pack/test/api_integration/apis/spaces/saved_objects.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts index b692699182cac..0722edbcb45b3 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import semver from 'semver'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { setupFleetAndAgents } from './services'; diff --git a/x-pack/test/functional/page_objects/infra_home_page.ts b/x-pack/test/functional/page_objects/infra_home_page.ts index 2f4575d45cc20..a5388aa829d01 100644 --- a/x-pack/test/functional/page_objects/infra_home_page.ts +++ b/x-pack/test/functional/page_objects/infra_home_page.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import testSubjSelector from '@kbn/test-subj-selector'; import { FtrProviderContext } from '../ftr_provider_context'; diff --git a/x-pack/test/functional/services/uptime/monitor.ts b/x-pack/test/functional/services/uptime/monitor.ts index 417c9bb20f9b7..3b22a5f7f6630 100644 --- a/x-pack/test/functional/services/uptime/monitor.ts +++ b/x-pack/test/functional/services/uptime/monitor.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export function UptimeMonitorProvider({ getService, getPageObjects }: FtrProviderContext) { diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts index 58bac6fe45417..f2497041094f7 100644 --- a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts @@ -7,7 +7,7 @@ import { merge, omit, chunk, isEmpty } from 'lodash'; import uuid from 'uuid'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import moment from 'moment'; import { FtrProviderContext } from '../../ftr_provider_context'; import { IEvent } from '../../../../plugins/event_log/server'; diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts index f9f518091847d..170b01a01edf9 100644 --- a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts @@ -7,7 +7,7 @@ import _ from 'lodash'; import uuid from 'uuid'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { IEvent } from '../../../../plugins/event_log/server'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/saved_object_api_integration/common/suites/delete.ts b/x-pack/test/saved_object_api_integration/common/suites/delete.ts index 37aff5a8cdadd..1ba8ea32b9922 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/delete.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/delete.ts @@ -6,7 +6,7 @@ */ import { SuperTest } from 'supertest'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils'; diff --git a/x-pack/test/security_api_integration/tests/session_idle/extension.ts b/x-pack/test/security_api_integration/tests/session_idle/extension.ts index 71621f4e3db8a..b8fef972f05d6 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/extension.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/extension.ts @@ -6,7 +6,7 @@ */ import { Cookie, cookie } from 'request'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index 13a19e55ab588..da339f54d41f4 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deleteAllDocsFromMetadataCurrentIndex, diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts index f3f86d4610d2b..1e1322944153b 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deleteMetadataStream } from './data_stream_helper'; import { METADATA_REQUEST_V1_ROUTE } from '../../../plugins/security_solution/server/endpoint/routes/metadata'; diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts b/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts index 79ea93da8a5b0..318e857bdcad0 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deletePolicyStream } from './data_stream_helper'; diff --git a/yarn.lock b/yarn.lock index 8b47284516978..7f2b44b5d0c3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1392,7 +1392,7 @@ utility-types "^3.10.0" uuid "^3.3.2" -"@elastic/datemath@link:bazel-bin/packages/elastic-datemath/npm_module": +"@elastic/datemath@link:bazel-bin/packages/elastic-datemath": version "0.0.0" uid "" @@ -1435,7 +1435,7 @@ semver "7.3.2" topojson-client "^3.1.0" -"@elastic/eslint-config-kibana@link:bazel-bin/packages/elastic-eslint-config-kibana/npm_module": +"@elastic/eslint-config-kibana@link:bazel-bin/packages/elastic-eslint-config-kibana": version "0.0.0" uid "" @@ -1572,7 +1572,7 @@ "@types/node-jose" "1.1.0" node-jose "1.1.0" -"@elastic/safer-lodash-set@link:bazel-bin/packages/elastic-safer-lodash-set/npm_module": +"@elastic/safer-lodash-set@link:bazel-bin/packages/elastic-safer-lodash-set": version "0.0.0" uid "" @@ -2591,27 +2591,27 @@ "@babel/runtime" "^7.7.2" regenerator-runtime "^0.13.3" -"@kbn/ace@link:bazel-bin/packages/kbn-ace/npm_module": +"@kbn/ace@link:bazel-bin/packages/kbn-ace": version "0.0.0" uid "" -"@kbn/analytics@link:bazel-bin/packages/kbn-analytics/npm_module": +"@kbn/analytics@link:bazel-bin/packages/kbn-analytics": version "0.0.0" uid "" -"@kbn/apm-config-loader@link:bazel-bin/packages/kbn-apm-config-loader/npm_module": +"@kbn/apm-config-loader@link:bazel-bin/packages/kbn-apm-config-loader": version "0.0.0" uid "" -"@kbn/apm-utils@link:bazel-bin/packages/kbn-apm-utils/npm_module": +"@kbn/apm-utils@link:bazel-bin/packages/kbn-apm-utils": version "0.0.0" uid "" -"@kbn/babel-code-parser@link:bazel-bin/packages/kbn-babel-code-parser/npm_module": +"@kbn/babel-code-parser@link:bazel-bin/packages/kbn-babel-code-parser": version "0.0.0" uid "" -"@kbn/babel-preset@link:bazel-bin/packages/kbn-babel-preset/npm_module": +"@kbn/babel-preset@link:bazel-bin/packages/kbn-babel-preset": version "0.0.0" uid "" @@ -2619,23 +2619,23 @@ version "0.0.0" uid "" -"@kbn/config-schema@link:bazel-bin/packages/kbn-config-schema/npm_module": +"@kbn/config-schema@link:bazel-bin/packages/kbn-config-schema": version "0.0.0" uid "" -"@kbn/config@link:bazel-bin/packages/kbn-config/npm_module": +"@kbn/config@link:bazel-bin/packages/kbn-config": version "0.0.0" uid "" -"@kbn/crypto@link:bazel-bin/packages/kbn-crypto/npm_module": +"@kbn/crypto@link:bazel-bin/packages/kbn-crypto": version "0.0.0" uid "" -"@kbn/dev-utils@link:bazel-bin/packages/kbn-dev-utils/npm_module": +"@kbn/dev-utils@link:bazel-bin/packages/kbn-dev-utils": version "0.0.0" uid "" -"@kbn/docs-utils@link:bazel-bin/packages/kbn-docs-utils/npm_module": +"@kbn/docs-utils@link:bazel-bin/packages/kbn-docs-utils": version "0.0.0" uid "" @@ -2643,23 +2643,23 @@ version "0.0.0" uid "" -"@kbn/es@link:bazel-bin/packages/kbn-es/npm_module": +"@kbn/es@link:bazel-bin/packages/kbn-es": version "0.0.0" uid "" -"@kbn/eslint-import-resolver-kibana@link:bazel-bin/packages/kbn-eslint-import-resolver-kibana/npm_module": +"@kbn/eslint-import-resolver-kibana@link:bazel-bin/packages/kbn-eslint-import-resolver-kibana": version "0.0.0" uid "" -"@kbn/eslint-plugin-eslint@link:bazel-bin/packages/kbn-eslint-plugin-eslint/npm_module": +"@kbn/eslint-plugin-eslint@link:bazel-bin/packages/kbn-eslint-plugin-eslint": version "0.0.0" uid "" -"@kbn/expect@link:bazel-bin/packages/kbn-expect/npm_module": +"@kbn/expect@link:bazel-bin/packages/kbn-expect": version "0.0.0" uid "" -"@kbn/i18n@link:bazel-bin/packages/kbn-i18n/npm_module": +"@kbn/i18n@link:bazel-bin/packages/kbn-i18n": version "0.0.0" uid "" @@ -2667,23 +2667,23 @@ version "0.0.0" uid "" -"@kbn/io-ts-utils@link:bazel-bin/packages/kbn-io-ts-utils/npm_module": +"@kbn/io-ts-utils@link:bazel-bin/packages/kbn-io-ts-utils": version "0.0.0" uid "" -"@kbn/legacy-logging@link:bazel-bin/packages/kbn-legacy-logging/npm_module": +"@kbn/legacy-logging@link:bazel-bin/packages/kbn-legacy-logging": version "0.0.0" uid "" -"@kbn/logging@link:bazel-bin/packages/kbn-logging/npm_module": +"@kbn/logging@link:bazel-bin/packages/kbn-logging": version "0.0.0" uid "" -"@kbn/mapbox-gl@link:bazel-bin/packages/kbn-mapbox-gl/npm_module": +"@kbn/mapbox-gl@link:bazel-bin/packages/kbn-mapbox-gl": version "0.0.0" uid "" -"@kbn/monaco@link:bazel-bin/packages/kbn-monaco/npm_module": +"@kbn/monaco@link:bazel-bin/packages/kbn-monaco": version "0.0.0" uid "" @@ -2691,7 +2691,7 @@ version "0.0.0" uid "" -"@kbn/plugin-generator@link:bazel-bin/packages/kbn-plugin-generator/npm_module": +"@kbn/plugin-generator@link:bazel-bin/packages/kbn-plugin-generator": version "0.0.0" uid "" @@ -2707,47 +2707,47 @@ version "0.0.0" uid "" -"@kbn/securitysolution-es-utils@link:bazel-bin/packages/kbn-securitysolution-es-utils/npm_module": +"@kbn/securitysolution-es-utils@link:bazel-bin/packages/kbn-securitysolution-es-utils": version "0.0.0" uid "" -"@kbn/securitysolution-io-ts-alerting-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types/npm_module": +"@kbn/securitysolution-io-ts-alerting-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types": version "0.0.0" uid "" -"@kbn/securitysolution-io-ts-list-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types/npm_module": +"@kbn/securitysolution-io-ts-list-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types": version "0.0.0" uid "" -"@kbn/securitysolution-io-ts-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-types/npm_module": +"@kbn/securitysolution-io-ts-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-types": version "0.0.0" uid "" -"@kbn/securitysolution-io-ts-utils@link:bazel-bin/packages/kbn-securitysolution-io-ts-utils/npm_module": +"@kbn/securitysolution-io-ts-utils@link:bazel-bin/packages/kbn-securitysolution-io-ts-utils": version "0.0.0" uid "" -"@kbn/securitysolution-list-api@link:bazel-bin/packages/kbn-securitysolution-list-api/npm_module": +"@kbn/securitysolution-list-api@link:bazel-bin/packages/kbn-securitysolution-list-api": version "0.0.0" uid "" -"@kbn/securitysolution-list-constants@link:bazel-bin/packages/kbn-securitysolution-list-constants/npm_module": +"@kbn/securitysolution-list-constants@link:bazel-bin/packages/kbn-securitysolution-list-constants": version "0.0.0" uid "" -"@kbn/securitysolution-list-hooks@link:bazel-bin/packages/kbn-securitysolution-list-hooks/npm_module": +"@kbn/securitysolution-list-hooks@link:bazel-bin/packages/kbn-securitysolution-list-hooks": version "0.0.0" uid "" -"@kbn/securitysolution-list-utils@link:bazel-bin/packages/kbn-securitysolution-list-utils/npm_module": +"@kbn/securitysolution-list-utils@link:bazel-bin/packages/kbn-securitysolution-list-utils": version "0.0.0" uid "" -"@kbn/securitysolution-utils@link:bazel-bin/packages/kbn-securitysolution-utils/npm_module": +"@kbn/securitysolution-utils@link:bazel-bin/packages/kbn-securitysolution-utils": version "0.0.0" uid "" -"@kbn/server-http-tools@link:bazel-bin/packages/kbn-server-http-tools/npm_module": +"@kbn/server-http-tools@link:bazel-bin/packages/kbn-server-http-tools": version "0.0.0" uid "" @@ -2755,7 +2755,7 @@ version "0.0.0" uid "" -"@kbn/std@link:bazel-bin/packages/kbn-std/npm_module": +"@kbn/std@link:bazel-bin/packages/kbn-std": version "0.0.0" uid "" @@ -2763,7 +2763,7 @@ version "0.0.0" uid "" -"@kbn/telemetry-tools@link:bazel-bin/packages/kbn-telemetry-tools/npm_module": +"@kbn/telemetry-tools@link:bazel-bin/packages/kbn-telemetry-tools": version "0.0.0" uid "" @@ -2775,7 +2775,7 @@ version "0.0.0" uid "" -"@kbn/tinymath@link:bazel-bin/packages/kbn-tinymath/npm_module": +"@kbn/tinymath@link:bazel-bin/packages/kbn-tinymath": version "0.0.0" uid "" @@ -2787,11 +2787,11 @@ version "0.0.0" uid "" -"@kbn/utility-types@link:bazel-bin/packages/kbn-utility-types/npm_module": +"@kbn/utility-types@link:bazel-bin/packages/kbn-utility-types": version "0.0.0" uid "" -"@kbn/utils@link:bazel-bin/packages/kbn-utils/npm_module": +"@kbn/utils@link:bazel-bin/packages/kbn-utils": version "0.0.0" uid "" From 843a81ea5182ae51824c7fb0420040248612ac30 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Thu, 3 Jun 2021 09:54:37 -0700 Subject: [PATCH 33/35] Splits migrationsv2 actions and unit tests into separate files (#101200) * Splits migrationsv2 actions and unit tests into separate files * Moves actions integration tests --- ...lk_overwrite_transformed_documents.test.ts | 46 + .../bulk_overwrite_transformed_documents.ts | 84 ++ .../migrationsv2/actions/clone_index.test.ts | 60 + .../migrationsv2/actions/clone_index.ts | 141 ++ .../migrationsv2/actions/close_pit.test.ts | 40 + .../migrationsv2/actions/close_pit.ts | 41 + .../migrationsv2/actions/constants.ts | 20 + .../migrationsv2/actions/create_index.test.ts | 59 + .../migrationsv2/actions/create_index.ts | 145 ++ .../actions/fetch_indices.test.ts | 37 + .../migrationsv2/actions/fetch_indices.ts | 49 + .../migrationsv2/actions/index.test.ts | 346 ----- .../migrationsv2/actions/index.ts | 1267 ++--------------- .../integration_tests/actions.test.ts | 14 +- .../migrationsv2/actions/open_pit.test.ts | 40 + .../migrationsv2/actions/open_pit.ts | 43 + .../actions/pickup_updated_mappings.test.ts | 39 + .../actions/pickup_updated_mappings.ts | 57 + .../actions/read_with_pit.test.ts | 45 + .../migrationsv2/actions/read_with_pit.ts | 92 ++ .../actions/refresh_index.test.ts | 42 + .../migrationsv2/actions/refresh_index.ts | 40 + .../migrationsv2/actions/reindex.test.ts | 48 + .../migrationsv2/actions/reindex.ts | 90 ++ .../actions/remove_write_block.test.ts | 53 + .../actions/remove_write_block.ts | 60 + .../search_for_outdated_documents.test.ts | 69 + .../actions/search_for_outdated_documents.ts | 77 + .../actions/set_write_block.test.ts | 52 + .../migrationsv2/actions/set_write_block.ts | 73 + .../migrationsv2/actions/transform_docs.ts | 30 + .../actions/update_aliases.test.ts | 55 + .../migrationsv2/actions/update_aliases.ts | 98 ++ .../update_and_pickup_mappings.test.ts | 45 + .../actions/update_and_pickup_mappings.ts | 80 ++ .../migrationsv2/actions/verify_reindex.ts | 52 + .../wait_for_index_status_yellow.test.ts | 44 + .../actions/wait_for_index_status_yellow.ts | 45 + ...t_for_pickup_updated_mappings_task.test.ts | 59 + .../wait_for_pickup_updated_mappings_task.ts | 43 + .../actions/wait_for_reindex_task.test.ts | 56 + .../actions/wait_for_reindex_task.ts | 65 + .../actions/wait_for_task.test.ts | 47 + .../migrationsv2/actions/wait_for_task.ts | 95 ++ 44 files changed, 2544 insertions(+), 1539 deletions(-) create mode 100644 src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/clone_index.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/clone_index.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/close_pit.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/close_pit.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/constants.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/create_index.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/create_index.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/fetch_indices.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/fetch_indices.ts delete mode 100644 src/core/server/saved_objects/migrationsv2/actions/index.test.ts rename src/core/server/saved_objects/migrationsv2/{ => actions}/integration_tests/actions.test.ts (99%) create mode 100644 src/core/server/saved_objects/migrationsv2/actions/open_pit.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/open_pit.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/read_with_pit.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/read_with_pit.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/refresh_index.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/refresh_index.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/reindex.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/reindex.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/remove_write_block.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/remove_write_block.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/set_write_block.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/set_write_block.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/transform_docs.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/update_aliases.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/update_aliases.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/verify_reindex.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_task.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_task.ts diff --git a/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts new file mode 100644 index 0000000000000..8ff9591798fd4 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { bulkOverwriteTransformedDocuments } from './bulk_overwrite_transformed_documents'; + +describe('bulkOverwriteTransformedDocuments', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = bulkOverwriteTransformedDocuments({ + client, + index: 'new_index', + transformedDocs: [], + refresh: 'wait_for', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts new file mode 100644 index 0000000000000..830a8efccc7eb --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import type { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import type { SavedObjectsRawDoc } from '../../serialization'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE } from './constants'; + +/** @internal */ +export interface BulkOverwriteTransformedDocumentsParams { + client: ElasticsearchClient; + index: string; + transformedDocs: SavedObjectsRawDoc[]; + refresh?: estypes.Refresh; +} +/** + * Write the up-to-date transformed documents to the index, overwriting any + * documents that are still on their outdated version. + */ +export const bulkOverwriteTransformedDocuments = ({ + client, + index, + transformedDocs, + refresh = false, +}: BulkOverwriteTransformedDocumentsParams): TaskEither.TaskEither< + RetryableEsClientError, + 'bulk_index_succeeded' +> => () => { + return client + .bulk({ + // Because we only add aliases in the MARK_VERSION_INDEX_READY step we + // can't bulkIndex to an alias with require_alias=true. This means if + // users tamper during this operation (delete indices or restore a + // snapshot), we could end up auto-creating an index without the correct + // mappings. Such tampering could lead to many other problems and is + // probably unlikely so for now we'll accept this risk and wait till + // system indices puts in place a hard control. + require_alias: false, + wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, + refresh, + filter_path: ['items.*.error'], + body: transformedDocs.flatMap((doc) => { + return [ + { + index: { + _index: index, + _id: doc._id, + // overwrite existing documents + op_type: 'index', + // use optimistic concurrency control to ensure that outdated + // documents are only overwritten once with the latest version + if_seq_no: doc._seq_no, + if_primary_term: doc._primary_term, + }, + }, + doc._source, + ]; + }), + }) + .then((res) => { + // Filter out version_conflict_engine_exception since these just mean + // that another instance already updated these documents + const errors = (res.body.items ?? []).filter( + (item) => item.index?.error?.type !== 'version_conflict_engine_exception' + ); + if (errors.length === 0) { + return Either.right('bulk_index_succeeded' as const); + } else { + throw new Error(JSON.stringify(errors)); + } + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/clone_index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/clone_index.test.ts new file mode 100644 index 0000000000000..84b4b00bc7e7f --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/clone_index.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { errors as EsErrors } from '@elastic/elasticsearch'; +import { cloneIndex } from './clone_index'; +import { setWriteBlock } from './set_write_block'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('cloneIndex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = cloneIndex({ + client, + source: 'my_source_index', + target: 'my_target_index', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/clone_index.ts b/src/core/server/saved_objects/migrationsv2/actions/clone_index.ts new file mode 100644 index 0000000000000..5674535c80328 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/clone_index.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import type { IndexNotFound, AcknowledgeResponse } from './'; +import { waitForIndexStatusYellow } from './wait_for_index_status_yellow'; +import { + DEFAULT_TIMEOUT, + INDEX_AUTO_EXPAND_REPLICAS, + INDEX_NUMBER_OF_SHARDS, + WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, +} from './constants'; +export type CloneIndexResponse = AcknowledgeResponse; + +/** @internal */ +export interface CloneIndexParams { + client: ElasticsearchClient; + source: string; + target: string; + /** only used for testing */ + timeout?: string; +} +/** + * Makes a clone of the source index into the target. + * + * @remarks + * This method adds some additional logic to the ES clone index API: + * - it is idempotent, if it gets called multiple times subsequent calls will + * wait for the first clone operation to complete (up to 60s) + * - the first call will wait up to 120s for the cluster state and all shards + * to be updated. + */ +export const cloneIndex = ({ + client, + source, + target, + timeout = DEFAULT_TIMEOUT, +}: CloneIndexParams): TaskEither.TaskEither< + RetryableEsClientError | IndexNotFound, + CloneIndexResponse +> => { + const cloneTask: TaskEither.TaskEither< + RetryableEsClientError | IndexNotFound, + AcknowledgeResponse + > = () => { + return client.indices + .clone( + { + index: source, + target, + wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, + body: { + settings: { + index: { + // The source we're cloning from will have a write block set, so + // we need to remove it to allow writes to our newly cloned index + 'blocks.write': false, + number_of_shards: INDEX_NUMBER_OF_SHARDS, + auto_expand_replicas: INDEX_AUTO_EXPAND_REPLICAS, + // Set an explicit refresh interval so that we don't inherit the + // value from incorrectly configured index templates (not required + // after we adopt system indices) + refresh_interval: '1s', + // Bump priority so that recovery happens before newer indices + priority: 10, + }, + }, + }, + timeout, + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + .then((res) => { + /** + * - acknowledged=false, we timed out before the cluster state was + * updated with the newly created index, but it probably will be + * created sometime soon. + * - shards_acknowledged=false, we timed out before all shards were + * started + * - acknowledged=true, shards_acknowledged=true, cloning complete + */ + return Either.right({ + acknowledged: res.body.acknowledged, + shardsAcknowledged: res.body.shards_acknowledged, + }); + }) + .catch((error: EsErrors.ResponseError) => { + if (error?.body?.error?.type === 'index_not_found_exception') { + return Either.left({ + type: 'index_not_found_exception' as const, + index: error.body.error.index, + }); + } else if (error?.body?.error?.type === 'resource_already_exists_exception') { + /** + * If the target index already exists it means a previous clone + * operation had already been started. However, we can't be sure + * that all shards were started so return shardsAcknowledged: false + */ + return Either.right({ + acknowledged: true, + shardsAcknowledged: false, + }); + } else { + throw error; + } + }) + .catch(catchRetryableEsClientErrors); + }; + + return pipe( + cloneTask, + TaskEither.chain((res) => { + if (res.acknowledged && res.shardsAcknowledged) { + // If the cluster state was updated and all shards ackd we're done + return TaskEither.right(res); + } else { + // Otherwise, wait until the target index has a 'green' status. + return pipe( + waitForIndexStatusYellow({ client, index: target, timeout }), + TaskEither.map((value) => { + /** When the index status is 'green' we know that all shards were started */ + return { acknowledged: true, shardsAcknowledged: true }; + }) + ); + } + }) + ); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/close_pit.test.ts b/src/core/server/saved_objects/migrationsv2/actions/close_pit.test.ts new file mode 100644 index 0000000000000..5d9696239a61e --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/close_pit.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { closePit } from './close_pit'; + +describe('closePit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = closePit({ client, pitId: 'pitId' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/close_pit.ts b/src/core/server/saved_objects/migrationsv2/actions/close_pit.ts new file mode 100644 index 0000000000000..d421950c839e2 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/close_pit.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface ClosePitParams { + client: ElasticsearchClient; + pitId: string; +} +/* + * Closes PIT. + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * */ +export const closePit = ({ + client, + pitId, +}: ClosePitParams): TaskEither.TaskEither => () => { + return client + .closePointInTime({ + body: { id: pitId }, + }) + .then((response) => { + if (!response.body.succeeded) { + throw new Error(`Failed to close PointInTime with id: ${pitId}`); + } + return Either.right({}); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/constants.ts b/src/core/server/saved_objects/migrationsv2/actions/constants.ts new file mode 100644 index 0000000000000..5d0d2ffe5d695 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/constants.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +/** + * Batch size for updateByQuery and reindex operations. + * Uses the default value of 1000 for Elasticsearch reindex operation. + */ +export const BATCH_SIZE = 1_000; +export const DEFAULT_TIMEOUT = '60s'; +/** Allocate 1 replica if there are enough data nodes, otherwise continue with 0 */ +export const INDEX_AUTO_EXPAND_REPLICAS = '0-1'; +/** ES rule of thumb: shards should be several GB to 10's of GB, so Kibana is unlikely to cross that limit */ +export const INDEX_NUMBER_OF_SHARDS = 1; +/** Wait for all shards to be active before starting an operation */ +export const WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE = 'all'; diff --git a/src/core/server/saved_objects/migrationsv2/actions/create_index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/create_index.test.ts new file mode 100644 index 0000000000000..d5d906898943c --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/create_index.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { createIndex } from './create_index'; +import { setWriteBlock } from './set_write_block'; + +describe('createIndex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = createIndex({ + client, + indexName: 'new_index', + mappings: { properties: {} }, + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/create_index.ts b/src/core/server/saved_objects/migrationsv2/actions/create_index.ts new file mode 100644 index 0000000000000..47ee44e762db7 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/create_index.ts @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { pipe } from 'fp-ts/lib/pipeable'; +import type { estypes } from '@elastic/elasticsearch'; +import { AcknowledgeResponse } from './index'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { IndexMapping } from '../../mappings'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { + DEFAULT_TIMEOUT, + INDEX_AUTO_EXPAND_REPLICAS, + WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, +} from './constants'; +import { waitForIndexStatusYellow } from './wait_for_index_status_yellow'; + +function aliasArrayToRecord(aliases: string[]): Record { + const result: Record = {}; + for (const alias of aliases) { + result[alias] = {}; + } + return result; +} + +/** @internal */ +export interface CreateIndexParams { + client: ElasticsearchClient; + indexName: string; + mappings: IndexMapping; + aliases?: string[]; +} +/** + * Creates an index with the given mappings + * + * @remarks + * This method adds some additional logic to the ES create index API: + * - it is idempotent, if it gets called multiple times subsequent calls will + * wait for the first create operation to complete (up to 60s) + * - the first call will wait up to 120s for the cluster state and all shards + * to be updated. + */ +export const createIndex = ({ + client, + indexName, + mappings, + aliases = [], +}: CreateIndexParams): TaskEither.TaskEither => { + const createIndexTask: TaskEither.TaskEither< + RetryableEsClientError, + AcknowledgeResponse + > = () => { + const aliasesObject = aliasArrayToRecord(aliases); + + return client.indices + .create( + { + index: indexName, + // wait until all shards are available before creating the index + // (since number_of_shards=1 this does not have any effect atm) + wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, + // Wait up to 60s for the cluster state to update and all shards to be + // started + timeout: DEFAULT_TIMEOUT, + body: { + mappings, + aliases: aliasesObject, + settings: { + index: { + // ES rule of thumb: shards should be several GB to 10's of GB, so + // Kibana is unlikely to cross that limit. + number_of_shards: 1, + auto_expand_replicas: INDEX_AUTO_EXPAND_REPLICAS, + // Set an explicit refresh interval so that we don't inherit the + // value from incorrectly configured index templates (not required + // after we adopt system indices) + refresh_interval: '1s', + // Bump priority so that recovery happens before newer indices + priority: 10, + }, + }, + }, + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + .then((res) => { + /** + * - acknowledged=false, we timed out before the cluster state was + * updated on all nodes with the newly created index, but it + * probably will be created sometime soon. + * - shards_acknowledged=false, we timed out before all shards were + * started + * - acknowledged=true, shards_acknowledged=true, index creation complete + */ + return Either.right({ + acknowledged: res.body.acknowledged, + shardsAcknowledged: res.body.shards_acknowledged, + }); + }) + .catch((error) => { + if (error?.body?.error?.type === 'resource_already_exists_exception') { + /** + * If the target index already exists it means a previous create + * operation had already been started. However, we can't be sure + * that all shards were started so return shardsAcknowledged: false + */ + return Either.right({ + acknowledged: true, + shardsAcknowledged: false, + }); + } else { + throw error; + } + }) + .catch(catchRetryableEsClientErrors); + }; + + return pipe( + createIndexTask, + TaskEither.chain((res) => { + if (res.acknowledged && res.shardsAcknowledged) { + // If the cluster state was updated and all shards ackd we're done + return TaskEither.right('create_index_succeeded'); + } else { + // Otherwise, wait until the target index has a 'yellow' status. + return pipe( + waitForIndexStatusYellow({ client, index: indexName, timeout: DEFAULT_TIMEOUT }), + TaskEither.map(() => { + /** When the index status is 'yellow' we know that all shards were started */ + return 'create_index_succeeded'; + }) + ); + } + }) + ); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.test.ts b/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.test.ts new file mode 100644 index 0000000000000..0dab1728b6ef2 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { fetchIndices } from './fetch_indices'; + +describe('fetchIndices', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = fetchIndices({ client, indices: ['my_index'] }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.ts b/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.ts new file mode 100644 index 0000000000000..3847252eb6db1 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Either from 'fp-ts/lib/Either'; +import { IndexMapping } from '../../mappings'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +export type FetchIndexResponse = Record< + string, + { aliases: Record; mappings: IndexMapping; settings: unknown } +>; + +/** @internal */ +export interface FetchIndicesParams { + client: ElasticsearchClient; + indices: string[]; +} + +/** + * Fetches information about the given indices including aliases, mappings and + * settings. + */ +export const fetchIndices = ({ + client, + indices, +}: FetchIndicesParams): TaskEither.TaskEither => + // @ts-expect-error @elastic/elasticsearch IndexState.alias and IndexState.mappings should be required + () => { + return client.indices + .get( + { + index: indices, + ignore_unavailable: true, // Don't return an error for missing indices. Note this *will* include closed indices, the docs are misleading https://github.com/elastic/elasticsearch/issues/63607 + }, + { ignore: [404], maxRetries: 0 } + ) + .then(({ body }) => { + return Either.right(body); + }) + .catch(catchRetryableEsClientErrors); + }; diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/index.test.ts deleted file mode 100644 index 05da335d70884..0000000000000 --- a/src/core/server/saved_objects/migrationsv2/actions/index.test.ts +++ /dev/null @@ -1,346 +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 * as Actions from './'; -import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; -import { errors as EsErrors } from '@elastic/elasticsearch'; -jest.mock('./catch_retryable_es_client_errors'); -import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; -import * as Option from 'fp-ts/lib/Option'; - -describe('actions', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - // Create a mock client that rejects all methods with a 503 status code - // response. - const retryableError = new EsErrors.ResponseError( - elasticsearchClientMock.createApiResponse({ - statusCode: 503, - body: { error: { type: 'es_type', reason: 'es_reason' } }, - }) - ); - const client = elasticsearchClientMock.createInternalClient( - elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) - ); - - const nonRetryableError = new Error('crash'); - const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( - elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) - ); - - describe('fetchIndices', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.fetchIndices({ client, indices: ['my_index'] }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('setWriteBlock', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.setWriteBlock({ client, index: 'my_index' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('cloneIndex', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.cloneIndex({ - client, - source: 'my_source_index', - target: 'my_target_index', - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('pickupUpdatedMappings', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.pickupUpdatedMappings(client, 'my_index'); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('openPit', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.openPit({ client, index: 'my_index' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('readWithPit', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.readWithPit({ - client, - pitId: 'pitId', - query: { match_all: {} }, - batchSize: 10_000, - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('closePit', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.closePit({ client, pitId: 'pitId' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('reindex', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.reindex({ - client, - sourceIndex: 'my_source_index', - targetIndex: 'my_target_index', - reindexScript: Option.none, - requireAlias: false, - unusedTypesQuery: {}, - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('waitForReindexTask', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.waitForReindexTask({ client, taskId: 'my task id', timeout: '60s' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('waitForPickupUpdatedMappingsTask', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.waitForPickupUpdatedMappingsTask({ - client, - taskId: 'my task id', - timeout: '60s', - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('updateAliases', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.updateAliases({ client, aliasActions: [] }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('createIndex', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.createIndex({ - client, - indexName: 'new_index', - mappings: { properties: {} }, - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('updateAndPickupMappings', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.updateAndPickupMappings({ - client, - index: 'new_index', - mappings: { properties: {} }, - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('searchForOutdatedDocuments', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.searchForOutdatedDocuments(client, { - batchSize: 1000, - targetIndex: 'new_index', - outdatedDocumentsQuery: {}, - }); - - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - - it('configures request according to given parameters', async () => { - const esClient = elasticsearchClientMock.createInternalClient(); - const query = {}; - const targetIndex = 'new_index'; - const batchSize = 1000; - const task = Actions.searchForOutdatedDocuments(esClient, { - batchSize, - targetIndex, - outdatedDocumentsQuery: query, - }); - - await task(); - - expect(esClient.search).toHaveBeenCalledTimes(1); - expect(esClient.search).toHaveBeenCalledWith( - expect.objectContaining({ - index: targetIndex, - size: batchSize, - body: expect.objectContaining({ query }), - }) - ); - }); - }); - - describe('bulkOverwriteTransformedDocuments', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.bulkOverwriteTransformedDocuments({ - client, - index: 'new_index', - transformedDocs: [], - refresh: 'wait_for', - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('refreshIndex', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.refreshIndex({ client, targetIndex: 'target_index' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); -}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.ts b/src/core/server/saved_objects/migrationsv2/actions/index.ts index 905d64947298e..98d7167ffc31a 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/index.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/index.ts @@ -6,1231 +6,126 @@ * Side Public License, v 1. */ -import * as Either from 'fp-ts/lib/Either'; -import * as TaskEither from 'fp-ts/lib/TaskEither'; -import * as Option from 'fp-ts/lib/Option'; -import type { estypes } from '@elastic/elasticsearch'; -import { errors as EsErrors } from '@elastic/elasticsearch'; -import type { ElasticsearchClientError, ResponseError } from '@elastic/elasticsearch/lib/errors'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { flow } from 'fp-ts/lib/function'; -import { ElasticsearchClient } from '../../../elasticsearch'; -import { IndexMapping } from '../../mappings'; -import type { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; -import type { TransformRawDocs } from '../types'; -import { - catchRetryableEsClientErrors, - RetryableEsClientError, -} from './catch_retryable_es_client_errors'; -import { - DocumentsTransformFailed, - DocumentsTransformSuccess, -} from '../../migrations/core/migrate_raw_docs'; -export type { RetryableEsClientError }; - -/** - * Batch size for updateByQuery and reindex operations. - * Uses the default value of 1000 for Elasticsearch reindex operation. - */ -const BATCH_SIZE = 1_000; -const DEFAULT_TIMEOUT = '60s'; -/** Allocate 1 replica if there are enough data nodes, otherwise continue with 0 */ -const INDEX_AUTO_EXPAND_REPLICAS = '0-1'; -/** ES rule of thumb: shards should be several GB to 10's of GB, so Kibana is unlikely to cross that limit */ -const INDEX_NUMBER_OF_SHARDS = 1; -/** Wait for all shards to be active before starting an operation */ -const WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE = 'all'; - -// Map of left response 'type' string -> response interface -export interface ActionErrorTypeMap { - wait_for_task_completion_timeout: WaitForTaskCompletionTimeout; - retryable_es_client_error: RetryableEsClientError; - index_not_found_exception: IndexNotFound; - target_index_had_write_block: TargetIndexHadWriteBlock; - incompatible_mapping_exception: IncompatibleMappingException; - alias_not_found_exception: AliasNotFound; - remove_index_not_a_concrete_index: RemoveIndexNotAConcreteIndex; - documents_transform_failed: DocumentsTransformFailed; -} - -/** - * Type guard for narrowing the type of a left - */ -export function isLeftTypeof( - res: any, - typeString: T -): res is ActionErrorTypeMap[T] { - return res.type === typeString; -} - -export type FetchIndexResponse = Record< - string, - { aliases: Record; mappings: IndexMapping; settings: unknown } ->; +import { RetryableEsClientError } from './catch_retryable_es_client_errors'; +import { DocumentsTransformFailed } from '../../migrations/core/migrate_raw_docs'; -/** @internal */ -export interface FetchIndicesParams { - client: ElasticsearchClient; - indices: string[]; -} - -/** - * Fetches information about the given indices including aliases, mappings and - * settings. - */ -export const fetchIndices = ({ - client, - indices, -}: FetchIndicesParams): TaskEither.TaskEither => - // @ts-expect-error @elastic/elasticsearch IndexState.alias and IndexState.mappings should be required - () => { - return client.indices - .get( - { - index: indices, - ignore_unavailable: true, // Don't return an error for missing indices. Note this *will* include closed indices, the docs are misleading https://github.com/elastic/elasticsearch/issues/63607 - }, - { ignore: [404], maxRetries: 0 } - ) - .then(({ body }) => { - return Either.right(body); - }) - .catch(catchRetryableEsClientErrors); - }; +export { + BATCH_SIZE, + DEFAULT_TIMEOUT, + INDEX_AUTO_EXPAND_REPLICAS, + INDEX_NUMBER_OF_SHARDS, + WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, +} from './constants'; -export interface IndexNotFound { - type: 'index_not_found_exception'; - index: string; -} +export type { RetryableEsClientError }; -/** @internal */ -export interface SetWriteBlockParams { - client: ElasticsearchClient; - index: string; -} -/** - * Sets a write block in place for the given index. If the response includes - * `acknowledged: true` all in-progress writes have drained and no further - * writes to this index will be possible. - * - * The first time the write block is added to an index the response will - * include `shards_acknowledged: true` but once the block is in place, - * subsequent calls return `shards_acknowledged: false` - */ -export const setWriteBlock = ({ - client, - index, -}: SetWriteBlockParams): TaskEither.TaskEither< - IndexNotFound | RetryableEsClientError, - 'set_write_block_succeeded' -> => () => { - return ( - client.indices - .addBlock<{ - acknowledged: boolean; - shards_acknowledged: boolean; - }>( - { - index, - block: 'write', - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ) - // not typed yet - .then((res: any) => { - return res.body.acknowledged === true - ? Either.right('set_write_block_succeeded' as const) - : Either.left({ - type: 'retryable_es_client_error' as const, - message: 'set_write_block_failed', - }); - }) - .catch((e: ElasticsearchClientError) => { - if (e instanceof EsErrors.ResponseError) { - if (e.body?.error?.type === 'index_not_found_exception') { - return Either.left({ type: 'index_not_found_exception' as const, index }); - } - } - throw e; - }) - .catch(catchRetryableEsClientErrors) - ); -}; +// actions/* imports +export type { FetchIndexResponse, FetchIndicesParams } from './fetch_indices'; +export { fetchIndices } from './fetch_indices'; -/** @internal */ -export interface RemoveWriteBlockParams { - client: ElasticsearchClient; - index: string; -} -/** - * Removes a write block from an index - */ -export const removeWriteBlock = ({ - client, - index, -}: RemoveWriteBlockParams): TaskEither.TaskEither< - RetryableEsClientError, - 'remove_write_block_succeeded' -> => () => { - return client.indices - .putSettings<{ - acknowledged: boolean; - shards_acknowledged: boolean; - }>( - { - index, - // Don't change any existing settings - preserve_existing: true, - body: { - index: { - blocks: { - write: false, - }, - }, - }, - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ) - .then((res) => { - return res.body.acknowledged === true - ? Either.right('remove_write_block_succeeded' as const) - : Either.left({ - type: 'retryable_es_client_error' as const, - message: 'remove_write_block_failed', - }); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { SetWriteBlockParams } from './set_write_block'; +export { setWriteBlock } from './set_write_block'; -/** @internal */ -export interface WaitForIndexStatusYellowParams { - client: ElasticsearchClient; - index: string; - timeout?: string; -} -/** - * A yellow index status means the index's primary shard is allocated and the - * index is ready for searching/indexing documents, but ES wasn't able to - * allocate the replicas. When migrations proceed with a yellow index it means - * we don't have as much data-redundancy as we could have, but waiting for - * replicas would mean that v2 migrations fail where v1 migrations would have - * succeeded. It doesn't feel like it's Kibana's job to force users to keep - * their clusters green and even if it's green when we migrate it can turn - * yellow at any point in the future. So ultimately data-redundancy is up to - * users to maintain. - */ -export const waitForIndexStatusYellow = ({ - client, - index, - timeout = DEFAULT_TIMEOUT, -}: WaitForIndexStatusYellowParams): TaskEither.TaskEither => () => { - return client.cluster - .health({ index, wait_for_status: 'yellow', timeout }) - .then(() => { - return Either.right({}); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { RemoveWriteBlockParams } from './remove_write_block'; +export { removeWriteBlock } from './remove_write_block'; -export type CloneIndexResponse = AcknowledgeResponse; +export type { CloneIndexResponse, CloneIndexParams } from './clone_index'; +export { cloneIndex } from './clone_index'; -/** @internal */ -export interface CloneIndexParams { - client: ElasticsearchClient; - source: string; - target: string; - /** only used for testing */ - timeout?: string; -} -/** - * Makes a clone of the source index into the target. - * - * @remarks - * This method adds some additional logic to the ES clone index API: - * - it is idempotent, if it gets called multiple times subsequent calls will - * wait for the first clone operation to complete (up to 60s) - * - the first call will wait up to 120s for the cluster state and all shards - * to be updated. - */ -export const cloneIndex = ({ - client, - source, - target, - timeout = DEFAULT_TIMEOUT, -}: CloneIndexParams): TaskEither.TaskEither< - RetryableEsClientError | IndexNotFound, - CloneIndexResponse -> => { - const cloneTask: TaskEither.TaskEither< - RetryableEsClientError | IndexNotFound, - AcknowledgeResponse - > = () => { - return client.indices - .clone( - { - index: source, - target, - wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, - body: { - settings: { - index: { - // The source we're cloning from will have a write block set, so - // we need to remove it to allow writes to our newly cloned index - 'blocks.write': false, - number_of_shards: INDEX_NUMBER_OF_SHARDS, - auto_expand_replicas: INDEX_AUTO_EXPAND_REPLICAS, - // Set an explicit refresh interval so that we don't inherit the - // value from incorrectly configured index templates (not required - // after we adopt system indices) - refresh_interval: '1s', - // Bump priority so that recovery happens before newer indices - priority: 10, - }, - }, - }, - timeout, - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ) - .then((res) => { - /** - * - acknowledged=false, we timed out before the cluster state was - * updated with the newly created index, but it probably will be - * created sometime soon. - * - shards_acknowledged=false, we timed out before all shards were - * started - * - acknowledged=true, shards_acknowledged=true, cloning complete - */ - return Either.right({ - acknowledged: res.body.acknowledged, - shardsAcknowledged: res.body.shards_acknowledged, - }); - }) - .catch((error: EsErrors.ResponseError) => { - if (error?.body?.error?.type === 'index_not_found_exception') { - return Either.left({ - type: 'index_not_found_exception' as const, - index: error.body.error.index, - }); - } else if (error?.body?.error?.type === 'resource_already_exists_exception') { - /** - * If the target index already exists it means a previous clone - * operation had already been started. However, we can't be sure - * that all shards were started so return shardsAcknowledged: false - */ - return Either.right({ - acknowledged: true, - shardsAcknowledged: false, - }); - } else { - throw error; - } - }) - .catch(catchRetryableEsClientErrors); - }; +export type { WaitForIndexStatusYellowParams } from './wait_for_index_status_yellow'; +import { waitForIndexStatusYellow } from './wait_for_index_status_yellow'; - return pipe( - cloneTask, - TaskEither.chain((res) => { - if (res.acknowledged && res.shardsAcknowledged) { - // If the cluster state was updated and all shards ackd we're done - return TaskEither.right(res); - } else { - // Otherwise, wait until the target index has a 'green' status. - return pipe( - waitForIndexStatusYellow({ client, index: target, timeout }), - TaskEither.map((value) => { - /** When the index status is 'green' we know that all shards were started */ - return { acknowledged: true, shardsAcknowledged: true }; - }) - ); - } - }) - ); -}; +export type { WaitForTaskResponse, WaitForTaskCompletionTimeout } from './wait_for_task'; +import { waitForTask, WaitForTaskCompletionTimeout } from './wait_for_task'; -interface WaitForTaskResponse { - error: Option.Option<{ type: string; reason: string; index: string }>; - completed: boolean; - failures: Option.Option; - description?: string; -} +export type { UpdateByQueryResponse } from './pickup_updated_mappings'; +import { pickupUpdatedMappings } from './pickup_updated_mappings'; -/** - * After waiting for the specificed timeout, the task has not yet completed. - * - * When querying the tasks API we use `wait_for_completion=true` to block the - * request until the task completes. If after the `timeout`, the task still has - * not completed we return this error. This does not mean that the task itelf - * has reached a timeout, Elasticsearch will continue to run the task. - */ -export interface WaitForTaskCompletionTimeout { - /** After waiting for the specificed timeout, the task has not yet completed. */ - readonly type: 'wait_for_task_completion_timeout'; - readonly message: string; - readonly error?: Error; -} +export type { OpenPitResponse, OpenPitParams } from './open_pit'; +export { openPit, pitKeepAlive } from './open_pit'; -const catchWaitForTaskCompletionTimeout = ( - e: ResponseError -): Either.Either => { - if ( - e.body?.error?.type === 'timeout_exception' || - e.body?.error?.type === 'receive_timeout_transport_exception' - ) { - return Either.left({ - type: 'wait_for_task_completion_timeout' as const, - message: `[${e.body.error.type}] ${e.body.error.reason}`, - error: e, - }); - } else { - throw e; - } -}; +export type { ReadWithPit, ReadWithPitParams } from './read_with_pit'; +export { readWithPit } from './read_with_pit'; -/** @internal */ -export interface WaitForTaskParams { - client: ElasticsearchClient; - taskId: string; - timeout: string; -} -/** - * Blocks for up to 60s or until a task completes. - * - * TODO: delete completed tasks - */ -const waitForTask = ({ - client, - taskId, - timeout, -}: WaitForTaskParams): TaskEither.TaskEither< - RetryableEsClientError | WaitForTaskCompletionTimeout, - WaitForTaskResponse -> => () => { - return client.tasks - .get({ - task_id: taskId, - wait_for_completion: true, - timeout, - }) - .then((res) => { - const body = res.body; - const failures = body.response?.failures ?? []; - return Either.right({ - completed: body.completed, - // @ts-expect-error @elastic/elasticsearch GetTaskResponse doesn't declare `error` property - error: Option.fromNullable(body.error), - failures: failures.length > 0 ? Option.some(failures) : Option.none, - description: body.task.description, - }); - }) - .catch(catchWaitForTaskCompletionTimeout) - .catch(catchRetryableEsClientErrors); -}; +export type { ClosePitParams } from './close_pit'; +export { closePit } from './close_pit'; -export interface UpdateByQueryResponse { - taskId: string; -} +export type { TransformDocsParams } from './transform_docs'; +export { transformDocs } from './transform_docs'; -/** - * Pickup updated mappings by performing an update by query operation on all - * documents in the index. Returns a task ID which can be - * tracked for progress. - * - * @remarks When mappings are updated to add a field which previously wasn't - * mapped Elasticsearch won't automatically add existing documents to it's - * internal search indices. So search results on this field won't return any - * existing documents. By running an update by query we essentially refresh - * these the internal search indices for all existing documents. - * This action uses `conflicts: 'proceed'` allowing several Kibana instances - * to run this in parallel. - */ -export const pickupUpdatedMappings = ( - client: ElasticsearchClient, - index: string -): TaskEither.TaskEither => () => { - return client - .updateByQuery({ - // Ignore version conflicts that can occur from parallel update by query operations - conflicts: 'proceed', - // Return an error when targeting missing or closed indices - allow_no_indices: false, - index, - // How many documents to update per batch - scroll_size: BATCH_SIZE, - // force a refresh so that we can query the updated index immediately - // after the operation completes - refresh: true, - // Create a task and return task id instead of blocking until complete - wait_for_completion: false, - }) - .then(({ body: { task: taskId } }) => { - return Either.right({ taskId: String(taskId!) }); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { RefreshIndexParams } from './refresh_index'; +export { refreshIndex } from './refresh_index'; -/** @internal */ -export interface OpenPitResponse { - pitId: string; -} +export type { ReindexResponse, ReindexParams } from './reindex'; +export { reindex } from './reindex'; -/** @internal */ -export interface OpenPitParams { - client: ElasticsearchClient; - index: string; -} -// how long ES should keep PIT alive -const pitKeepAlive = '10m'; -/* - * Creates a lightweight view of data when the request has been initiated. - * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html - * */ -export const openPit = ({ - client, - index, -}: OpenPitParams): TaskEither.TaskEither => () => { - return client - .openPointInTime({ - index, - keep_alive: pitKeepAlive, - }) - .then((response) => Either.right({ pitId: response.body.id })) - .catch(catchRetryableEsClientErrors); -}; +import type { IncompatibleMappingException } from './wait_for_reindex_task'; +export { waitForReindexTask } from './wait_for_reindex_task'; -/** @internal */ -export interface ReadWithPit { - outdatedDocuments: SavedObjectsRawDoc[]; - readonly lastHitSortValue: number[] | undefined; - readonly totalHits: number | undefined; -} +export type { VerifyReindexParams } from './verify_reindex'; +export { verifyReindex } from './verify_reindex'; -/** @internal */ +import type { AliasNotFound, RemoveIndexNotAConcreteIndex } from './update_aliases'; +export type { AliasAction, UpdateAliasesParams } from './update_aliases'; +export { updateAliases } from './update_aliases'; -export interface ReadWithPitParams { - client: ElasticsearchClient; - pitId: string; - query: estypes.QueryContainer; - batchSize: number; - searchAfter?: number[]; - seqNoPrimaryTerm?: boolean; -} +export type { CreateIndexParams } from './create_index'; +export { createIndex } from './create_index'; -/* - * Requests documents from the index using PIT mechanism. - * */ -export const readWithPit = ({ - client, - pitId, - query, - batchSize, - searchAfter, - seqNoPrimaryTerm, -}: ReadWithPitParams): TaskEither.TaskEither => () => { - return client - .search({ - seq_no_primary_term: seqNoPrimaryTerm, - body: { - // Sort fields are required to use searchAfter - sort: { - // the most efficient option as order is not important for the migration - _shard_doc: { order: 'asc' }, - }, - pit: { id: pitId, keep_alive: pitKeepAlive }, - size: batchSize, - search_after: searchAfter, - /** - * We want to know how many documents we need to process so we can log the progress. - * But we also want to increase the performance of these requests, - * so we ask ES to report the total count only on the first request (when searchAfter does not exist) - */ - track_total_hits: typeof searchAfter === 'undefined', - query, - }, - }) - .then((response) => { - const totalHits = - typeof response.body.hits.total === 'number' - ? response.body.hits.total // This format is to be removed in 8.0 - : response.body.hits.total?.value; - const hits = response.body.hits.hits; +export type { + UpdateAndPickupMappingsResponse, + UpdateAndPickupMappingsParams, +} from './update_and_pickup_mappings'; +export { updateAndPickupMappings } from './update_and_pickup_mappings'; - if (hits.length > 0) { - return Either.right({ - // @ts-expect-error @elastic/elasticsearch _source is optional - outdatedDocuments: hits as SavedObjectsRawDoc[], - lastHitSortValue: hits[hits.length - 1].sort as number[], - totalHits, - }); - } +export { waitForPickupUpdatedMappingsTask } from './wait_for_pickup_updated_mappings_task'; - return Either.right({ - outdatedDocuments: [], - lastHitSortValue: undefined, - totalHits, - }); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { + SearchResponse, + SearchForOutdatedDocumentsOptions, +} from './search_for_outdated_documents'; +export { searchForOutdatedDocuments } from './search_for_outdated_documents'; -/** @internal */ -export interface ClosePitParams { - client: ElasticsearchClient; - pitId: string; -} -/* - * Closes PIT. - * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html - * */ -export const closePit = ({ - client, - pitId, -}: ClosePitParams): TaskEither.TaskEither => () => { - return client - .closePointInTime({ - body: { id: pitId }, - }) - .then((response) => { - if (!response.body.succeeded) { - throw new Error(`Failed to close PointInTime with id: ${pitId}`); - } - return Either.right({}); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { BulkOverwriteTransformedDocumentsParams } from './bulk_overwrite_transformed_documents'; +export { bulkOverwriteTransformedDocuments } from './bulk_overwrite_transformed_documents'; -/** @internal */ -export interface TransformDocsParams { - transformRawDocs: TransformRawDocs; - outdatedDocuments: SavedObjectsRawDoc[]; -} -/* - * Transform outdated docs - * */ -export const transformDocs = ({ - transformRawDocs, - outdatedDocuments, -}: TransformDocsParams): TaskEither.TaskEither< - DocumentsTransformFailed, - DocumentsTransformSuccess -> => transformRawDocs(outdatedDocuments); +export { pickupUpdatedMappings, waitForTask, waitForIndexStatusYellow }; +export type { AliasNotFound, RemoveIndexNotAConcreteIndex }; -/** @internal */ -export interface ReindexResponse { - taskId: string; -} - -/** @internal */ -export interface RefreshIndexParams { - client: ElasticsearchClient; - targetIndex: string; -} -/** - * Wait for Elasticsearch to reindex all the changes. - */ -export const refreshIndex = ({ - client, - targetIndex, -}: RefreshIndexParams): TaskEither.TaskEither< - RetryableEsClientError, - { refreshed: boolean } -> => () => { - return client.indices - .refresh({ - index: targetIndex, - }) - .then(() => { - return Either.right({ refreshed: true }); - }) - .catch(catchRetryableEsClientErrors); -}; -/** @internal */ -export interface ReindexParams { - client: ElasticsearchClient; - sourceIndex: string; - targetIndex: string; - reindexScript: Option.Option; - requireAlias: boolean; - /* When reindexing we use a source query to exclude saved objects types which - * are no longer used. These saved objects will still be kept in the outdated - * index for backup purposes, but won't be available in the upgraded index. - */ - unusedTypesQuery: estypes.QueryContainer; +export interface IndexNotFound { + type: 'index_not_found_exception'; + index: string; } -/** - * Reindex documents from the `sourceIndex` into the `targetIndex`. Returns a - * task ID which can be tracked for progress. - * - * @remarks This action is idempotent allowing several Kibana instances to run - * this in parallel. By using `op_type: 'create', conflicts: 'proceed'` there - * will be only one write per reindexed document. - */ -export const reindex = ({ - client, - sourceIndex, - targetIndex, - reindexScript, - requireAlias, - unusedTypesQuery, -}: ReindexParams): TaskEither.TaskEither => () => { - return client - .reindex({ - // Require targetIndex to be an alias. Prevents a new index from being - // created if targetIndex doesn't exist. - require_alias: requireAlias, - body: { - // Ignore version conflicts from existing documents - conflicts: 'proceed', - source: { - index: sourceIndex, - // Set reindex batch size - size: BATCH_SIZE, - // Exclude saved object types - query: unusedTypesQuery, - }, - dest: { - index: targetIndex, - // Don't override existing documents, only create if missing - op_type: 'create', - }, - script: Option.fold( - () => undefined, - (script) => ({ - source: script, - lang: 'painless', - }) - )(reindexScript), - }, - // force a refresh so that we can query the target index - refresh: true, - // Create a task and return task id instead of blocking until complete - wait_for_completion: false, - }) - .then(({ body: { task: taskId } }) => { - return Either.right({ taskId: String(taskId) }); - }) - .catch(catchRetryableEsClientErrors); -}; - -interface WaitForReindexTaskFailure { +export interface WaitForReindexTaskFailure { readonly cause: { type: string; reason: string }; } - -/** @internal */ export interface TargetIndexHadWriteBlock { type: 'target_index_had_write_block'; } -/** @internal */ -export interface IncompatibleMappingException { - type: 'incompatible_mapping_exception'; -} - -export const waitForReindexTask = flow( - waitForTask, - TaskEither.chain( - ( - res - ): TaskEither.TaskEither< - | IndexNotFound - | TargetIndexHadWriteBlock - | IncompatibleMappingException - | RetryableEsClientError - | WaitForTaskCompletionTimeout, - 'reindex_succeeded' - > => { - const failureIsAWriteBlock = ({ cause: { type, reason } }: WaitForReindexTaskFailure) => - type === 'cluster_block_exception' && - reason.match(/index \[.+] blocked by: \[FORBIDDEN\/8\/index write \(api\)\]/); - - const failureIsIncompatibleMappingException = ({ - cause: { type, reason }, - }: WaitForReindexTaskFailure) => - type === 'strict_dynamic_mapping_exception' || type === 'mapper_parsing_exception'; - - if (Option.isSome(res.error)) { - if (res.error.value.type === 'index_not_found_exception') { - return TaskEither.left({ - type: 'index_not_found_exception' as const, - index: res.error.value.index, - }); - } else { - throw new Error('Reindex failed with the following error:\n' + JSON.stringify(res.error)); - } - } else if (Option.isSome(res.failures)) { - if (res.failures.value.every(failureIsAWriteBlock)) { - return TaskEither.left({ type: 'target_index_had_write_block' as const }); - } else if (res.failures.value.every(failureIsIncompatibleMappingException)) { - return TaskEither.left({ type: 'incompatible_mapping_exception' as const }); - } else { - throw new Error( - 'Reindex failed with the following failures:\n' + JSON.stringify(res.failures.value) - ); - } - } else { - return TaskEither.right('reindex_succeeded' as const); - } - } - ) -); - -/** @internal */ -export interface VerifyReindexParams { - client: ElasticsearchClient; - sourceIndex: string; - targetIndex: string; -} - -export const verifyReindex = ({ - client, - sourceIndex, - targetIndex, -}: VerifyReindexParams): TaskEither.TaskEither< - RetryableEsClientError | { type: 'verify_reindex_failed' }, - 'verify_reindex_succeeded' -> => () => { - const count = (index: string) => - client - .count<{ count: number }>({ - index, - // Return an error when targeting missing or closed indices - allow_no_indices: false, - }) - .then((res) => { - return res.body.count; - }); - - return Promise.all([count(sourceIndex), count(targetIndex)]) - .then(([sourceCount, targetCount]) => { - if (targetCount >= sourceCount) { - return Either.right('verify_reindex_succeeded' as const); - } else { - return Either.left({ type: 'verify_reindex_failed' as const }); - } - }) - .catch(catchRetryableEsClientErrors); -}; - -export const waitForPickupUpdatedMappingsTask = flow( - waitForTask, - TaskEither.chain( - ( - res - ): TaskEither.TaskEither< - RetryableEsClientError | WaitForTaskCompletionTimeout, - 'pickup_updated_mappings_succeeded' - > => { - // We don't catch or type failures/errors because they should never - // occur in our migration algorithm and we don't have any business logic - // for dealing with it. If something happens we'll just crash and try - // again. - if (Option.isSome(res.failures)) { - throw new Error( - 'pickupUpdatedMappings task failed with the following failures:\n' + - JSON.stringify(res.failures.value) - ); - } else if (Option.isSome(res.error)) { - throw new Error( - 'pickupUpdatedMappings task failed with the following error:\n' + - JSON.stringify(res.error.value) - ); - } else { - return TaskEither.right('pickup_updated_mappings_succeeded' as const); - } - } - ) -); -export interface AliasNotFound { - type: 'alias_not_found_exception'; -} - -/** @internal */ -export interface RemoveIndexNotAConcreteIndex { - type: 'remove_index_not_a_concrete_index'; -} - -/** @internal */ -export type AliasAction = - | { remove_index: { index: string } } - | { remove: { index: string; alias: string; must_exist: boolean } } - | { add: { index: string; alias: string } }; - -/** @internal */ -export interface UpdateAliasesParams { - client: ElasticsearchClient; - aliasActions: AliasAction[]; -} -/** - * Calls the Update index alias API `_alias` with the provided alias actions. - */ -export const updateAliases = ({ - client, - aliasActions, -}: UpdateAliasesParams): TaskEither.TaskEither< - IndexNotFound | AliasNotFound | RemoveIndexNotAConcreteIndex | RetryableEsClientError, - 'update_aliases_succeeded' -> => () => { - return client.indices - .updateAliases( - { - body: { - actions: aliasActions, - }, - }, - { maxRetries: 0 } - ) - .then(() => { - // Ignore `acknowledged: false`. When the coordinating node accepts - // the new cluster state update but not all nodes have applied the - // update within the timeout `acknowledged` will be false. However, - // retrying this update will always immediately result in `acknowledged: - // true` even if there are still nodes which are falling behind with - // cluster state updates. - // The only impact for using `updateAliases` to mark the version index - // as ready is that it could take longer for other Kibana instances to - // see that the version index is ready so they are more likely to - // perform unecessary duplicate work. - return Either.right('update_aliases_succeeded' as const); - }) - .catch((err: EsErrors.ElasticsearchClientError) => { - if (err instanceof EsErrors.ResponseError) { - if (err?.body?.error?.type === 'index_not_found_exception') { - return Either.left({ - type: 'index_not_found_exception' as const, - index: err.body.error.index, - }); - } else if ( - err?.body?.error?.type === 'illegal_argument_exception' && - err?.body?.error?.reason?.match( - /The provided expression \[.+\] matches an alias, specify the corresponding concrete indices instead./ - ) - ) { - return Either.left({ type: 'remove_index_not_a_concrete_index' as const }); - } else if ( - err?.body?.error?.type === 'aliases_not_found_exception' || - (err?.body?.error?.type === 'resource_not_found_exception' && - err?.body?.error?.reason?.match(/required alias \[.+\] does not exist/)) - ) { - return Either.left({ - type: 'alias_not_found_exception' as const, - }); - } - } - throw err; - }) - .catch(catchRetryableEsClientErrors); -}; - /** @internal */ export interface AcknowledgeResponse { acknowledged: boolean; shardsAcknowledged: boolean; } - -function aliasArrayToRecord(aliases: string[]): Record { - const result: Record = {}; - for (const alias of aliases) { - result[alias] = {}; - } - return result; -} - -/** @internal */ -export interface CreateIndexParams { - client: ElasticsearchClient; - indexName: string; - mappings: IndexMapping; - aliases?: string[]; -} -/** - * Creates an index with the given mappings - * - * @remarks - * This method adds some additional logic to the ES create index API: - * - it is idempotent, if it gets called multiple times subsequent calls will - * wait for the first create operation to complete (up to 60s) - * - the first call will wait up to 120s for the cluster state and all shards - * to be updated. - */ -export const createIndex = ({ - client, - indexName, - mappings, - aliases = [], -}: CreateIndexParams): TaskEither.TaskEither => { - const createIndexTask: TaskEither.TaskEither< - RetryableEsClientError, - AcknowledgeResponse - > = () => { - const aliasesObject = aliasArrayToRecord(aliases); - - return client.indices - .create( - { - index: indexName, - // wait until all shards are available before creating the index - // (since number_of_shards=1 this does not have any effect atm) - wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, - // Wait up to 60s for the cluster state to update and all shards to be - // started - timeout: DEFAULT_TIMEOUT, - body: { - mappings, - aliases: aliasesObject, - settings: { - index: { - // ES rule of thumb: shards should be several GB to 10's of GB, so - // Kibana is unlikely to cross that limit. - number_of_shards: 1, - auto_expand_replicas: INDEX_AUTO_EXPAND_REPLICAS, - // Set an explicit refresh interval so that we don't inherit the - // value from incorrectly configured index templates (not required - // after we adopt system indices) - refresh_interval: '1s', - // Bump priority so that recovery happens before newer indices - priority: 10, - }, - }, - }, - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ) - .then((res) => { - /** - * - acknowledged=false, we timed out before the cluster state was - * updated on all nodes with the newly created index, but it - * probably will be created sometime soon. - * - shards_acknowledged=false, we timed out before all shards were - * started - * - acknowledged=true, shards_acknowledged=true, index creation complete - */ - return Either.right({ - acknowledged: res.body.acknowledged, - shardsAcknowledged: res.body.shards_acknowledged, - }); - }) - .catch((error) => { - if (error?.body?.error?.type === 'resource_already_exists_exception') { - /** - * If the target index already exists it means a previous create - * operation had already been started. However, we can't be sure - * that all shards were started so return shardsAcknowledged: false - */ - return Either.right({ - acknowledged: true, - shardsAcknowledged: false, - }); - } else { - throw error; - } - }) - .catch(catchRetryableEsClientErrors); - }; - - return pipe( - createIndexTask, - TaskEither.chain((res) => { - if (res.acknowledged && res.shardsAcknowledged) { - // If the cluster state was updated and all shards ackd we're done - return TaskEither.right('create_index_succeeded'); - } else { - // Otherwise, wait until the target index has a 'yellow' status. - return pipe( - waitForIndexStatusYellow({ client, index: indexName, timeout: DEFAULT_TIMEOUT }), - TaskEither.map(() => { - /** When the index status is 'yellow' we know that all shards were started */ - return 'create_index_succeeded'; - }) - ); - } - }) - ); -}; - -/** @internal */ -export interface UpdateAndPickupMappingsResponse { - taskId: string; -} - -/** @internal */ -export interface UpdateAndPickupMappingsParams { - client: ElasticsearchClient; - index: string; - mappings: IndexMapping; -} -/** - * Updates an index's mappings and runs an pickupUpdatedMappings task so that the mapping - * changes are "picked up". Returns a taskId to track progress. - */ -export const updateAndPickupMappings = ({ - client, - index, - mappings, -}: UpdateAndPickupMappingsParams): TaskEither.TaskEither< - RetryableEsClientError, - UpdateAndPickupMappingsResponse -> => { - const putMappingTask: TaskEither.TaskEither< - RetryableEsClientError, - 'update_mappings_succeeded' - > = () => { - return client.indices - .putMapping({ - index, - timeout: DEFAULT_TIMEOUT, - body: mappings, - }) - .then((res) => { - // Ignore `acknowledged: false`. When the coordinating node accepts - // the new cluster state update but not all nodes have applied the - // update within the timeout `acknowledged` will be false. However, - // retrying this update will always immediately result in `acknowledged: - // true` even if there are still nodes which are falling behind with - // cluster state updates. - // For updateAndPickupMappings this means that there is the potential - // that some existing document's fields won't be picked up if the node - // on which the Kibana shard is running has fallen behind with cluster - // state updates and the mapping update wasn't applied before we run - // `pickupUpdatedMappings`. ES tries to limit this risk by blocking - // index operations (including update_by_query used by - // updateAndPickupMappings) if there are pending mappings changes. But - // not all mapping changes will prevent this. - return Either.right('update_mappings_succeeded' as const); - }) - .catch(catchRetryableEsClientErrors); - }; - - return pipe( - putMappingTask, - TaskEither.chain((res) => { - return pickupUpdatedMappings(client, index); - }) - ); -}; - -/** @internal */ -export interface SearchResponse { - outdatedDocuments: SavedObjectsRawDoc[]; -} - -interface SearchForOutdatedDocumentsOptions { - batchSize: number; - targetIndex: string; - outdatedDocumentsQuery?: estypes.QueryContainer; +// Map of left response 'type' string -> response interface +export interface ActionErrorTypeMap { + wait_for_task_completion_timeout: WaitForTaskCompletionTimeout; + retryable_es_client_error: RetryableEsClientError; + index_not_found_exception: IndexNotFound; + target_index_had_write_block: TargetIndexHadWriteBlock; + incompatible_mapping_exception: IncompatibleMappingException; + alias_not_found_exception: AliasNotFound; + remove_index_not_a_concrete_index: RemoveIndexNotAConcreteIndex; + documents_transform_failed: DocumentsTransformFailed; } /** - * Search for outdated saved object documents with the provided query. Will - * return one batch of documents. Searching should be repeated until no more - * outdated documents can be found. - * - * Used for testing only + * Type guard for narrowing the type of a left */ -export const searchForOutdatedDocuments = ( - client: ElasticsearchClient, - options: SearchForOutdatedDocumentsOptions -): TaskEither.TaskEither => () => { - return client - .search({ - index: options.targetIndex, - // Return the _seq_no and _primary_term so we can use optimistic - // concurrency control for updates - seq_no_primary_term: true, - size: options.batchSize, - body: { - query: options.outdatedDocumentsQuery, - // Optimize search performance by sorting by the "natural" index order - sort: ['_doc'], - }, - // Return an error when targeting missing or closed indices - allow_no_indices: false, - // Don't return partial results if timeouts or shard failures are - // encountered. This is important because 0 search hits is interpreted as - // there being no more outdated documents left that require - // transformation. Although the default is `false`, we set this - // explicitly to avoid users overriding the - // search.default_allow_partial_results cluster setting to true. - allow_partial_search_results: false, - // Improve performance by not calculating the total number of hits - // matching the query. - track_total_hits: false, - // Reduce the response payload size by only returning the data we care about - filter_path: [ - 'hits.hits._id', - 'hits.hits._source', - 'hits.hits._seq_no', - 'hits.hits._primary_term', - ], - }) - .then((res) => - Either.right({ outdatedDocuments: (res.body.hits?.hits as SavedObjectsRawDoc[]) ?? [] }) - ) - .catch(catchRetryableEsClientErrors); -}; - -/** @internal */ -export interface BulkOverwriteTransformedDocumentsParams { - client: ElasticsearchClient; - index: string; - transformedDocs: SavedObjectsRawDoc[]; - refresh?: estypes.Refresh; +export function isLeftTypeof( + res: any, + typeString: T +): res is ActionErrorTypeMap[T] { + return res.type === typeString; } -/** - * Write the up-to-date transformed documents to the index, overwriting any - * documents that are still on their outdated version. - */ -export const bulkOverwriteTransformedDocuments = ({ - client, - index, - transformedDocs, - refresh = false, -}: BulkOverwriteTransformedDocumentsParams): TaskEither.TaskEither< - RetryableEsClientError, - 'bulk_index_succeeded' -> => () => { - return client - .bulk({ - // Because we only add aliases in the MARK_VERSION_INDEX_READY step we - // can't bulkIndex to an alias with require_alias=true. This means if - // users tamper during this operation (delete indices or restore a - // snapshot), we could end up auto-creating an index without the correct - // mappings. Such tampering could lead to many other problems and is - // probably unlikely so for now we'll accept this risk and wait till - // system indices puts in place a hard control. - require_alias: false, - wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, - refresh, - filter_path: ['items.*.error'], - body: transformedDocs.flatMap((doc) => { - return [ - { - index: { - _index: index, - _id: doc._id, - // overwrite existing documents - op_type: 'index', - // use optimistic concurrency control to ensure that outdated - // documents are only overwritten once with the latest version - if_seq_no: doc._seq_no, - if_primary_term: doc._primary_term, - }, - }, - doc._source, - ]; - }), - }) - .then((res) => { - // Filter out version_conflict_engine_exception since these just mean - // that another instance already updated these documents - const errors = (res.body.items ?? []).filter( - (item) => item.index?.error?.type !== 'version_conflict_engine_exception' - ); - if (errors.length === 0) { - return Either.right('bulk_index_succeeded' as const); - } else { - throw new Error(JSON.stringify(errors)); - } - }) - .catch(catchRetryableEsClientErrors); -}; diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts similarity index 99% rename from src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts rename to src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts index 67a2685caf3d6..b508a6198bfb3 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts @@ -6,11 +6,11 @@ * Side Public License, v 1. */ -import { ElasticsearchClient } from '../../../'; -import { InternalCoreStart } from '../../../internal_types'; -import * as kbnTestServer from '../../../../test_helpers/kbn_server'; -import { Root } from '../../../root'; -import { SavedObjectsRawDoc } from '../../serialization'; +import { ElasticsearchClient } from '../../../../'; +import { InternalCoreStart } from '../../../../internal_types'; +import * as kbnTestServer from '../../../../../test_helpers/kbn_server'; +import { Root } from '../../../../root'; +import { SavedObjectsRawDoc } from '../../../serialization'; import { bulkOverwriteTransformedDocuments, cloneIndex, @@ -37,11 +37,11 @@ import { removeWriteBlock, transformDocs, waitForIndexStatusYellow, -} from '../actions'; +} from '../../actions'; import * as Either from 'fp-ts/lib/Either'; import * as Option from 'fp-ts/lib/Option'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; -import { DocumentsTransformFailed, DocumentsTransformSuccess } from '../../migrations/core'; +import { DocumentsTransformFailed, DocumentsTransformSuccess } from '../../../migrations/core'; import { TaskEither } from 'fp-ts/lib/TaskEither'; const { startES } = kbnTestServer.createTestServers({ diff --git a/src/core/server/saved_objects/migrationsv2/actions/open_pit.test.ts b/src/core/server/saved_objects/migrationsv2/actions/open_pit.test.ts new file mode 100644 index 0000000000000..c8fc29d06f42f --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/open_pit.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { openPit } from './open_pit'; + +describe('openPit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = openPit({ client, index: 'my_index' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/open_pit.ts b/src/core/server/saved_objects/migrationsv2/actions/open_pit.ts new file mode 100644 index 0000000000000..e740dc00ac27e --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/open_pit.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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +/** @internal */ +export interface OpenPitResponse { + pitId: string; +} + +/** @internal */ +export interface OpenPitParams { + client: ElasticsearchClient; + index: string; +} +// how long ES should keep PIT alive +export const pitKeepAlive = '10m'; +/* + * Creates a lightweight view of data when the request has been initiated. + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * */ +export const openPit = ({ + client, + index, +}: OpenPitParams): TaskEither.TaskEither => () => { + return client + .openPointInTime({ + index, + keep_alive: pitKeepAlive, + }) + .then((response) => Either.right({ pitId: response.body.id })) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.test.ts b/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.test.ts new file mode 100644 index 0000000000000..e319d4149dd1a --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { pickupUpdatedMappings } from './pickup_updated_mappings'; + +describe('pickupUpdatedMappings', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = pickupUpdatedMappings(client, 'my_index'); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.ts b/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.ts new file mode 100644 index 0000000000000..8cc609e5277bc --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { BATCH_SIZE } from './constants'; +export interface UpdateByQueryResponse { + taskId: string; +} + +/** + * Pickup updated mappings by performing an update by query operation on all + * documents in the index. Returns a task ID which can be + * tracked for progress. + * + * @remarks When mappings are updated to add a field which previously wasn't + * mapped Elasticsearch won't automatically add existing documents to it's + * internal search indices. So search results on this field won't return any + * existing documents. By running an update by query we essentially refresh + * these the internal search indices for all existing documents. + * This action uses `conflicts: 'proceed'` allowing several Kibana instances + * to run this in parallel. + */ +export const pickupUpdatedMappings = ( + client: ElasticsearchClient, + index: string +): TaskEither.TaskEither => () => { + return client + .updateByQuery({ + // Ignore version conflicts that can occur from parallel update by query operations + conflicts: 'proceed', + // Return an error when targeting missing or closed indices + allow_no_indices: false, + index, + // How many documents to update per batch + scroll_size: BATCH_SIZE, + // force a refresh so that we can query the updated index immediately + // after the operation completes + refresh: true, + // Create a task and return task id instead of blocking until complete + wait_for_completion: false, + }) + .then(({ body: { task: taskId } }) => { + return Either.right({ taskId: String(taskId!) }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.test.ts b/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.test.ts new file mode 100644 index 0000000000000..0d8d76b45a57b --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { readWithPit } from './read_with_pit'; + +describe('readWithPit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = readWithPit({ + client, + pitId: 'pitId', + query: { match_all: {} }, + batchSize: 10_000, + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.ts b/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.ts new file mode 100644 index 0000000000000..16f1df05f26b3 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import type { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import type { SavedObjectsRawDoc } from '../../serialization'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { pitKeepAlive } from './open_pit'; + +/** @internal */ +export interface ReadWithPit { + outdatedDocuments: SavedObjectsRawDoc[]; + readonly lastHitSortValue: number[] | undefined; + readonly totalHits: number | undefined; +} + +/** @internal */ +export interface ReadWithPitParams { + client: ElasticsearchClient; + pitId: string; + query: estypes.QueryContainer; + batchSize: number; + searchAfter?: number[]; + seqNoPrimaryTerm?: boolean; +} + +/* + * Requests documents from the index using PIT mechanism. + * */ +export const readWithPit = ({ + client, + pitId, + query, + batchSize, + searchAfter, + seqNoPrimaryTerm, +}: ReadWithPitParams): TaskEither.TaskEither => () => { + return client + .search({ + seq_no_primary_term: seqNoPrimaryTerm, + body: { + // Sort fields are required to use searchAfter + sort: { + // the most efficient option as order is not important for the migration + _shard_doc: { order: 'asc' }, + }, + pit: { id: pitId, keep_alive: pitKeepAlive }, + size: batchSize, + search_after: searchAfter, + /** + * We want to know how many documents we need to process so we can log the progress. + * But we also want to increase the performance of these requests, + * so we ask ES to report the total count only on the first request (when searchAfter does not exist) + */ + track_total_hits: typeof searchAfter === 'undefined', + query, + }, + }) + .then((response) => { + const totalHits = + typeof response.body.hits.total === 'number' + ? response.body.hits.total // This format is to be removed in 8.0 + : response.body.hits.total?.value; + const hits = response.body.hits.hits; + + if (hits.length > 0) { + return Either.right({ + // @ts-expect-error @elastic/elasticsearch _source is optional + outdatedDocuments: hits as SavedObjectsRawDoc[], + lastHitSortValue: hits[hits.length - 1].sort as number[], + totalHits, + }); + } + + return Either.right({ + outdatedDocuments: [], + lastHitSortValue: undefined, + totalHits, + }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/refresh_index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/refresh_index.test.ts new file mode 100644 index 0000000000000..0ebdb2b2b1851 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/refresh_index.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { refreshIndex } from './refresh_index'; + +describe('refreshIndex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = refreshIndex({ client, targetIndex: 'target_index' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/refresh_index.ts b/src/core/server/saved_objects/migrationsv2/actions/refresh_index.ts new file mode 100644 index 0000000000000..e7bcbfb7d2d53 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/refresh_index.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; + +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface RefreshIndexParams { + client: ElasticsearchClient; + targetIndex: string; +} +/** + * Wait for Elasticsearch to reindex all the changes. + */ +export const refreshIndex = ({ + client, + targetIndex, +}: RefreshIndexParams): TaskEither.TaskEither< + RetryableEsClientError, + { refreshed: boolean } +> => () => { + return client.indices + .refresh({ + index: targetIndex, + }) + .then(() => { + return Either.right({ refreshed: true }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/reindex.test.ts b/src/core/server/saved_objects/migrationsv2/actions/reindex.test.ts new file mode 100644 index 0000000000000..f53368bd9321b --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/reindex.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as Option from 'fp-ts/lib/Option'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { reindex } from './reindex'; + +describe('reindex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = reindex({ + client, + sourceIndex: 'my_source_index', + targetIndex: 'my_target_index', + reindexScript: Option.none, + requireAlias: false, + unusedTypesQuery: {}, + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/reindex.ts b/src/core/server/saved_objects/migrationsv2/actions/reindex.ts new file mode 100644 index 0000000000000..ca8d3b594703c --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/reindex.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Option from 'fp-ts/lib/Option'; +import type { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { BATCH_SIZE } from './constants'; + +/** @internal */ +export interface ReindexResponse { + taskId: string; +} +/** @internal */ +export interface ReindexParams { + client: ElasticsearchClient; + sourceIndex: string; + targetIndex: string; + reindexScript: Option.Option; + requireAlias: boolean; + /* When reindexing we use a source query to exclude saved objects types which + * are no longer used. These saved objects will still be kept in the outdated + * index for backup purposes, but won't be available in the upgraded index. + */ + unusedTypesQuery: estypes.QueryContainer; +} +/** + * Reindex documents from the `sourceIndex` into the `targetIndex`. Returns a + * task ID which can be tracked for progress. + * + * @remarks This action is idempotent allowing several Kibana instances to run + * this in parallel. By using `op_type: 'create', conflicts: 'proceed'` there + * will be only one write per reindexed document. + */ +export const reindex = ({ + client, + sourceIndex, + targetIndex, + reindexScript, + requireAlias, + unusedTypesQuery, +}: ReindexParams): TaskEither.TaskEither => () => { + return client + .reindex({ + // Require targetIndex to be an alias. Prevents a new index from being + // created if targetIndex doesn't exist. + require_alias: requireAlias, + body: { + // Ignore version conflicts from existing documents + conflicts: 'proceed', + source: { + index: sourceIndex, + // Set reindex batch size + size: BATCH_SIZE, + // Exclude saved object types + query: unusedTypesQuery, + }, + dest: { + index: targetIndex, + // Don't override existing documents, only create if missing + op_type: 'create', + }, + script: Option.fold( + () => undefined, + (script) => ({ + source: script, + lang: 'painless', + }) + )(reindexScript), + }, + // force a refresh so that we can query the target index + refresh: true, + // Create a task and return task id instead of blocking until complete + wait_for_completion: false, + }) + .then(({ body: { task: taskId } }) => { + return Either.right({ taskId: String(taskId) }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.test.ts b/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.test.ts new file mode 100644 index 0000000000000..497211cb693ab --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.test.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 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 { removeWriteBlock } from './remove_write_block'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('removeWriteBlock', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = removeWriteBlock({ client, index: 'my_index' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = removeWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.ts b/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.ts new file mode 100644 index 0000000000000..c55e4a235fbf1 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface RemoveWriteBlockParams { + client: ElasticsearchClient; + index: string; +} +/** + * Removes a write block from an index + */ +export const removeWriteBlock = ({ + client, + index, +}: RemoveWriteBlockParams): TaskEither.TaskEither< + RetryableEsClientError, + 'remove_write_block_succeeded' +> => () => { + return client.indices + .putSettings<{ + acknowledged: boolean; + shards_acknowledged: boolean; + }>( + { + index, + // Don't change any existing settings + preserve_existing: true, + body: { + index: { + blocks: { + write: false, + }, + }, + }, + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + .then((res) => { + return res.body.acknowledged === true + ? Either.right('remove_write_block_succeeded' as const) + : Either.left({ + type: 'retryable_es_client_error' as const, + message: 'remove_write_block_failed', + }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.test.ts b/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.test.ts new file mode 100644 index 0000000000000..ab133e9a564be --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { searchForOutdatedDocuments } from './search_for_outdated_documents'; + +describe('searchForOutdatedDocuments', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = searchForOutdatedDocuments(client, { + batchSize: 1000, + targetIndex: 'new_index', + outdatedDocumentsQuery: {}, + }); + + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + + it('configures request according to given parameters', async () => { + const esClient = elasticsearchClientMock.createInternalClient(); + const query = {}; + const targetIndex = 'new_index'; + const batchSize = 1000; + const task = searchForOutdatedDocuments(esClient, { + batchSize, + targetIndex, + outdatedDocumentsQuery: query, + }); + + await task(); + + expect(esClient.search).toHaveBeenCalledTimes(1); + expect(esClient.search).toHaveBeenCalledWith( + expect.objectContaining({ + index: targetIndex, + size: batchSize, + body: expect.objectContaining({ query }), + }) + ); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.ts b/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.ts new file mode 100644 index 0000000000000..7406cd35b1593 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import type { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import type { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface SearchResponse { + outdatedDocuments: SavedObjectsRawDoc[]; +} + +export interface SearchForOutdatedDocumentsOptions { + batchSize: number; + targetIndex: string; + outdatedDocumentsQuery?: estypes.QueryContainer; +} + +/** + * Search for outdated saved object documents with the provided query. Will + * return one batch of documents. Searching should be repeated until no more + * outdated documents can be found. + * + * Used for testing only + */ +export const searchForOutdatedDocuments = ( + client: ElasticsearchClient, + options: SearchForOutdatedDocumentsOptions +): TaskEither.TaskEither => () => { + return client + .search({ + index: options.targetIndex, + // Return the _seq_no and _primary_term so we can use optimistic + // concurrency control for updates + seq_no_primary_term: true, + size: options.batchSize, + body: { + query: options.outdatedDocumentsQuery, + // Optimize search performance by sorting by the "natural" index order + sort: ['_doc'], + }, + // Return an error when targeting missing or closed indices + allow_no_indices: false, + // Don't return partial results if timeouts or shard failures are + // encountered. This is important because 0 search hits is interpreted as + // there being no more outdated documents left that require + // transformation. Although the default is `false`, we set this + // explicitly to avoid users overriding the + // search.default_allow_partial_results cluster setting to true. + allow_partial_search_results: false, + // Improve performance by not calculating the total number of hits + // matching the query. + track_total_hits: false, + // Reduce the response payload size by only returning the data we care about + filter_path: [ + 'hits.hits._id', + 'hits.hits._source', + 'hits.hits._seq_no', + 'hits.hits._primary_term', + ], + }) + .then((res) => + Either.right({ outdatedDocuments: (res.body.hits?.hits as SavedObjectsRawDoc[]) ?? [] }) + ) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/set_write_block.test.ts b/src/core/server/saved_objects/migrationsv2/actions/set_write_block.test.ts new file mode 100644 index 0000000000000..cf7b3091f38ff --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/set_write_block.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright 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 { setWriteBlock } from './set_write_block'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('setWriteBlock', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = setWriteBlock({ client, index: 'my_index' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/set_write_block.ts b/src/core/server/saved_objects/migrationsv2/actions/set_write_block.ts new file mode 100644 index 0000000000000..5aed316306cf9 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/set_write_block.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { ElasticsearchClientError } from '@elastic/elasticsearch/lib/errors'; +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import type { IndexNotFound } from './'; + +/** @internal */ +export interface SetWriteBlockParams { + client: ElasticsearchClient; + index: string; +} +/** + * Sets a write block in place for the given index. If the response includes + * `acknowledged: true` all in-progress writes have drained and no further + * writes to this index will be possible. + * + * The first time the write block is added to an index the response will + * include `shards_acknowledged: true` but once the block is in place, + * subsequent calls return `shards_acknowledged: false` + */ +export const setWriteBlock = ({ + client, + index, +}: SetWriteBlockParams): TaskEither.TaskEither< + IndexNotFound | RetryableEsClientError, + 'set_write_block_succeeded' +> => () => { + return ( + client.indices + .addBlock<{ + acknowledged: boolean; + shards_acknowledged: boolean; + }>( + { + index, + block: 'write', + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + // not typed yet + .then((res: any) => { + return res.body.acknowledged === true + ? Either.right('set_write_block_succeeded' as const) + : Either.left({ + type: 'retryable_es_client_error' as const, + message: 'set_write_block_failed', + }); + }) + .catch((e: ElasticsearchClientError) => { + if (e instanceof EsErrors.ResponseError) { + if (e.body?.error?.type === 'index_not_found_exception') { + return Either.left({ type: 'index_not_found_exception' as const, index }); + } + } + throw e; + }) + .catch(catchRetryableEsClientErrors) + ); +}; +// diff --git a/src/core/server/saved_objects/migrationsv2/actions/transform_docs.ts b/src/core/server/saved_objects/migrationsv2/actions/transform_docs.ts new file mode 100644 index 0000000000000..4c712afcff3a4 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/transform_docs.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import type { TransformRawDocs } from '../types'; +import type { SavedObjectsRawDoc } from '../../serialization'; +import { + DocumentsTransformFailed, + DocumentsTransformSuccess, +} from '../../migrations/core/migrate_raw_docs'; + +/** @internal */ +export interface TransformDocsParams { + transformRawDocs: TransformRawDocs; + outdatedDocuments: SavedObjectsRawDoc[]; +} +/* + * Transform outdated docs + * */ +export const transformDocs = ({ + transformRawDocs, + outdatedDocuments, +}: TransformDocsParams): TaskEither.TaskEither< + DocumentsTransformFailed, + DocumentsTransformSuccess +> => transformRawDocs(outdatedDocuments); diff --git a/src/core/server/saved_objects/migrationsv2/actions/update_aliases.test.ts b/src/core/server/saved_objects/migrationsv2/actions/update_aliases.test.ts new file mode 100644 index 0000000000000..e2ea07d40281b --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/update_aliases.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { updateAliases } from './update_aliases'; +import { setWriteBlock } from './set_write_block'; + +describe('updateAliases', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = updateAliases({ client, aliasActions: [] }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/update_aliases.ts b/src/core/server/saved_objects/migrationsv2/actions/update_aliases.ts new file mode 100644 index 0000000000000..ffb8002f09212 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/update_aliases.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { IndexNotFound } from './index'; + +export interface AliasNotFound { + type: 'alias_not_found_exception'; +} + +/** @internal */ +export interface RemoveIndexNotAConcreteIndex { + type: 'remove_index_not_a_concrete_index'; +} + +/** @internal */ +export type AliasAction = + | { remove_index: { index: string } } + | { remove: { index: string; alias: string; must_exist: boolean } } + | { add: { index: string; alias: string } }; + +/** @internal */ +export interface UpdateAliasesParams { + client: ElasticsearchClient; + aliasActions: AliasAction[]; +} +/** + * Calls the Update index alias API `_alias` with the provided alias actions. + */ +export const updateAliases = ({ + client, + aliasActions, +}: UpdateAliasesParams): TaskEither.TaskEither< + IndexNotFound | AliasNotFound | RemoveIndexNotAConcreteIndex | RetryableEsClientError, + 'update_aliases_succeeded' +> => () => { + return client.indices + .updateAliases( + { + body: { + actions: aliasActions, + }, + }, + { maxRetries: 0 } + ) + .then(() => { + // Ignore `acknowledged: false`. When the coordinating node accepts + // the new cluster state update but not all nodes have applied the + // update within the timeout `acknowledged` will be false. However, + // retrying this update will always immediately result in `acknowledged: + // true` even if there are still nodes which are falling behind with + // cluster state updates. + // The only impact for using `updateAliases` to mark the version index + // as ready is that it could take longer for other Kibana instances to + // see that the version index is ready so they are more likely to + // perform unecessary duplicate work. + return Either.right('update_aliases_succeeded' as const); + }) + .catch((err: EsErrors.ElasticsearchClientError) => { + if (err instanceof EsErrors.ResponseError) { + if (err?.body?.error?.type === 'index_not_found_exception') { + return Either.left({ + type: 'index_not_found_exception' as const, + index: err.body.error.index, + }); + } else if ( + err?.body?.error?.type === 'illegal_argument_exception' && + err?.body?.error?.reason?.match( + /The provided expression \[.+\] matches an alias, specify the corresponding concrete indices instead./ + ) + ) { + return Either.left({ type: 'remove_index_not_a_concrete_index' as const }); + } else if ( + err?.body?.error?.type === 'aliases_not_found_exception' || + (err?.body?.error?.type === 'resource_not_found_exception' && + err?.body?.error?.reason?.match(/required alias \[.+\] does not exist/)) + ) { + return Either.left({ + type: 'alias_not_found_exception' as const, + }); + } + } + throw err; + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.test.ts b/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.test.ts new file mode 100644 index 0000000000000..3ecb990cd9e82 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { updateAndPickupMappings } from './update_and_pickup_mappings'; + +describe('updateAndPickupMappings', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = updateAndPickupMappings({ + client, + index: 'new_index', + mappings: { properties: {} }, + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.ts b/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.ts new file mode 100644 index 0000000000000..8c742005a01ce --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { IndexMapping } from '../../mappings'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { pickupUpdatedMappings } from './pickup_updated_mappings'; +import { DEFAULT_TIMEOUT } from './constants'; + +/** @internal */ +export interface UpdateAndPickupMappingsResponse { + taskId: string; +} + +/** @internal */ +export interface UpdateAndPickupMappingsParams { + client: ElasticsearchClient; + index: string; + mappings: IndexMapping; +} +/** + * Updates an index's mappings and runs an pickupUpdatedMappings task so that the mapping + * changes are "picked up". Returns a taskId to track progress. + */ +export const updateAndPickupMappings = ({ + client, + index, + mappings, +}: UpdateAndPickupMappingsParams): TaskEither.TaskEither< + RetryableEsClientError, + UpdateAndPickupMappingsResponse +> => { + const putMappingTask: TaskEither.TaskEither< + RetryableEsClientError, + 'update_mappings_succeeded' + > = () => { + return client.indices + .putMapping({ + index, + timeout: DEFAULT_TIMEOUT, + body: mappings, + }) + .then((res) => { + // Ignore `acknowledged: false`. When the coordinating node accepts + // the new cluster state update but not all nodes have applied the + // update within the timeout `acknowledged` will be false. However, + // retrying this update will always immediately result in `acknowledged: + // true` even if there are still nodes which are falling behind with + // cluster state updates. + // For updateAndPickupMappings this means that there is the potential + // that some existing document's fields won't be picked up if the node + // on which the Kibana shard is running has fallen behind with cluster + // state updates and the mapping update wasn't applied before we run + // `pickupUpdatedMappings`. ES tries to limit this risk by blocking + // index operations (including update_by_query used by + // updateAndPickupMappings) if there are pending mappings changes. But + // not all mapping changes will prevent this. + return Either.right('update_mappings_succeeded' as const); + }) + .catch(catchRetryableEsClientErrors); + }; + + return pipe( + putMappingTask, + TaskEither.chain((res) => { + return pickupUpdatedMappings(client, index); + }) + ); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/verify_reindex.ts b/src/core/server/saved_objects/migrationsv2/actions/verify_reindex.ts new file mode 100644 index 0000000000000..4db599d8fbadf --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/verify_reindex.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface VerifyReindexParams { + client: ElasticsearchClient; + sourceIndex: string; + targetIndex: string; +} + +export const verifyReindex = ({ + client, + sourceIndex, + targetIndex, +}: VerifyReindexParams): TaskEither.TaskEither< + RetryableEsClientError | { type: 'verify_reindex_failed' }, + 'verify_reindex_succeeded' +> => () => { + const count = (index: string) => + client + .count<{ count: number }>({ + index, + // Return an error when targeting missing or closed indices + allow_no_indices: false, + }) + .then((res) => { + return res.body.count; + }); + + return Promise.all([count(sourceIndex), count(targetIndex)]) + .then(([sourceCount, targetCount]) => { + if (targetCount >= sourceCount) { + return Either.right('verify_reindex_succeeded' as const); + } else { + return Either.left({ type: 'verify_reindex_failed' as const }); + } + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.test.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.test.ts new file mode 100644 index 0000000000000..8cea34b80ffad --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { waitForIndexStatusYellow } from './wait_for_index_status_yellow'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('waitForIndexStatusYellow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = waitForIndexStatusYellow({ + client, + index: 'my_index', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.ts new file mode 100644 index 0000000000000..307c77ee5b89c --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { DEFAULT_TIMEOUT } from './constants'; + +/** @internal */ +export interface WaitForIndexStatusYellowParams { + client: ElasticsearchClient; + index: string; + timeout?: string; +} +/** + * A yellow index status means the index's primary shard is allocated and the + * index is ready for searching/indexing documents, but ES wasn't able to + * allocate the replicas. When migrations proceed with a yellow index it means + * we don't have as much data-redundancy as we could have, but waiting for + * replicas would mean that v2 migrations fail where v1 migrations would have + * succeeded. It doesn't feel like it's Kibana's job to force users to keep + * their clusters green and even if it's green when we migrate it can turn + * yellow at any point in the future. So ultimately data-redundancy is up to + * users to maintain. + */ +export const waitForIndexStatusYellow = ({ + client, + index, + timeout = DEFAULT_TIMEOUT, +}: WaitForIndexStatusYellowParams): TaskEither.TaskEither => () => { + return client.cluster + .health({ index, wait_for_status: 'yellow', timeout }) + .then(() => { + return Either.right({}); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.test.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.test.ts new file mode 100644 index 0000000000000..f7c380be9427c --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { waitForPickupUpdatedMappingsTask } from './wait_for_pickup_updated_mappings_task'; +import { setWriteBlock } from './set_write_block'; + +describe('waitForPickupUpdatedMappingsTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = waitForPickupUpdatedMappingsTask({ + client, + taskId: 'my task id', + timeout: '60s', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.ts new file mode 100644 index 0000000000000..02f7c3455cec9 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.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 * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Option from 'fp-ts/lib/Option'; +import { flow } from 'fp-ts/lib/function'; +import { waitForTask, WaitForTaskCompletionTimeout } from './wait_for_task'; +import { RetryableEsClientError } from './catch_retryable_es_client_errors'; + +export const waitForPickupUpdatedMappingsTask = flow( + waitForTask, + TaskEither.chain( + ( + res + ): TaskEither.TaskEither< + RetryableEsClientError | WaitForTaskCompletionTimeout, + 'pickup_updated_mappings_succeeded' + > => { + // We don't catch or type failures/errors because they should never + // occur in our migration algorithm and we don't have any business logic + // for dealing with it. If something happens we'll just crash and try + // again. + if (Option.isSome(res.failures)) { + throw new Error( + 'pickupUpdatedMappings task failed with the following failures:\n' + + JSON.stringify(res.failures.value) + ); + } else if (Option.isSome(res.error)) { + throw new Error( + 'pickupUpdatedMappings task failed with the following error:\n' + + JSON.stringify(res.error.value) + ); + } else { + return TaskEither.right('pickup_updated_mappings_succeeded' as const); + } + } + ) +); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.test.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.test.ts new file mode 100644 index 0000000000000..f6a236aab5c85 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { waitForReindexTask } from './wait_for_reindex_task'; +import { setWriteBlock } from './set_write_block'; + +describe('waitForReindexTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = waitForReindexTask({ client, taskId: 'my task id', timeout: '60s' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts new file mode 100644 index 0000000000000..fcadb5e80298a --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Option from 'fp-ts/lib/Option'; +import { flow } from 'fp-ts/lib/function'; +import { RetryableEsClientError } from './catch_retryable_es_client_errors'; +import type { IndexNotFound, WaitForReindexTaskFailure, TargetIndexHadWriteBlock } from './index'; +import { waitForTask, WaitForTaskCompletionTimeout } from './wait_for_task'; + +export interface IncompatibleMappingException { + type: 'incompatible_mapping_exception'; +} +export const waitForReindexTask = flow( + waitForTask, + TaskEither.chain( + ( + res + ): TaskEither.TaskEither< + | IndexNotFound + | TargetIndexHadWriteBlock + | IncompatibleMappingException + | RetryableEsClientError + | WaitForTaskCompletionTimeout, + 'reindex_succeeded' + > => { + const failureIsAWriteBlock = ({ cause: { type, reason } }: WaitForReindexTaskFailure) => + type === 'cluster_block_exception' && + reason.match(/index \[.+] blocked by: \[FORBIDDEN\/8\/index write \(api\)\]/); + + const failureIsIncompatibleMappingException = ({ + cause: { type, reason }, + }: WaitForReindexTaskFailure) => + type === 'strict_dynamic_mapping_exception' || type === 'mapper_parsing_exception'; + + if (Option.isSome(res.error)) { + if (res.error.value.type === 'index_not_found_exception') { + return TaskEither.left({ + type: 'index_not_found_exception' as const, + index: res.error.value.index, + }); + } else { + throw new Error('Reindex failed with the following error:\n' + JSON.stringify(res.error)); + } + } else if (Option.isSome(res.failures)) { + if (res.failures.value.every(failureIsAWriteBlock)) { + return TaskEither.left({ type: 'target_index_had_write_block' as const }); + } else if (res.failures.value.every(failureIsIncompatibleMappingException)) { + return TaskEither.left({ type: 'incompatible_mapping_exception' as const }); + } else { + throw new Error( + 'Reindex failed with the following failures:\n' + JSON.stringify(res.failures.value) + ); + } + } else { + return TaskEither.right('reindex_succeeded' as const); + } + } + ) +); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.test.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.test.ts new file mode 100644 index 0000000000000..c7ca9bf36a2c6 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { errors as EsErrors } from '@elastic/elasticsearch'; +import { waitForTask } from './wait_for_task'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('waitForTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + describe('waitForPickupUpdatedMappingsTask', () => { + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = waitForTask({ + client, + taskId: 'my task id', + timeout: '60s', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.ts new file mode 100644 index 0000000000000..4e3631797e34b --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Option from 'fp-ts/lib/Option'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +/** @internal */ +export interface WaitForTaskResponse { + error: Option.Option<{ type: string; reason: string; index: string }>; + completed: boolean; + failures: Option.Option; + description?: string; +} + +/** + * After waiting for the specificed timeout, the task has not yet completed. + * + * When querying the tasks API we use `wait_for_completion=true` to block the + * request until the task completes. If after the `timeout`, the task still has + * not completed we return this error. This does not mean that the task itelf + * has reached a timeout, Elasticsearch will continue to run the task. + */ +export interface WaitForTaskCompletionTimeout { + /** After waiting for the specificed timeout, the task has not yet completed. */ + readonly type: 'wait_for_task_completion_timeout'; + readonly message: string; + readonly error?: Error; +} + +const catchWaitForTaskCompletionTimeout = ( + e: EsErrors.ResponseError +): Either.Either => { + if ( + e.body?.error?.type === 'timeout_exception' || + e.body?.error?.type === 'receive_timeout_transport_exception' + ) { + return Either.left({ + type: 'wait_for_task_completion_timeout' as const, + message: `[${e.body.error.type}] ${e.body.error.reason}`, + error: e, + }); + } else { + throw e; + } +}; + +/** @internal */ +export interface WaitForTaskParams { + client: ElasticsearchClient; + taskId: string; + timeout: string; +} +/** + * Blocks for up to 60s or until a task completes. + * + * TODO: delete completed tasks + */ +export const waitForTask = ({ + client, + taskId, + timeout, +}: WaitForTaskParams): TaskEither.TaskEither< + RetryableEsClientError | WaitForTaskCompletionTimeout, + WaitForTaskResponse +> => () => { + return client.tasks + .get({ + task_id: taskId, + wait_for_completion: true, + timeout, + }) + .then((res) => { + const body = res.body; + const failures = body.response?.failures ?? []; + return Either.right({ + completed: body.completed, + // @ts-expect-error @elastic/elasticsearch GetTaskResponse doesn't declare `error` property + error: Option.fromNullable(body.error), + failures: failures.length > 0 ? Option.some(failures) : Option.none, + description: body.task.description, + }); + }) + .catch(catchWaitForTaskCompletionTimeout) + .catch(catchRetryableEsClientErrors); +}; From e4f74471ecdbf2723581f38b7a5088360b353338 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 3 Jun 2021 12:57:21 -0400 Subject: [PATCH 34/35] [Fleet] Rename config value agents.elasticsearch.host => agents.elasticsearch.hosts (#101162) --- docs/settings/fleet-settings.asciidoc | 4 +- x-pack/plugins/fleet/common/types/index.ts | 2 +- .../fleet/mock/plugin_configuration.ts | 2 +- x-pack/plugins/fleet/server/index.ts | 19 ++++- .../fleet/server/services/output.test.ts | 85 +++++++++++++++++++ .../plugins/fleet/server/services/output.ts | 24 ++++-- 6 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 x-pack/plugins/fleet/server/services/output.test.ts diff --git a/docs/settings/fleet-settings.asciidoc b/docs/settings/fleet-settings.asciidoc index 9c054fbc00222..134d9de3f49d8 100644 --- a/docs/settings/fleet-settings.asciidoc +++ b/docs/settings/fleet-settings.asciidoc @@ -39,8 +39,8 @@ See the {fleet-guide}/index.html[{fleet}] docs for more information. |=== | `xpack.fleet.agents.fleet_server.hosts` | Hostnames used by {agent} for accessing {fleet-server}. -| `xpack.fleet.agents.elasticsearch.host` - | The hostname used by {agent} for accessing {es}. +| `xpack.fleet.agents.elasticsearch.hosts` + | Hostnames used by {agent} for accessing {es}. |=== [NOTE] diff --git a/x-pack/plugins/fleet/common/types/index.ts b/x-pack/plugins/fleet/common/types/index.ts index 7117973baa139..95f91165aaf94 100644 --- a/x-pack/plugins/fleet/common/types/index.ts +++ b/x-pack/plugins/fleet/common/types/index.ts @@ -16,7 +16,7 @@ export interface FleetConfigType { agents: { enabled: boolean; elasticsearch: { - host?: string; + hosts?: string[]; ca_sha256?: string; }; fleet_server?: { diff --git a/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts b/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts index 7f0b71de779dc..a9ad6b1bd8794 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts @@ -15,7 +15,7 @@ export const createConfigurationMock = (): FleetConfigType => { agents: { enabled: true, elasticsearch: { - host: '', + hosts: [''], ca_sha256: '', }, }, diff --git a/x-pack/plugins/fleet/server/index.ts b/x-pack/plugins/fleet/server/index.ts index e83617413b744..0a886ffedbd6c 100644 --- a/x-pack/plugins/fleet/server/index.ts +++ b/x-pack/plugins/fleet/server/index.ts @@ -41,6 +41,23 @@ export const config: PluginConfigDescriptor = { unused('agents.pollingRequestTimeout'), unused('agents.tlsCheckDisabled'), unused('agents.fleetServerEnabled'), + (fullConfig, fromPath, addDeprecation) => { + const oldValue = fullConfig?.xpack?.fleet?.agents?.elasticsearch?.host; + if (oldValue) { + delete fullConfig.xpack.fleet.agents.elasticsearch.host; + fullConfig.xpack.fleet.agents.elasticsearch.hosts = [oldValue]; + addDeprecation({ + message: `Config key [xpack.fleet.agents.elasticsearch.host] is deprecated and replaced by [xpack.fleet.agents.elasticsearch.hosts]`, + correctiveActions: { + manualSteps: [ + `Use [xpack.fleet.agents.elasticsearch.hosts] with an array of host instead.`, + ], + }, + }); + } + + return fullConfig; + }, ], schema: schema.object({ enabled: schema.boolean({ defaultValue: true }), @@ -49,7 +66,7 @@ export const config: PluginConfigDescriptor = { agents: schema.object({ enabled: schema.boolean({ defaultValue: true }), elasticsearch: schema.object({ - host: schema.maybe(schema.string()), + hosts: schema.maybe(schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }))), ca_sha256: schema.maybe(schema.string()), }), fleet_server: schema.maybe( diff --git a/x-pack/plugins/fleet/server/services/output.test.ts b/x-pack/plugins/fleet/server/services/output.test.ts new file mode 100644 index 0000000000000..26e3955607ada --- /dev/null +++ b/x-pack/plugins/fleet/server/services/output.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { outputService } from './output'; + +import { appContextService } from './app_context'; + +jest.mock('./app_context'); + +const mockedAppContextService = appContextService as jest.Mocked; + +const CLOUD_ID = + 'dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRjZWM2ZjI2MWE3NGJmMjRjZTMzYmI4ODExYjg0Mjk0ZiRjNmMyY2E2ZDA0MjI0OWFmMGNjN2Q3YTllOTYyNTc0Mw=='; + +const CONFIG_WITH_ES_HOSTS = { + enabled: true, + agents: { + enabled: true, + elasticsearch: { + hosts: ['http://host1.com'], + }, + }, +}; + +const CONFIG_WITHOUT_ES_HOSTS = { + enabled: true, + agents: { + enabled: true, + elasticsearch: {}, + }, +}; + +describe('Output Service', () => { + describe('getDefaultESHosts', () => { + afterEach(() => { + mockedAppContextService.getConfig.mockReset(); + mockedAppContextService.getConfig.mockReset(); + }); + it('Should use cloud ID as the source of truth for ES hosts', () => { + // @ts-expect-error + mockedAppContextService.getCloud.mockReturnValue({ + isCloudEnabled: true, + cloudId: CLOUD_ID, + }); + + mockedAppContextService.getConfig.mockReturnValue(CONFIG_WITH_ES_HOSTS); + + const hosts = outputService.getDefaultESHosts(); + + expect(hosts).toEqual([ + 'https://cec6f261a74bf24ce33bb8811b84294f.us-east-1.aws.found.io:443', + ]); + }); + + it('Should use the value from the config if not in cloud', () => { + // @ts-expect-error + mockedAppContextService.getCloud.mockReturnValue({ + isCloudEnabled: false, + }); + + mockedAppContextService.getConfig.mockReturnValue(CONFIG_WITH_ES_HOSTS); + + const hosts = outputService.getDefaultESHosts(); + + expect(hosts).toEqual(['http://host1.com']); + }); + + it('Should use the default value if there is no config', () => { + // @ts-expect-error + mockedAppContextService.getCloud.mockReturnValue({ + isCloudEnabled: false, + }); + + mockedAppContextService.getConfig.mockReturnValue(CONFIG_WITHOUT_ES_HOSTS); + + const hosts = outputService.getDefaultESHosts(); + + expect(hosts).toEqual(['http://localhost:9200']); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index b3857ba5c0ef3..0c7b086f78fdf 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -16,6 +16,8 @@ import { normalizeHostsForAgents } from './hosts_utils'; const SAVED_OBJECT_TYPE = OUTPUT_SAVED_OBJECT_TYPE; +const DEFAULT_ES_HOSTS = ['http://localhost:9200']; + class OutputService { public async getDefaultOutput(soClient: SavedObjectsClientContract) { return await soClient.find({ @@ -27,17 +29,11 @@ class OutputService { public async ensureDefaultOutput(soClient: SavedObjectsClientContract) { const outputs = await this.getDefaultOutput(soClient); - const cloud = appContextService.getCloud(); - const cloudId = cloud?.isCloudEnabled && cloud.cloudId; - const cloudUrl = cloudId && decodeCloudId(cloudId)?.elasticsearchUrl; - const flagsUrl = appContextService.getConfig()!.agents.elasticsearch.host; - const defaultUrl = 'http://localhost:9200'; - const defaultOutputUrl = cloudUrl || flagsUrl || defaultUrl; if (!outputs.saved_objects.length) { const newDefaultOutput = { ...DEFAULT_OUTPUT, - hosts: [defaultOutputUrl], + hosts: this.getDefaultESHosts(), ca_sha256: appContextService.getConfig()!.agents.elasticsearch.ca_sha256, } as NewOutput; @@ -50,6 +46,20 @@ class OutputService { }; } + public getDefaultESHosts(): string[] { + const cloud = appContextService.getCloud(); + const cloudId = cloud?.isCloudEnabled && cloud.cloudId; + const cloudUrl = cloudId && decodeCloudId(cloudId)?.elasticsearchUrl; + const cloudHosts = cloudUrl ? [cloudUrl] : undefined; + const flagHosts = + appContextService.getConfig()!.agents?.elasticsearch?.hosts && + appContextService.getConfig()!.agents.elasticsearch.hosts?.length + ? appContextService.getConfig()!.agents.elasticsearch.hosts + : undefined; + + return cloudHosts || flagHosts || DEFAULT_ES_HOSTS; + } + public async getDefaultOutputId(soClient: SavedObjectsClientContract) { const outputs = await this.getDefaultOutput(soClient); From 747b80b58ff054ca2a0a00824e7b3911a9268f55 Mon Sep 17 00:00:00 2001 From: Dan Panzarella Date: Thu, 3 Jun 2021 14:51:45 -0400 Subject: [PATCH 35/35] [Security Solution] [OLM] Endpoint pending actions API (#101269) --- .../common/endpoint/constants.ts | 1 + .../common/endpoint/schema/actions.ts | 9 + .../common/endpoint/types/actions.ts | 24 +- .../server/endpoint/routes/actions/index.ts | 18 +- .../endpoint/routes/actions/isolation.test.ts | 1 - .../endpoint/routes/actions/isolation.ts | 8 +- .../server/endpoint/routes/actions/mocks.ts | 140 +++++++++ .../endpoint/routes/actions/status.test.ts | 276 ++++++++++++++++++ .../server/endpoint/routes/actions/status.ts | 128 ++++++++ .../security_solution/server/plugin.ts | 8 +- 10 files changed, 592 insertions(+), 21 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index cdfc34c2e9cda..f4cf85e025237 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -36,3 +36,4 @@ export const UNISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/unisolate`; /** Endpoint Actions Log Routes */ export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`; +export const ACTION_STATUS_ROUTE = `/api/endpoint/action_status`; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts index 32affddf46294..09776b57ed8ea 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts @@ -28,3 +28,12 @@ export const EndpointActionLogRequestSchema = { agent_id: schema.string(), }), }; + +export const ActionStatusRequestSchema = { + query: schema.object({ + agent_ids: schema.oneOf([ + schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1, maxSize: 50 }), + schema.string({ minLength: 1 }), + ]), + }), +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index fcfda9c9a30d9..3c9be9a823c49 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -10,6 +10,11 @@ import { HostIsolationRequestSchema } from '../schema/actions'; export type ISOLATION_ACTIONS = 'isolate' | 'unisolate'; +export interface EndpointActionData { + command: ISOLATION_ACTIONS; + comment?: string; +} + export interface EndpointAction { action_id: string; '@timestamp': string; @@ -18,10 +23,7 @@ export interface EndpointAction { input_type: 'endpoint'; agents: string[]; user_id: string; - data: { - command: ISOLATION_ACTIONS; - comment?: string; - }; + data: EndpointActionData; } export interface EndpointActionResponse { @@ -32,11 +34,8 @@ export interface EndpointActionResponse { agent_id: string; started_at: string; completed_at: string; - error: string; - action_data: { - command: ISOLATION_ACTIONS; - comment?: string; - }; + error?: string; + action_data: EndpointActionData; } export type HostIsolationRequestBody = TypeOf; @@ -44,3 +43,10 @@ export type HostIsolationRequestBody = TypeOf { }, ]) ); - endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); licenseEmitter = new Subject(); licenseService = new LicenseService(); licenseService.start(licenseEmitter); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index 6842041128465..9dacc9767b88b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -117,7 +117,7 @@ export const isolationRequestHandler = function ( const actionID = uuid.v4(); let result; try { - result = await esClient.index({ + result = await esClient.index({ index: AGENT_ACTIONS_INDEX, body: { action_id: actionID, @@ -126,12 +126,12 @@ export const isolationRequestHandler = function ( type: 'INPUT_ACTION', input_type: 'endpoint', agents: agentIDs, - user_id: user?.username, + user_id: user!.username, data: { command: isolate ? 'isolate' : 'unisolate', - comment: req.body.comment, + comment: req.body.comment ?? undefined, }, - } as EndpointAction, + }, }); } catch (e) { return res.customError({ diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts new file mode 100644 index 0000000000000..34f7d140a78de --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable max-classes-per-file */ +/* eslint-disable @typescript-eslint/no-useless-constructor */ + +import { ApiResponse } from '@elastic/elasticsearch'; +import moment from 'moment'; +import uuid from 'uuid'; +import { + EndpointAction, + EndpointActionResponse, + ISOLATION_ACTIONS, +} from '../../../../common/endpoint/types'; + +export const mockSearchResult = (results: any = []): ApiResponse => { + return { + body: { + hits: { + hits: results.map((a: any) => ({ + _source: a, + })), + }, + }, + statusCode: 200, + headers: {}, + warnings: [], + meta: {} as any, + }; +}; + +export class MockAction { + private actionID: string = uuid.v4(); + private ts: moment.Moment = moment(); + private user: string = ''; + private agents: string[] = []; + private command: ISOLATION_ACTIONS = 'isolate'; + private comment?: string; + + constructor() {} + + public build(): EndpointAction { + return { + action_id: this.actionID, + '@timestamp': this.ts.toISOString(), + expiration: this.ts.add(2, 'weeks').toISOString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: this.agents, + user_id: this.user, + data: { + command: this.command, + comment: this.comment, + }, + }; + } + + public fromUser(u: string) { + this.user = u; + return this; + } + public withAgents(a: string[]) { + this.agents = a; + return this; + } + public withAgent(a: string) { + this.agents = [a]; + return this; + } + public withComment(c: string) { + this.comment = c; + return this; + } + public withAction(a: ISOLATION_ACTIONS) { + this.command = a; + return this; + } + public atTime(m: moment.Moment | Date) { + if (m instanceof Date) { + this.ts = moment(m); + } else { + this.ts = m; + } + return this; + } + public withID(id: string) { + this.actionID = id; + return this; + } +} + +export const aMockAction = (): MockAction => { + return new MockAction(); +}; + +export class MockResponse { + private actionID: string = uuid.v4(); + private ts: moment.Moment = moment(); + private started: moment.Moment = moment(); + private completed: moment.Moment = moment(); + private agent: string = ''; + private command: ISOLATION_ACTIONS = 'isolate'; + private comment?: string; + private error?: string; + + constructor() {} + + public build(): EndpointActionResponse { + return { + '@timestamp': this.ts.toISOString(), + action_id: this.actionID, + agent_id: this.agent, + started_at: this.started.toISOString(), + completed_at: this.completed.toISOString(), + error: this.error, + action_data: { + command: this.command, + comment: this.comment, + }, + }; + } + + public forAction(id: string) { + this.actionID = id; + return this; + } + public forAgent(id: string) { + this.agent = id; + return this; + } +} + +export const aMockResponse = (actionID: string, agentID: string): MockResponse => { + return new MockResponse().forAction(actionID).forAgent(agentID); +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts new file mode 100644 index 0000000000000..62e138ead7f81 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts @@ -0,0 +1,276 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { KibanaResponseFactory, RequestHandler, RouteConfig } from 'kibana/server'; +import { + elasticsearchServiceMock, + httpServerMock, + httpServiceMock, + loggingSystemMock, + savedObjectsClientMock, +} from 'src/core/server/mocks'; +import { ActionStatusRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { ACTION_STATUS_ROUTE } from '../../../../common/endpoint/constants'; +import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; +import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; +import { EndpointAppContextService } from '../../endpoint_app_context_services'; +import { + createMockEndpointAppContextServiceStartContract, + createRouteHandlerContext, +} from '../../mocks'; +import { registerActionStatusRoutes } from './status'; +import uuid from 'uuid'; +import { aMockAction, aMockResponse, MockAction, mockSearchResult, MockResponse } from './mocks'; + +describe('Endpoint Action Status', () => { + describe('schema', () => { + it('should require at least 1 agent ID', () => { + expect(() => { + ActionStatusRequestSchema.query.validate({}); // no agent_ids provided + }).toThrow(); + }); + + it('should accept a single agent ID', () => { + expect(() => { + ActionStatusRequestSchema.query.validate({ agent_ids: uuid.v4() }); + }).not.toThrow(); + }); + + it('should accept multiple agent IDs', () => { + expect(() => { + ActionStatusRequestSchema.query.validate({ agent_ids: [uuid.v4(), uuid.v4()] }); + }).not.toThrow(); + }); + it('should limit the maximum number of agent IDs', () => { + const tooManyCooks = new Array(200).fill(uuid.v4()); // all the same ID string + expect(() => { + ActionStatusRequestSchema.query.validate({ agent_ids: tooManyCooks }); + }).toThrow(); + }); + }); + + describe('response', () => { + let endpointAppContextService: EndpointAppContextService; + + // convenience for calling the route and handler for action status + let getPendingStatus: (reqParams?: any) => Promise>; + // convenience for injecting mock responses for actions index and responses + let havingActionsAndResponses: (actions: MockAction[], responses: any[]) => void; + + beforeEach(() => { + const esClientMock = elasticsearchServiceMock.createScopedClusterClient(); + const routerMock = httpServiceMock.createRouter(); + endpointAppContextService = new EndpointAppContextService(); + endpointAppContextService.start(createMockEndpointAppContextServiceStartContract()); + + registerActionStatusRoutes(routerMock, { + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), + }); + + getPendingStatus = async (reqParams?: any): Promise> => { + const req = httpServerMock.createKibanaRequest(reqParams); + const mockResponse = httpServerMock.createResponseFactory(); + const [, routeHandler]: [ + RouteConfig, + RequestHandler + ] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith(ACTION_STATUS_ROUTE))!; + await routeHandler( + createRouteHandlerContext(esClientMock, savedObjectsClientMock.create()), + req, + mockResponse + ); + + return mockResponse; + }; + + havingActionsAndResponses = (actions: MockAction[], responses: MockResponse[]) => { + esClientMock.asCurrentUser.search = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve(mockSearchResult(actions.map((a) => a.build()))) + ) + .mockImplementationOnce(() => + Promise.resolve(mockSearchResult(responses.map((r) => r.build()))) + ); + }; + }); + + afterEach(() => { + endpointAppContextService.stop(); + }); + + it('should include agent IDs in the output, even if they have no actions', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses([], []); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + }); + + it('should respond with a valid pending action', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses([aMockAction().withAgent(mockID)], []); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + }); + it('should include a total count of a pending action', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses( + [ + aMockAction().withAgent(mockID).withAction('isolate'), + aMockAction().withAgent(mockID).withAction('isolate'), + ], + [] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual( + 2 + ); + }); + it('should show multiple pending actions, and their counts', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses( + [ + aMockAction().withAgent(mockID).withAction('isolate'), + aMockAction().withAgent(mockID).withAction('isolate'), + aMockAction().withAgent(mockID).withAction('isolate'), + aMockAction().withAgent(mockID).withAction('unisolate'), + aMockAction().withAgent(mockID).withAction('unisolate'), + ], + [] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual( + 3 + ); + expect( + (response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.unisolate + ).toEqual(2); + }); + it('should calculate correct pending counts from grouped/bulked actions', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses( + [ + aMockAction() + .withAgents([mockID, 'IRRELEVANT-OTHER-AGENT', 'ANOTHER-POSSIBLE-AGENT']) + .withAction('isolate'), + aMockAction().withAgents([mockID, 'YET-ANOTHER-AGENT-ID']).withAction('isolate'), + aMockAction().withAgents(['YET-ANOTHER-AGENT-ID']).withAction('isolate'), // one WITHOUT our agent-under-test + ], + [] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual( + 2 + ); + }); + + it('should exclude actions that have responses from the pending count', async () => { + const mockAgentID = 'XYZABC-000'; + const actionID = 'some-known-actionid'; + havingActionsAndResponses( + [ + aMockAction().withAgent(mockAgentID).withAction('isolate'), + aMockAction().withAgent(mockAgentID).withAction('isolate').withID(actionID), + ], + [aMockResponse(actionID, mockAgentID)] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [mockAgentID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockAgentID); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual( + 1 + ); + }); + + it('should have accurate counts for multiple agents, bulk actions, and responses', async () => { + const agentOne = 'XYZABC-000'; + const agentTwo = 'DEADBEEF'; + const agentThree = 'IDIDIDID'; + + const actionTwoID = 'ID-TWO'; + havingActionsAndResponses( + [ + aMockAction().withAgents([agentOne, agentTwo, agentThree]).withAction('isolate'), + aMockAction() + .withAgents([agentTwo, agentThree]) + .withAction('isolate') + .withID(actionTwoID), + aMockAction().withAgents([agentThree]).withAction('isolate'), + ], + [aMockResponse(actionTwoID, agentThree)] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [agentOne, agentTwo, agentThree], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(3); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({ + agent_id: agentOne, + pending_actions: { + isolate: 1, + }, + }); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({ + agent_id: agentTwo, + pending_actions: { + isolate: 2, + }, + }); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({ + agent_id: agentThree, + pending_actions: { + isolate: 2, // present in all three actions, but second one has a response, therefore not pending + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts new file mode 100644 index 0000000000000..faaf41962a96c --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RequestHandler } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; +import { + EndpointAction, + EndpointActionResponse, + PendingActionsResponse, +} from '../../../../common/endpoint/types'; +import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common'; +import { ActionStatusRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { ACTION_STATUS_ROUTE } from '../../../../common/endpoint/constants'; +import { + SecuritySolutionPluginRouter, + SecuritySolutionRequestHandlerContext, +} from '../../../types'; +import { EndpointAppContext } from '../../types'; + +/** + * Registers routes for checking status of endpoints based on pending actions + */ +export function registerActionStatusRoutes( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext +) { + router.get( + { + path: ACTION_STATUS_ROUTE, + validate: ActionStatusRequestSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + actionStatusRequestHandler(endpointContext) + ); +} + +export const actionStatusRequestHandler = function ( + endpointContext: EndpointAppContext +): RequestHandler< + unknown, + TypeOf, + unknown, + SecuritySolutionRequestHandlerContext +> { + return async (context, req, res) => { + const esClient = context.core.elasticsearch.client.asCurrentUser; + const agentIDs: string[] = Array.isArray(req.query.agent_ids) + ? [...new Set(req.query.agent_ids)] + : [req.query.agent_ids]; + + // retrieve the unexpired actions for the given hosts + const recentActionResults = await esClient.search( + { + index: AGENT_ACTIONS_INDEX, + body: { + query: { + bool: { + filter: [ + { term: { type: 'INPUT_ACTION' } }, // actions that are directed at agent children + { term: { input_type: 'endpoint' } }, // filter for agent->endpoint actions + { range: { expiration: { gte: 'now' } } }, // that have not expired yet + { terms: { agents: agentIDs } }, // for the requested agent IDs + ], + }, + }, + }, + }, + { + ignore: [404], + } + ); + const pendingActions = + recentActionResults.body?.hits?.hits?.map((a): EndpointAction => a._source!) || []; + + // retrieve any responses to those action IDs from these agents + const actionIDs = pendingActions.map((a) => a.action_id); + const responseResults = await esClient.search( + { + index: '.fleet-actions-results', + body: { + query: { + bool: { + filter: [ + { terms: { action_id: actionIDs } }, // get results for these actions + { terms: { agent_id: agentIDs } }, // ignoring responses from agents we're not looking for + ], + }, + }, + }, + }, + { + ignore: [404], + } + ); + const actionResponses = responseResults.body?.hits?.hits?.map((a) => a._source!) || []; + + // respond with action-count per agent + const response = agentIDs.map((aid) => { + const responseIDsFromAgent = actionResponses + .filter((r) => r.agent_id === aid) + .map((r) => r.action_id); + return { + agent_id: aid, + pending_actions: pendingActions + .filter((a) => a.agents.includes(aid) && !responseIDsFromAgent.includes(a.action_id)) + .map((a) => a.data.command) + .reduce((acc, cur) => { + if (cur in acc) { + acc[cur] += 1; + } else { + acc[cur] = 1; + } + return acc; + }, {} as PendingActionsResponse['pending_actions']), + } as PendingActionsResponse; + }); + + return res.ok({ + body: { + data: response, + }, + }); + }; +}; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 732ae48223421..5aa298d6789be 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -75,10 +75,7 @@ import { registerEndpointRoutes } from './endpoint/routes/metadata'; import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency'; import { registerResolverRoutes } from './endpoint/routes/resolver'; import { registerPolicyRoutes } from './endpoint/routes/policy'; -import { - registerHostIsolationRoutes, - registerActionAuditLogRoutes, -} from './endpoint/routes/actions'; +import { registerActionRoutes } from './endpoint/routes/actions'; import { EndpointArtifactClient, ManifestManager } from './endpoint/services'; import { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; import { EndpointAppContext } from './endpoint/types'; @@ -293,8 +290,7 @@ export class Plugin implements IPlugin