From e235321903b0d8824d9850710392158d19b1dbd5 Mon Sep 17 00:00:00 2001 From: Bhavya RM Date: Mon, 23 Mar 2020 09:45:19 -0400 Subject: [PATCH 01/35] a11y tests for login and logout (#60799) a11y login screen --- x-pack/test/accessibility/apps/login_page.ts | 21 +++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/x-pack/test/accessibility/apps/login_page.ts b/x-pack/test/accessibility/apps/login_page.ts index 5b18b6be9e3a4..8c673bb332d91 100644 --- a/x-pack/test/accessibility/apps/login_page.ts +++ b/x-pack/test/accessibility/apps/login_page.ts @@ -28,14 +28,33 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.security.forceLogout(); }); - it('meets a11y requirements', async () => { + it('login page meets a11y requirements', async () => { await PageObjects.common.navigateToApp('login'); await retry.waitFor( 'login page visible', async () => await testSubjects.exists('loginSubmit') ); + await a11y.testAppSnapshot(); + }); + + it('User can login with a11y requirements', async () => { + await PageObjects.security.login(); + await a11y.testAppSnapshot(); + }); + + it('Wrong credentials message meets a11y requirements', async () => { + await PageObjects.security.loginPage.login('wrong-user', 'wrong-password', { + expectSuccess: false, + }); + await PageObjects.security.loginPage.getErrorMessage(); + await a11y.testAppSnapshot(); + }); + it('Logout message acknowledges a11y requirements', async () => { + await PageObjects.security.login(); + await PageObjects.security.logout(); + await testSubjects.getVisibleText('loginInfoMessage'); await a11y.testAppSnapshot(); }); }); From a5aafc039d8f445293ddf43d89ab58bdab61f83c Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Mon, 23 Mar 2020 13:56:26 +0000 Subject: [PATCH 02/35] [Alerting] Fixes mistake in empty list assertion (#60896) --- .../page_objects/triggers_actions_ui_page.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts index 6c41c2cab801e..2a50c0117eae9 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts @@ -111,11 +111,11 @@ export function TriggersActionsPageProvider({ getService }: FtrProviderContext) const table = await find.byCssSelector('[data-test-subj="alertsList"] table'); const $ = await table.parseDomContent(); const rows = $.findTestSubjects('alert-row').toArray(); - expect(rows.length).not.to.eql(0); + expect(rows.length).to.eql(0); const emptyRow = await find.byCssSelector( '[data-test-subj="alertsList"] table .euiTableRow' ); - expect(await emptyRow.getVisibleText()).not.to.eql('No items found'); + expect(await emptyRow.getVisibleText()).to.eql('No items found'); }); return true; }, From c7b0ade01d7d01ebe4d9329e517a44be20ea833b Mon Sep 17 00:00:00 2001 From: Dmitry Lemeshko Date: Mon, 23 Mar 2020 17:06:19 +0300 Subject: [PATCH 03/35] skip flaky functional test (#60898) --- x-pack/test/functional/apps/uptime/settings.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/uptime/settings.ts b/x-pack/test/functional/apps/uptime/settings.ts index 0e804dd161c6b..aafb145a1b9b0 100644 --- a/x-pack/test/functional/apps/uptime/settings.ts +++ b/x-pack/test/functional/apps/uptime/settings.ts @@ -16,7 +16,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['uptime']); const es = getService('es'); - describe('uptime settings page', () => { + // Flaky https://github.com/elastic/kibana/issues/60866 + describe.skip('uptime settings page', () => { const settingsPage = () => pageObjects.uptime.settings; beforeEach('navigate to clean app root', async () => { // make 10 checks From c22dbb17641b5ea9a9ae0742e623f7b5c53ffbfc Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Mon, 23 Mar 2020 10:29:33 -0400 Subject: [PATCH 04/35] [CI] Add error steps and help links to PR comments (#60772) --- vars/githubPr.groovy | 19 +++++++++++++++++++ vars/jenkinsApi.groovy | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 vars/jenkinsApi.groovy diff --git a/vars/githubPr.groovy b/vars/githubPr.groovy index 0176424452d07..965fb1d4e108e 100644 --- a/vars/githubPr.groovy +++ b/vars/githubPr.groovy @@ -169,7 +169,20 @@ def getNextCommentMessage(previousCommentInfo = [:]) { ## :broken_heart: Build Failed * [continuous-integration/kibana-ci/pull-request](${env.BUILD_URL}) * Commit: ${getCommitHash()} + * [Pipeline Steps](${env.BUILD_URL}flowGraphTable) (look for red circles / failed steps) + * [Interpreting CI Failures](https://www.elastic.co/guide/en/kibana/current/interpreting-ci-failures.html) """ + + try { + def steps = getFailedSteps() + if (steps?.size() > 0) { + def list = steps.collect { "* [${it.displayName}](${it.logs})" }.join("\n") + messages << "### Failed CI Steps\n${list}" + } + } catch (ex) { + buildUtils.printStacktrace(ex) + print "Error retrieving failed pipeline steps for PR comment, will skip this section" + } } messages << getTestFailuresMessage() @@ -220,3 +233,9 @@ def deleteComment(commentId) { def getCommitHash() { return env.ghprbActualCommit } + +def getFailedSteps() { + return jenkinsApi.getFailedSteps()?.findAll { step -> + step.displayName != 'Check out from version control' + } +} diff --git a/vars/jenkinsApi.groovy b/vars/jenkinsApi.groovy new file mode 100644 index 0000000000000..1ea4c3dd76b8d --- /dev/null +++ b/vars/jenkinsApi.groovy @@ -0,0 +1,21 @@ +def getSteps() { + def url = "${env.BUILD_URL}api/json?tree=actions[nodes[iconColor,running,displayName,id,parents]]" + def responseRaw = httpRequest([ method: "GET", url: url ]) + def response = toJSON(responseRaw) + + def graphAction = response?.actions?.find { it._class == "org.jenkinsci.plugins.workflow.job.views.FlowGraphAction" } + + return graphAction?.nodes +} + +def getFailedSteps() { + def steps = getSteps() + def failedSteps = steps?.findAll { it.iconColor == "red" && it._class == "org.jenkinsci.plugins.workflow.cps.nodes.StepAtomNode" } + failedSteps.each { step -> + step.logs = "${env.BUILD_URL}execution/node/${step.id}/log".toString() + } + + return failedSteps +} + +return this From 42539a56ebd3dafc9dec92052d81508be8386377 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Mon, 23 Mar 2020 10:30:14 -0400 Subject: [PATCH 05/35] Only run xpack siem cypress in PRs when there are siem changes (#60661) --- Jenkinsfile | 7 ++++- vars/prChanges.groovy | 11 ++++++-- vars/whenChanged.groovy | 57 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 vars/whenChanged.groovy diff --git a/Jenkinsfile b/Jenkinsfile index d43da6e0bee04..79d3c93006cb6 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -40,7 +40,12 @@ kibanaPipeline(timeoutMinutes: 135, checkPrChanges: true) { 'xpack-ciGroup9': kibanaPipeline.xpackCiGroupProcess(9), 'xpack-ciGroup10': kibanaPipeline.xpackCiGroupProcess(10), 'xpack-accessibility': kibanaPipeline.functionalTestProcess('xpack-accessibility', './test/scripts/jenkins_xpack_accessibility.sh'), - 'xpack-siemCypress': kibanaPipeline.functionalTestProcess('xpack-siemCypress', './test/scripts/jenkins_siem_cypress.sh'), + 'xpack-siemCypress': { processNumber -> + whenChanged(['x-pack/legacy/plugins/siem/', 'x-pack/test/siem_cypress/']) { + kibanaPipeline.functionalTestProcess('xpack-siemCypress', './test/scripts/jenkins_siem_cypress.sh')(processNumber) + } + }, + // 'xpack-visualRegression': kibanaPipeline.functionalTestProcess('xpack-visualRegression', './test/scripts/jenkins_xpack_visual_regression.sh'), ]), ]) diff --git a/vars/prChanges.groovy b/vars/prChanges.groovy index a9eb9027a0597..d7f46ee7be23e 100644 --- a/vars/prChanges.groovy +++ b/vars/prChanges.groovy @@ -1,3 +1,6 @@ +import groovy.transform.Field + +public static @Field PR_CHANGES_CACHE = null def getSkippablePaths() { return [ @@ -36,9 +39,13 @@ def areChangesSkippable() { } def getChanges() { - withGithubCredentials { - return githubPrs.getChanges(env.ghprbPullId) + if (!PR_CHANGES_CACHE && env.ghprbPullId) { + withGithubCredentials { + PR_CHANGES_CACHE = githubPrs.getChanges(env.ghprbPullId) + } } + + return PR_CHANGES_CACHE } def getChangedFiles() { diff --git a/vars/whenChanged.groovy b/vars/whenChanged.groovy new file mode 100644 index 0000000000000..c58ec83f2b051 --- /dev/null +++ b/vars/whenChanged.groovy @@ -0,0 +1,57 @@ +/* + whenChanged('some/path') { yourCode() } can be used to execute pipeline code in PRs only when changes are detected on paths that you specify. + The specified code blocks will also always be executed during the non-PR jobs for tracked branches. + + You have the option of passing in path prefixes, or regexes. Single or multiple. + Path specifications are NOT globby, they are only prefixes. + Specifying multiple will treat them as ORs. + + Example Usages: + whenChanged('a/path/prefix/') { someCode() } + whenChanged(startsWith: 'a/path/prefix/') { someCode() } // Same as above + whenChanged(['prefix1/', 'prefix2/']) { someCode() } + whenChanged(regex: /\.test\.js$/) { someCode() } + whenChanged(regex: [/abc/, /xyz/]) { someCode() } +*/ + +def call(String startsWithString, Closure closure) { + return whenChanged([ startsWith: startsWithString ], closure) +} + +def call(List startsWithStrings, Closure closure) { + return whenChanged([ startsWith: startsWithStrings ], closure) +} + +def call(Map params, Closure closure) { + if (!githubPr.isPr()) { + return closure() + } + + def files = prChanges.getChangedFiles() + def hasMatch = false + + if (params.regex) { + params.regex = [] + params.regex + print "Checking PR for changes that match: ${params.regex.join(', ')}" + hasMatch = !!files.find { file -> + params.regex.find { regex -> file =~ regex } + } + } + + if (!hasMatch && params.startsWith) { + params.startsWith = [] + params.startsWith + print "Checking PR for changes that start with: ${params.startsWith.join(', ')}" + hasMatch = !!files.find { file -> + params.startsWith.find { str -> file.startsWith(str) } + } + } + + if (hasMatch) { + print "Changes found, executing pipeline." + closure() + } else { + print "No changes found, skipping." + } +} + +return this From 8572e3f18fb8f45aad96b76f5b3e1bf3873f04e4 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Mon, 23 Mar 2020 10:42:40 -0400 Subject: [PATCH 06/35] [Remote clustersadopt changes to remote info API (#60795) --- .../common/lib/cluster_serialization.test.ts | 35 ++++++++++++++++++- .../common/lib/cluster_serialization.ts | 15 ++++---- .../server/routes/api/get_route.ts | 7 ---- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.test.ts b/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.test.ts index 5be6ed8828e6f..10b3dbbd9b452 100644 --- a/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.test.ts +++ b/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.test.ts @@ -13,11 +13,12 @@ describe('cluster_serialization', () => { expect(() => deserializeCluster('foo', 'bar')).toThrowError(); }); - it('should deserialize a complete cluster object', () => { + it('should deserialize a complete default cluster object', () => { expect( deserializeCluster('test_cluster', { seeds: ['localhost:9300'], connected: true, + mode: 'sniff', num_nodes_connected: 1, max_connections_per_cluster: 3, initial_connect_timeout: '30s', @@ -29,6 +30,7 @@ describe('cluster_serialization', () => { }) ).toEqual({ name: 'test_cluster', + mode: 'sniff', seeds: ['localhost:9300'], isConnected: true, connectedNodesCount: 1, @@ -40,6 +42,37 @@ describe('cluster_serialization', () => { }); }); + it('should deserialize a complete "proxy" mode cluster object', () => { + expect( + deserializeCluster('test_cluster', { + proxy_address: 'localhost:9300', + mode: 'proxy', + connected: true, + num_proxy_sockets_connected: 1, + max_proxy_socket_connections: 3, + initial_connect_timeout: '30s', + skip_unavailable: false, + server_name: 'my_server_name', + transport: { + ping_schedule: '-1', + compress: false, + }, + }) + ).toEqual({ + name: 'test_cluster', + mode: 'proxy', + proxyAddress: 'localhost:9300', + isConnected: true, + connectedSocketsCount: 1, + proxySocketConnections: 3, + initialConnectTimeout: '30s', + skipUnavailable: false, + transportPingSchedule: '-1', + transportCompress: false, + serverName: 'my_server_name', + }); + }); + it('should deserialize a cluster object without transport information', () => { expect( deserializeCluster('test_cluster', { diff --git a/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.ts b/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.ts index 53dc72eb1695a..fbea311cdeefa 100644 --- a/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.ts +++ b/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.ts @@ -18,9 +18,10 @@ export interface ClusterEs { ping_schedule?: string; compress?: boolean; }; - address?: string; - max_socket_connections?: number; - num_sockets_connected?: number; + proxy_address?: string; + max_proxy_socket_connections?: number; + num_proxy_sockets_connected?: number; + server_name?: string; } export interface Cluster { @@ -77,9 +78,10 @@ export function deserializeCluster( initial_connect_timeout: initialConnectTimeout, skip_unavailable: skipUnavailable, transport, - address: proxyAddress, - max_socket_connections: proxySocketConnections, - num_sockets_connected: connectedSocketsCount, + proxy_address: proxyAddress, + max_proxy_socket_connections: proxySocketConnections, + num_proxy_sockets_connected: connectedSocketsCount, + server_name: serverName, } = esClusterObject; let deserializedClusterObject: Cluster = { @@ -94,6 +96,7 @@ export function deserializeCluster( proxyAddress, proxySocketConnections, connectedSocketsCount, + serverName, }; if (transport) { diff --git a/x-pack/plugins/remote_clusters/server/routes/api/get_route.ts b/x-pack/plugins/remote_clusters/server/routes/api/get_route.ts index abd44977d8e46..8938f342674f0 100644 --- a/x-pack/plugins/remote_clusters/server/routes/api/get_route.ts +++ b/x-pack/plugins/remote_clusters/server/routes/api/get_route.ts @@ -45,16 +45,9 @@ export const register = (deps: RouteDependencies): void => { ? get(clusterSettings, `persistent.cluster.remote[${clusterName}].proxy`, undefined) : undefined; - // server_name is not available via the GET /_remote/info API, so we get it from the cluster settings - // Per https://github.com/elastic/kibana/pull/26067#issuecomment-441848124, we only look at persistent settings - const serverName = isPersistent - ? get(clusterSettings, `persistent.cluster.remote[${clusterName}].server_name`, undefined) - : undefined; - return { ...deserializeCluster(clusterName, cluster, deprecatedProxyAddress), isConfiguredByNode, - serverName, }; }); From a79087769471837440efb9c333b56fc04a809e23 Mon Sep 17 00:00:00 2001 From: Phillip Burch Date: Mon, 23 Mar 2020 10:02:11 -0500 Subject: [PATCH 07/35] [Metrics UI] Alerting for metrics explorer and inventory (#58779) * Add flyout with expressions * Integrate frontend with backend * Extended AlertContextValue with metadata optional property * Progress * Pre-fill criteria with current page filters * Better validation. Naming for clarity * Fix types for flyout * Respect the groupby property in metric explorer * Fix lint errors * Fix text, add toast notifications * Fix tests. Make sure update handles predefined expressions * Dynamically load source from alert flyout * Remove unused import * Simplify and add group by functionality * Remove unecessary useEffect * disable exhastive deps * Remove unecessary useEffect * change language * Implement design feedback * Add alert dropdown to the header and snapshot screen * Remove icon * Remove unused props. Code cleanup * Remove unused values * Fix formatted message id * Remove create alert option for now. * Fix type issue * Add rate, card and count as aggs * Fix types Co-authored-by: Yuliia Naumenko Co-authored-by: Elastic Machine Co-authored-by: Henry Harding --- x-pack/plugins/infra/kibana.json | 3 +- .../plugins/infra/public/apps/start_app.tsx | 36 +- .../alerting/metrics/alert_dropdown.tsx | 62 +++ .../alerting/metrics/alert_flyout.tsx | 53 ++ .../alerting/metrics/expression.tsx | 473 ++++++++++++++++++ .../metrics/metric_threshold_alert_type.ts | 24 + .../alerting/metrics/validation.tsx | 80 +++ .../chart_context_menu.test.tsx | 2 +- .../metrics_explorer/chart_context_menu.tsx | 44 +- .../components/metrics_explorer/kuery_bar.tsx | 19 +- .../components/metrics_explorer/toolbar.tsx | 1 + .../components/waffle/node_context_menu.tsx | 83 +-- .../public/pages/infrastructure/index.tsx | 60 ++- x-pack/plugins/infra/public/plugin.ts | 11 +- .../public/utils/triggers_actions_context.tsx | 32 ++ 15 files changed, 887 insertions(+), 96 deletions(-) create mode 100644 x-pack/plugins/infra/public/components/alerting/metrics/alert_dropdown.tsx create mode 100644 x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx create mode 100644 x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx create mode 100644 x-pack/plugins/infra/public/components/alerting/metrics/metric_threshold_alert_type.ts create mode 100644 x-pack/plugins/infra/public/components/alerting/metrics/validation.tsx create mode 100644 x-pack/plugins/infra/public/utils/triggers_actions_context.tsx diff --git a/x-pack/plugins/infra/kibana.json b/x-pack/plugins/infra/kibana.json index bb40d65d311e8..b8796ad7a358e 100644 --- a/x-pack/plugins/infra/kibana.json +++ b/x-pack/plugins/infra/kibana.json @@ -11,7 +11,8 @@ "data", "dataEnhanced", "metrics", - "alerting" + "alerting", + "triggers_actions_ui" ], "server": true, "ui": true, diff --git a/x-pack/plugins/infra/public/apps/start_app.tsx b/x-pack/plugins/infra/public/apps/start_app.tsx index a797e4c9d4ba7..a986ee6ece352 100644 --- a/x-pack/plugins/infra/public/apps/start_app.tsx +++ b/x-pack/plugins/infra/public/apps/start_app.tsx @@ -15,7 +15,8 @@ import { CoreStart, AppMountParameters } from 'kibana/public'; // TODO use theme provided from parentApp when kibana supports it import { EuiErrorBoundary } from '@elastic/eui'; -import { EuiThemeProvider } from '../../../observability/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { EuiThemeProvider } from '../../../observability/public/typings/eui_styled_components'; import { InfraFrontendLibs } from '../lib/lib'; import { createStore } from '../store'; import { ApolloClientContext } from '../utils/apollo_context'; @@ -26,6 +27,8 @@ import { KibanaContextProvider, } from '../../../../../src/plugins/kibana_react/public'; import { AppRouter } from '../routers'; +import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public'; +import { TriggersActionsProvider } from '../utils/triggers_actions_context'; import '../index.scss'; export const CONTAINER_CLASSNAME = 'infra-container-element'; @@ -35,7 +38,8 @@ export async function startApp( core: CoreStart, plugins: object, params: AppMountParameters, - Router: AppRouter + Router: AppRouter, + triggersActionsUI: TriggersAndActionsUIPublicPluginSetup ) { const { element, appBasePath } = params; const history = createBrowserHistory({ basename: appBasePath }); @@ -51,19 +55,21 @@ export async function startApp( return ( - - - - - - - - - - - - - + + + + + + + + + + + + + + + ); diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/alert_dropdown.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/alert_dropdown.tsx new file mode 100644 index 0000000000000..0a464d91fbe06 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/metrics/alert_dropdown.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { EuiPopover, EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AlertFlyout } from './alert_flyout'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; + +export const AlertDropdown = () => { + const [popoverOpen, setPopoverOpen] = useState(false); + const [flyoutVisible, setFlyoutVisible] = useState(false); + const kibana = useKibana(); + + const closePopover = useCallback(() => { + setPopoverOpen(false); + }, [setPopoverOpen]); + + const openPopover = useCallback(() => { + setPopoverOpen(true); + }, [setPopoverOpen]); + + const menuItems = useMemo(() => { + return [ + setFlyoutVisible(true)}> + + , + + + , + ]; + }, [kibana.services]); + + return ( + <> + + + + } + isOpen={popoverOpen} + closePopover={closePopover} + > + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx new file mode 100644 index 0000000000000..a00d63af8aac2 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { AlertsContextProvider, AlertAdd } from '../../../../../triggers_actions_ui/public'; +import { TriggerActionsContext } from '../../../utils/triggers_actions_context'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/metric_threshold/types'; +import { MetricsExplorerOptions } from '../../../containers/metrics_explorer/use_metrics_explorer_options'; +import { MetricsExplorerSeries } from '../../../../common/http_api/metrics_explorer'; + +interface Props { + visible?: boolean; + options?: Partial; + series?: MetricsExplorerSeries; + setVisible: React.Dispatch>; +} + +export const AlertFlyout = (props: Props) => { + const { triggersActionsUI } = useContext(TriggerActionsContext); + const { services } = useKibana(); + + return ( + <> + {triggersActionsUI && ( + + + + )} + + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx new file mode 100644 index 0000000000000..ea8dd1484a670 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx @@ -0,0 +1,473 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useMemo, useEffect, useState } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonIcon, + EuiSpacer, + EuiText, + EuiFormRow, + EuiButtonEmpty, +} from '@elastic/eui'; +import { IFieldType } from 'src/plugins/data/public'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { euiStyled } from '../../../../../observability/public'; +import { + WhenExpression, + OfExpression, + ThresholdExpression, + ForLastExpression, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../triggers_actions_ui/public/common'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertsContextValue } from '../../../../../triggers_actions_ui/public/application/context/alerts_context'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { MetricsExplorerOptions } from '../../../containers/metrics_explorer/use_metrics_explorer_options'; +import { MetricsExplorerKueryBar } from '../../metrics_explorer/kuery_bar'; +import { MetricsExplorerSeries } from '../../../../common/http_api/metrics_explorer'; +import { useSource } from '../../../containers/source'; +import { MetricsExplorerGroupBy } from '../../metrics_explorer/group_by'; + +export interface MetricExpression { + aggType?: string; + metric?: string; + comparator?: Comparator; + threshold?: number[]; + timeSize?: number; + timeUnit?: TimeUnit; + indexPattern?: string; +} + +interface AlertContextMeta { + currentOptions?: Partial; + series?: MetricsExplorerSeries; +} + +interface Props { + errors: IErrorObject[]; + alertParams: { + criteria: MetricExpression[]; + groupBy?: string; + filterQuery?: string; + }; + alertsContext: AlertsContextValue; + setAlertParams(key: string, value: any): void; + setAlertProperty(key: string, value: any): void; +} + +type Comparator = '>' | '>=' | 'between' | '<' | '<='; +type TimeUnit = 's' | 'm' | 'h' | 'd'; + +export const Expressions: React.FC = props => { + const { setAlertParams, alertParams, errors, alertsContext } = props; + const { source, createDerivedIndexPattern } = useSource({ sourceId: 'default' }); + const [timeSize, setTimeSize] = useState(1); + const [timeUnit, setTimeUnit] = useState('s'); + + const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ + createDerivedIndexPattern, + ]); + + const options = useMemo(() => { + if (alertsContext.metadata?.currentOptions?.metrics) { + return alertsContext.metadata.currentOptions as MetricsExplorerOptions; + } else { + return { + metrics: [], + aggregation: 'avg', + }; + } + }, [alertsContext.metadata]); + + const defaultExpression = useMemo( + () => ({ + aggType: AGGREGATION_TYPES.MAX, + comparator: '>', + threshold: [], + timeSize: 1, + timeUnit: 's', + indexPattern: source?.configuration.metricAlias, + }), + [source] + ); + + const updateParams = useCallback( + (id, e: MetricExpression) => { + const exp = alertParams.criteria ? alertParams.criteria.slice() : []; + exp[id] = { ...exp[id], ...e }; + setAlertParams('criteria', exp); + }, + [setAlertParams, alertParams.criteria] + ); + + const addExpression = useCallback(() => { + const exp = alertParams.criteria.slice(); + exp.push(defaultExpression); + setAlertParams('criteria', exp); + }, [setAlertParams, alertParams.criteria, defaultExpression]); + + const removeExpression = useCallback( + (id: number) => { + const exp = alertParams.criteria.slice(); + if (exp.length > 1) { + exp.splice(id, 1); + setAlertParams('criteria', exp); + } + }, + [setAlertParams, alertParams.criteria] + ); + + const onFilterQuerySubmit = useCallback( + (filter: any) => { + setAlertParams('filterQuery', filter); + }, + [setAlertParams] + ); + + const onGroupByChange = useCallback( + (group: string | null) => { + setAlertParams('groupBy', group || undefined); + }, + [setAlertParams] + ); + + const emptyError = useMemo(() => { + return { + aggField: [], + timeSizeUnit: [], + timeWindowSize: [], + }; + }, []); + + const updateTimeSize = useCallback( + (ts: number | undefined) => { + const criteria = alertParams.criteria.map(c => ({ + ...c, + timeSize: ts, + })); + setTimeSize(ts || undefined); + setAlertParams('criteria', criteria); + }, + [alertParams.criteria, setAlertParams] + ); + + const updateTimeUnit = useCallback( + (tu: string) => { + const criteria = alertParams.criteria.map(c => ({ + ...c, + timeUnit: tu, + })); + setTimeUnit(tu as TimeUnit); + setAlertParams('criteria', criteria); + }, + [alertParams.criteria, setAlertParams] + ); + + useEffect(() => { + const md = alertsContext.metadata; + if (md) { + if (md.currentOptions?.metrics) { + setAlertParams( + 'criteria', + md.currentOptions.metrics.map(metric => ({ + metric: metric.field, + comparator: '>', + threshold: [], + timeSize, + timeUnit, + indexPattern: source?.configuration.metricAlias, + aggType: metric.aggregation, + })) + ); + } else { + setAlertParams('criteria', [defaultExpression]); + } + + if (md.currentOptions) { + if (md.currentOptions.filterQuery) { + setAlertParams('filterQuery', md.currentOptions.filterQuery); + } else if (md.currentOptions.groupBy && md.series) { + const filter = `${md.currentOptions.groupBy}: "${md.series.id}"`; + setAlertParams('filterQuery', filter); + } + + setAlertParams('groupBy', md.currentOptions.groupBy); + } + } + }, [alertsContext.metadata, defaultExpression, source]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + <> + + +

+ +

+
+ + {alertParams.criteria && + alertParams.criteria.map((e, idx) => { + return ( + 1} + fields={derivedIndexPattern.fields} + remove={removeExpression} + addExpression={addExpression} + key={idx} // idx's don't usually make good key's but here the index has semantic meaning + expressionId={idx} + setAlertParams={updateParams} + errors={errors[idx] || emptyError} + expression={e || {}} + /> + ); + })} + + + +
+ + + +
+ + + + + + + + + + {alertsContext.metadata && ( + + + + )} + + ); +}; + +interface ExpressionRowProps { + fields: IFieldType[]; + expressionId: number; + expression: MetricExpression; + errors: IErrorObject; + canDelete: boolean; + addExpression(): void; + remove(id: number): void; + setAlertParams(id: number, params: MetricExpression): void; +} + +const StyledExpressionRow = euiStyled(EuiFlexGroup)` + display: flex; + flex-wrap: wrap; + margin: 0 -${props => props.theme.eui.euiSizeXS}; +`; + +const StyledExpression = euiStyled.div` + padding: 0 ${props => props.theme.eui.euiSizeXS}; +`; + +export const ExpressionRow: React.FC = props => { + const { setAlertParams, expression, errors, expressionId, remove, fields, canDelete } = props; + const { aggType = AGGREGATION_TYPES.MAX, metric, comparator = '>', threshold = [] } = expression; + + const updateAggType = useCallback( + (at: string) => { + setAlertParams(expressionId, { ...expression, aggType: at }); + }, + [expressionId, expression, setAlertParams] + ); + + const updateMetric = useCallback( + (m?: string) => { + setAlertParams(expressionId, { ...expression, metric: m }); + }, + [expressionId, expression, setAlertParams] + ); + + const updateComparator = useCallback( + (c?: string) => { + setAlertParams(expressionId, { ...expression, comparator: c as Comparator }); + }, + [expressionId, expression, setAlertParams] + ); + + const updateThreshold = useCallback( + t => { + setAlertParams(expressionId, { ...expression, threshold: t }); + }, + [expressionId, expression, setAlertParams] + ); + + return ( + <> + + + + + + + {aggType !== 'count' && ( + + ({ + normalizedType: f.type, + name: f.name, + }))} + aggType={aggType} + errors={errors} + onChangeSelectedAggField={updateMetric} + /> + + )} + + '} + threshold={threshold} + onChangeSelectedThresholdComparator={updateComparator} + onChangeSelectedThreshold={updateThreshold} + errors={errors} + /> + + + + {canDelete && ( + + remove(expressionId)} + /> + + )} + + + + ); +}; + +enum AGGREGATION_TYPES { + COUNT = 'count', + AVERAGE = 'avg', + SUM = 'sum', + MIN = 'min', + MAX = 'max', + RATE = 'rate', + CARDINALITY = 'cardinality', +} + +export const aggregationType: { [key: string]: any } = { + avg: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.avg', { + defaultMessage: 'Average', + }), + fieldRequired: true, + validNormalizedTypes: ['number'], + value: AGGREGATION_TYPES.AVERAGE, + }, + max: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.max', { + defaultMessage: 'Max', + }), + fieldRequired: true, + validNormalizedTypes: ['number', 'date'], + value: AGGREGATION_TYPES.MAX, + }, + min: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.min', { + defaultMessage: 'Min', + }), + fieldRequired: true, + validNormalizedTypes: ['number', 'date'], + value: AGGREGATION_TYPES.MIN, + }, + cardinality: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.cardinality', { + defaultMessage: 'Cardinality', + }), + fieldRequired: false, + value: AGGREGATION_TYPES.CARDINALITY, + validNormalizedTypes: ['number'], + }, + rate: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.rate', { + defaultMessage: 'Rate', + }), + fieldRequired: false, + value: AGGREGATION_TYPES.RATE, + validNormalizedTypes: ['number'], + }, + count: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.count', { + defaultMessage: 'Document count', + }), + fieldRequired: false, + value: AGGREGATION_TYPES.COUNT, + validNormalizedTypes: ['number'], + }, +}; diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/metric_threshold_alert_type.ts b/x-pack/plugins/infra/public/components/alerting/metrics/metric_threshold_alert_type.ts new file mode 100644 index 0000000000000..d3b5aaa7c8796 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/metrics/metric_threshold_alert_type.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertTypeModel } from '../../../../../triggers_actions_ui/public/types'; +import { Expressions } from './expression'; +import { validateMetricThreshold } from './validation'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/metric_threshold/types'; + +export function getAlertType(): AlertTypeModel { + return { + id: METRIC_THRESHOLD_ALERT_TYPE_ID, + name: i18n.translate('xpack.infra.metrics.alertFlyout.alertName', { + defaultMessage: 'Alert Trigger', + }), + iconClass: 'bell', + alertParamsExpression: Expressions, + validate: validateMetricThreshold, + }; +} diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/validation.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/validation.tsx new file mode 100644 index 0000000000000..0f5b07f8c0e13 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/metrics/validation.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths + +import { MetricExpression } from './expression'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ValidationResult } from '../../../../../triggers_actions_ui/public/types'; + +export function validateMetricThreshold({ + criteria, +}: { + criteria: MetricExpression[]; +}): ValidationResult { + const validationResult = { errors: {} }; + const errors: { + [id: string]: { + aggField: string[]; + timeSizeUnit: string[]; + timeWindowSize: string[]; + threshold0: string[]; + threshold1: string[]; + }; + } = {}; + validationResult.errors = errors; + + if (!criteria || !criteria.length) { + return validationResult; + } + + criteria.forEach((c, idx) => { + // Create an id for each criteria, so we can map errors to specific criteria. + const id = idx.toString(); + + errors[id] = errors[id] || { + aggField: [], + timeSizeUnit: [], + timeWindowSize: [], + threshold0: [], + threshold1: [], + }; + if (!c.aggType) { + errors[id].aggField.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.aggregationRequired', { + defaultMessage: 'Aggreation is required.', + }) + ); + } + + if (!c.threshold || !c.threshold.length) { + errors[id].threshold0.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdRequired', { + defaultMessage: 'Threshold is required.', + }) + ); + } + + if (c.comparator === 'between' && (!c.threshold || c.threshold.length < 2)) { + errors[id].threshold1.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdRequired', { + defaultMessage: 'Threshold is required.', + }) + ); + } + + if (!c.timeSize) { + errors[id].timeWindowSize.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.timeRequred', { + defaultMessage: 'Time size is Required.', + }) + ); + } + }); + + return validationResult; +} diff --git a/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx b/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx index a23a2739a8e23..8ffef269a42ea 100644 --- a/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx +++ b/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx @@ -143,7 +143,7 @@ describe('MetricsExplorerChartContextMenu', () => { uiCapabilities: customUICapabilities, chartOptions, }); - expect(component.find('button').length).toBe(0); + expect(component.find('button').length).toBe(1); }); }); diff --git a/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx b/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx index c50550f1de56f..75a04cbe9799e 100644 --- a/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx +++ b/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx @@ -24,6 +24,7 @@ import { createTSVBLink } from './helpers/create_tsvb_link'; import { getNodeDetailUrl } from '../../pages/link_to/redirect_to_node_detail'; import { SourceConfiguration } from '../../utils/source_configuration'; import { InventoryItemType } from '../../../common/inventory_models/types'; +import { AlertFlyout } from '../alerting/metrics/alert_flyout'; import { useLinkProps } from '../../hooks/use_link_props'; export interface Props { @@ -81,6 +82,7 @@ export const MetricsExplorerChartContextMenu: React.FC = ({ chartOptions, }: Props) => { const [isPopoverOpen, setPopoverState] = useState(false); + const [flyoutVisible, setFlyoutVisible] = useState(false); const supportFiltering = options.groupBy != null && onFilter != null; const handleFilter = useCallback(() => { // onFilter needs check for Typescript even though it's @@ -141,7 +143,20 @@ export const MetricsExplorerChartContextMenu: React.FC = ({ ] : []; - const itemPanels = [...filterByItem, ...openInVisualize, ...viewNodeDetail]; + const itemPanels = [ + ...filterByItem, + ...openInVisualize, + ...viewNodeDetail, + { + name: i18n.translate('xpack.infra.metricsExplorer.alerts.createAlertButton', { + defaultMessage: 'Create alert', + }), + icon: 'bell', + onClick() { + setFlyoutVisible(true); + }, + }, + ]; // If there are no itemPanels then there is no reason to show the actions button. if (itemPanels.length === 0) return null; @@ -174,15 +189,24 @@ export const MetricsExplorerChartContextMenu: React.FC = ({ {actionLabel} ); + return ( - - - + <> + + + + + ); }; diff --git a/x-pack/plugins/infra/public/components/metrics_explorer/kuery_bar.tsx b/x-pack/plugins/infra/public/components/metrics_explorer/kuery_bar.tsx index 0e18deedd404c..dcc160d05b6ad 100644 --- a/x-pack/plugins/infra/public/components/metrics_explorer/kuery_bar.tsx +++ b/x-pack/plugins/infra/public/components/metrics_explorer/kuery_bar.tsx @@ -16,6 +16,7 @@ interface Props { derivedIndexPattern: IIndexPattern; onSubmit: (query: string) => void; value?: string | null; + placeholder?: string; } function validateQuery(query: string) { @@ -27,7 +28,12 @@ function validateQuery(query: string) { return true; } -export const MetricsExplorerKueryBar = ({ derivedIndexPattern, onSubmit, value }: Props) => { +export const MetricsExplorerKueryBar = ({ + derivedIndexPattern, + onSubmit, + value, + placeholder, +}: Props) => { const [draftQuery, setDraftQuery] = useState(value || ''); const [isValid, setValidation] = useState(true); @@ -48,9 +54,12 @@ export const MetricsExplorerKueryBar = ({ derivedIndexPattern, onSubmit, value } fields: derivedIndexPattern.fields.filter(field => isDisplayable(field)), }; - const placeholder = i18n.translate('xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder', { - defaultMessage: 'Search for infrastructure data… (e.g. host.name:host-1)', - }); + const defaultPlaceholder = i18n.translate( + 'xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder', + { + defaultMessage: 'Search for infrastructure data… (e.g. host.name:host-1)', + } + ); return ( @@ -62,7 +71,7 @@ export const MetricsExplorerKueryBar = ({ derivedIndexPattern, onSubmit, value } loadSuggestions={loadSuggestions} onChange={handleChange} onSubmit={onSubmit} - placeholder={placeholder} + placeholder={placeholder || defaultPlaceholder} suggestions={suggestions} value={draftQuery} /> diff --git a/x-pack/plugins/infra/public/components/metrics_explorer/toolbar.tsx b/x-pack/plugins/infra/public/components/metrics_explorer/toolbar.tsx index 9e96819a36cac..0fbb0b6acad17 100644 --- a/x-pack/plugins/infra/public/components/metrics_explorer/toolbar.tsx +++ b/x-pack/plugins/infra/public/components/metrics_explorer/toolbar.tsx @@ -63,6 +63,7 @@ export const MetricsExplorerToolbar = ({ const isDefaultOptions = options.aggregation === 'avg' && options.metrics.length === 0; const [timepickerQuickRanges] = useKibanaUiSetting('timepicker:quickRanges'); const commonlyUsedRanges = mapKibanaQuickRangesToDatePickerRanges(timepickerQuickRanges); + return ( diff --git a/x-pack/plugins/infra/public/components/waffle/node_context_menu.tsx b/x-pack/plugins/infra/public/components/waffle/node_context_menu.tsx index cc6a94c8a41a2..5f05cebd8f616 100644 --- a/x-pack/plugins/infra/public/components/waffle/node_context_menu.tsx +++ b/x-pack/plugins/infra/public/components/waffle/node_context_menu.tsx @@ -8,7 +8,7 @@ import { EuiPopoverProps, EuiCode } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../lib/lib'; import { getNodeDetailUrl, getNodeLogsUrl } from '../../pages/link_to'; import { createUptimeLink } from './lib/create_uptime_link'; @@ -25,6 +25,7 @@ import { SectionLink, } from '../../../../observability/public'; import { useLinkProps } from '../../hooks/use_link_props'; +import { AlertFlyout } from '../alerting/metrics/alert_flyout'; interface Props { options: InfraWaffleMapOptions; @@ -46,6 +47,7 @@ export const NodeContextMenu: React.FC = ({ nodeType, popoverPosition, }) => { + const [flyoutVisible, setFlyoutVisible] = useState(false); const inventoryModel = findInventoryModel(nodeType); const nodeDetailFrom = currentTime - inventoryModel.metrics.defaultTimeRangeInSeconds * 1000; const uiCapabilities = useKibana().services.application?.capabilities; @@ -144,41 +146,48 @@ export const NodeContextMenu: React.FC = ({ }; return ( - -
-
- - - - {inventoryId.label && ( - -
- -
-
- )} - - - - - - -
-
-
+ <> + +
+
+ + + + {inventoryId.label && ( + +
+ +
+
+ )} + + + + + + +
+
+
+ + ); }; diff --git a/x-pack/plugins/infra/public/pages/infrastructure/index.tsx b/x-pack/plugins/infra/public/pages/infrastructure/index.tsx index b4ff7aeff696c..730f67ab2bdca 100644 --- a/x-pack/plugins/infra/public/pages/infrastructure/index.tsx +++ b/x-pack/plugins/infra/public/pages/infrastructure/index.tsx @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { Route, RouteComponentProps, Switch } from 'react-router-dom'; +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { DocumentTitle } from '../../components/document_title'; import { HelpCenterContent } from '../../components/help_center_content'; import { RoutedTabs } from '../../components/navigation/routed_tabs'; @@ -24,9 +25,11 @@ import { MetricsSettingsPage } from './settings'; import { AppNavigation } from '../../components/navigation/app_navigation'; import { SourceLoadingPage } from '../../components/source_loading_page'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { AlertDropdown } from '../../components/alerting/metrics/alert_dropdown'; export const InfrastructurePage = ({ match }: RouteComponentProps) => { const uiCapabilities = useKibana().services.application?.capabilities; + return ( @@ -59,31 +62,38 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { defaultMessage: 'Metrics', })} > - + + + + + + + + diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index d576331662a08..15796f35856bd 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -29,6 +29,8 @@ import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/pl import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public'; import { LogsRouter, MetricsRouter } from './routers'; +import { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public'; +import { getAlertType } from './components/alerting/metrics/metric_threshold_alert_type'; export type ClientSetup = void; export type ClientStart = void; @@ -38,6 +40,7 @@ export interface ClientPluginsSetup { data: DataPublicPluginSetup; usageCollection: UsageCollectionSetup; dataEnhanced: DataEnhancedSetup; + triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; } export interface ClientPluginsStart { @@ -58,6 +61,8 @@ export class Plugin setup(core: CoreSetup, pluginsSetup: ClientPluginsSetup) { registerFeatures(pluginsSetup.home); + pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getAlertType()); + core.application.register({ id: 'logs', title: i18n.translate('xpack.infra.logs.pluginTitle', { @@ -76,7 +81,8 @@ export class Plugin coreStart, plugins, params, - LogsRouter + LogsRouter, + pluginsSetup.triggers_actions_ui ); }, }); @@ -99,7 +105,8 @@ export class Plugin coreStart, plugins, params, - MetricsRouter + MetricsRouter, + pluginsSetup.triggers_actions_ui ); }, }); diff --git a/x-pack/plugins/infra/public/utils/triggers_actions_context.tsx b/x-pack/plugins/infra/public/utils/triggers_actions_context.tsx new file mode 100644 index 0000000000000..4ca4aedb4a08b --- /dev/null +++ b/x-pack/plugins/infra/public/utils/triggers_actions_context.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public'; + +interface ContextProps { + triggersActionsUI: TriggersAndActionsUIPublicPluginSetup | null; +} + +export const TriggerActionsContext = React.createContext({ + triggersActionsUI: null, +}); + +interface Props { + triggersActionsUI: TriggersAndActionsUIPublicPluginSetup; +} + +export const TriggersActionsProvider: React.FC = props => { + return ( + + {props.children} + + ); +}; From 3401ae42e0b9d700a91b6933f3310b61ee19789e Mon Sep 17 00:00:00 2001 From: Luke Elmers Date: Mon, 23 Mar 2020 09:17:27 -0600 Subject: [PATCH 08/35] =?UTF-8?q?Goodbye,=20legacy=20data=20plugin=20?= =?UTF-8?q?=F0=9F=91=8B=20(#60449)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintrc.js | 6 -- .i18nrc.json | 5 +- src/legacy/core_plugins/data/index.ts | 41 ------------- src/legacy/core_plugins/data/package.json | 4 -- src/legacy/core_plugins/data/public/index.ts | 26 --------- src/legacy/core_plugins/data/public/legacy.ts | 44 -------------- src/legacy/core_plugins/data/public/plugin.ts | 58 ------------------- src/legacy/core_plugins/data/public/setup.ts | 23 -------- .../core_plugins/input_control_vis/index.ts | 2 +- .../core_plugins/kibana/public/.eslintrc.js | 2 - src/legacy/core_plugins/timelion/index.ts | 2 +- .../core_plugins/timelion/public/app.js | 1 - .../public/markdown_vis_controller.test.tsx | 5 -- .../core_plugins/vis_type_timelion/index.ts | 2 +- .../components/panel_config/gauge.test.js | 6 -- .../components/vis_types/gauge/series.test.js | 6 -- .../vis_types/metric/series.test.js | 6 -- .../core_plugins/vis_type_vislib/index.ts | 2 +- src/legacy/core_plugins/vis_type_xy/index.ts | 2 +- x-pack/legacy/plugins/lens/index.ts | 2 +- .../lens/public/app_plugin/app.test.tsx | 5 -- .../dimension_panel/dimension_panel.test.tsx | 6 -- .../indexpattern_suggestions.test.tsx | 6 +- 23 files changed, 8 insertions(+), 254 deletions(-) delete mode 100644 src/legacy/core_plugins/data/index.ts delete mode 100644 src/legacy/core_plugins/data/package.json delete mode 100644 src/legacy/core_plugins/data/public/index.ts delete mode 100644 src/legacy/core_plugins/data/public/legacy.ts delete mode 100644 src/legacy/core_plugins/data/public/plugin.ts delete mode 100644 src/legacy/core_plugins/data/public/setup.ts diff --git a/.eslintrc.js b/.eslintrc.js index 3d6a5c262c453..af05af0f6e402 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -69,12 +69,6 @@ module.exports = { 'jsx-a11y/no-onchange': 'off', }, }, - { - files: ['src/legacy/core_plugins/data/**/*.{js,ts,tsx}'], - rules: { - 'react-hooks/exhaustive-deps': 'off', - }, - }, { files: ['src/legacy/core_plugins/expressions/**/*.{js,ts,tsx}'], rules: { diff --git a/.i18nrc.json b/.i18nrc.json index 07878ed3c15fb..bffe99bf3654b 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -4,10 +4,7 @@ "console": "src/plugins/console", "core": "src/core", "dashboard": "src/plugins/dashboard", - "data": [ - "src/legacy/core_plugins/data", - "src/plugins/data" - ], + "data": "src/plugins/data", "embeddableApi": "src/plugins/embeddable", "embeddableExamples": "examples/embeddable_examples", "share": "src/plugins/share", diff --git a/src/legacy/core_plugins/data/index.ts b/src/legacy/core_plugins/data/index.ts deleted file mode 100644 index 10c8cf464b82d..0000000000000 --- a/src/legacy/core_plugins/data/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { resolve } from 'path'; -import { Legacy } from '../../../../kibana'; - -// eslint-disable-next-line import/no-default-export -export default function DataPlugin(kibana: any) { - const config: Legacy.PluginSpecOptions = { - id: 'data', - require: ['elasticsearch'], - publicDir: resolve(__dirname, 'public'), - config: (Joi: any) => { - return Joi.object({ - enabled: Joi.boolean().default(true), - }).default(); - }, - init: (server: Legacy.Server) => ({}), - uiExports: { - injectDefaultVars: () => ({}), - }, - }; - - return new kibana.Plugin(config); -} diff --git a/src/legacy/core_plugins/data/package.json b/src/legacy/core_plugins/data/package.json deleted file mode 100644 index 3f40374650ad7..0000000000000 --- a/src/legacy/core_plugins/data/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "data", - "version": "kibana" -} diff --git a/src/legacy/core_plugins/data/public/index.ts b/src/legacy/core_plugins/data/public/index.ts deleted file mode 100644 index 27a3dd825485d..0000000000000 --- a/src/legacy/core_plugins/data/public/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { DataPlugin as Plugin } from './plugin'; - -export function plugin() { - return new Plugin(); -} - -export { DataSetup, DataStart } from './plugin'; diff --git a/src/legacy/core_plugins/data/public/legacy.ts b/src/legacy/core_plugins/data/public/legacy.ts deleted file mode 100644 index 370b412127db8..0000000000000 --- a/src/legacy/core_plugins/data/public/legacy.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * New Platform Shim - * - * In this file, we import any legacy dependencies we have, and shim them into - * our plugin by manually constructing the values that the new platform will - * eventually be passing to the `setup` method of our plugin definition. - * - * The idea is that our `plugin.ts` can stay "pure" and not contain any legacy - * world code. Then when it comes time to migrate to the new platform, we can - * simply delete this shim file. - * - * We are also calling `setup` here and exporting our public contract so that - * other legacy plugins are able to import from '../core_plugins/data/legacy' - * and receive the response value of the `setup` contract, mimicking the - * data that will eventually be injected by the new platform. - */ - -import { npSetup, npStart } from 'ui/new_platform'; -import { plugin } from '.'; - -const dataPlugin = plugin(); - -export const setup = dataPlugin.setup(npSetup.core); - -export const start = dataPlugin.start(npStart.core); diff --git a/src/legacy/core_plugins/data/public/plugin.ts b/src/legacy/core_plugins/data/public/plugin.ts deleted file mode 100644 index 76a3d92d20283..0000000000000 --- a/src/legacy/core_plugins/data/public/plugin.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { CoreSetup, CoreStart, Plugin } from 'kibana/public'; - -/** - * Interface for this plugin's returned `setup` contract. - * - * @public - */ -export interface DataSetup {} // eslint-disable-line @typescript-eslint/no-empty-interface - -/** - * Interface for this plugin's returned `start` contract. - * - * @public - */ -export interface DataStart {} // eslint-disable-line @typescript-eslint/no-empty-interface - -/** - * Data Plugin - public - * - * This is the entry point for the entire client-side public contract of the plugin. - * If something is not explicitly exported here, you can safely assume it is private - * to the plugin and not considered stable. - * - * All stateful contracts will be injected by the platform at runtime, and are defined - * in the setup/start interfaces. The remaining items exported here are either types, - * or static code. - */ - -export class DataPlugin implements Plugin { - public setup(core: CoreSetup) { - return {}; - } - - public start(core: CoreStart): DataStart { - return {}; - } - - public stop() {} -} diff --git a/src/legacy/core_plugins/data/public/setup.ts b/src/legacy/core_plugins/data/public/setup.ts deleted file mode 100644 index a99a2a4d06efe..0000000000000 --- a/src/legacy/core_plugins/data/public/setup.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { setup } from './legacy'; - -// for backwards compatibility with 7.3 -export const data = setup; diff --git a/src/legacy/core_plugins/input_control_vis/index.ts b/src/legacy/core_plugins/input_control_vis/index.ts index 8f6178e26126b..d67472ac4b95f 100644 --- a/src/legacy/core_plugins/input_control_vis/index.ts +++ b/src/legacy/core_plugins/input_control_vis/index.ts @@ -25,7 +25,7 @@ import { LegacyPluginApi, LegacyPluginInitializer } from '../../../../src/legacy const inputControlVisPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => new Plugin({ id: 'input_control_vis', - require: ['kibana', 'elasticsearch', 'visualizations', 'interpreter', 'data'], + require: ['kibana', 'elasticsearch', 'visualizations', 'interpreter'], publicDir: resolve(__dirname, 'public'), uiExports: { styleSheetPaths: resolve(__dirname, 'public/index.scss'), diff --git a/src/legacy/core_plugins/kibana/public/.eslintrc.js b/src/legacy/core_plugins/kibana/public/.eslintrc.js index e7171a5291d26..1153706eb8566 100644 --- a/src/legacy/core_plugins/kibana/public/.eslintrc.js +++ b/src/legacy/core_plugins/kibana/public/.eslintrc.js @@ -43,8 +43,6 @@ function buildRestrictedPaths(shimmedPlugins) { 'ui/**/*', 'src/legacy/ui/**/*', 'src/legacy/core_plugins/kibana/public/**/*', - 'src/legacy/core_plugins/data/public/**/*', - '!src/legacy/core_plugins/data/public/index.ts', `!src/legacy/core_plugins/kibana/public/${shimmedPlugin}/**/*`, ], allowSameFolder: false, diff --git a/src/legacy/core_plugins/timelion/index.ts b/src/legacy/core_plugins/timelion/index.ts index 9e2bfd4023bd9..41a15dc4e0186 100644 --- a/src/legacy/core_plugins/timelion/index.ts +++ b/src/legacy/core_plugins/timelion/index.ts @@ -29,7 +29,7 @@ const experimentalLabel = i18n.translate('timelion.uiSettings.experimentalLabel' const timelionPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => new Plugin({ - require: ['kibana', 'elasticsearch', 'data'], + require: ['kibana', 'elasticsearch'], config(Joi: any) { return Joi.object({ enabled: Joi.boolean().default(true), diff --git a/src/legacy/core_plugins/timelion/public/app.js b/src/legacy/core_plugins/timelion/public/app.js index a9d678cfea79c..66d93b4ce9b89 100644 --- a/src/legacy/core_plugins/timelion/public/app.js +++ b/src/legacy/core_plugins/timelion/public/app.js @@ -38,7 +38,6 @@ import 'ui/directives/input_focus'; import './directives/saved_object_finder'; import 'ui/directives/listen'; import './directives/saved_object_save_as_checkbox'; -import '../../data/public/legacy'; import './services/saved_sheet_register'; import rootTemplate from 'plugins/timelion/index.html'; diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.test.tsx b/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.test.tsx index 5bcb2961c42de..103879cb6e6df 100644 --- a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.test.tsx +++ b/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.test.tsx @@ -21,11 +21,6 @@ import React from 'react'; import { render, mount } from 'enzyme'; import { MarkdownVisWrapper } from './markdown_vis_controller'; -// We need Markdown to do these tests, so mock data plugin -jest.mock('../../data/public/legacy', () => { - return {}; -}); - describe('markdown vis controller', () => { it('should set html from markdown params', () => { const vis = { diff --git a/src/legacy/core_plugins/vis_type_timelion/index.ts b/src/legacy/core_plugins/vis_type_timelion/index.ts index 4664bebb4f38a..6c1e3f452959e 100644 --- a/src/legacy/core_plugins/vis_type_timelion/index.ts +++ b/src/legacy/core_plugins/vis_type_timelion/index.ts @@ -25,7 +25,7 @@ import { LegacyPluginApi, LegacyPluginInitializer } from '../../../../src/legacy const timelionVisPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => new Plugin({ id: 'timelion_vis', - require: ['kibana', 'elasticsearch', 'visualizations', 'data'], + require: ['kibana', 'elasticsearch', 'visualizations'], publicDir: resolve(__dirname, 'public'), uiExports: { styleSheetPaths: resolve(__dirname, 'public/index.scss'), diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/gauge.test.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/gauge.test.js index d92dafadb68bc..4509b669b0505 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/gauge.test.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/gauge.test.js @@ -20,12 +20,6 @@ import React from 'react'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; -jest.mock('plugins/data', () => { - return { - QueryStringInput: () =>
, - }; -}); - jest.mock('../lib/get_default_query_language', () => ({ getDefaultQueryLanguage: () => 'kuery', })); diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/gauge/series.test.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/gauge/series.test.js index 4efd5bb65451c..65bf7561e3866 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/gauge/series.test.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/gauge/series.test.js @@ -20,12 +20,6 @@ import React from 'react'; import { GaugeSeries } from './series'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -jest.mock('plugins/data', () => { - return { - QueryStringInput: () =>
, - }; -}); - const defaultProps = { disableAdd: true, disableDelete: true, diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/metric/series.test.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/metric/series.test.js index 299e7c12f931a..94a12266df3b3 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/metric/series.test.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/metric/series.test.js @@ -21,12 +21,6 @@ import React from 'react'; import { MetricSeries } from './series'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -jest.mock('plugins/data', () => { - return { - QueryStringInput: () =>
, - }; -}); - const defaultProps = { disableAdd: false, disableDelete: true, diff --git a/src/legacy/core_plugins/vis_type_vislib/index.ts b/src/legacy/core_plugins/vis_type_vislib/index.ts index 74c8f3f96e669..1f75aea31ba0b 100644 --- a/src/legacy/core_plugins/vis_type_vislib/index.ts +++ b/src/legacy/core_plugins/vis_type_vislib/index.ts @@ -25,7 +25,7 @@ import { LegacyPluginApi, LegacyPluginInitializer } from '../../types'; const visTypeVislibPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => new Plugin({ id: 'vis_type_vislib', - require: ['kibana', 'elasticsearch', 'visualizations', 'interpreter', 'data'], + require: ['kibana', 'elasticsearch', 'visualizations', 'interpreter'], publicDir: resolve(__dirname, 'public'), styleSheetPaths: resolve(__dirname, 'public/index.scss'), uiExports: { diff --git a/src/legacy/core_plugins/vis_type_xy/index.ts b/src/legacy/core_plugins/vis_type_xy/index.ts index 975399f891503..58d2e425eef40 100644 --- a/src/legacy/core_plugins/vis_type_xy/index.ts +++ b/src/legacy/core_plugins/vis_type_xy/index.ts @@ -31,7 +31,7 @@ export interface ConfigSchema { const visTypeXyPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => new Plugin({ id: 'visTypeXy', - require: ['kibana', 'elasticsearch', 'visualizations', 'interpreter', 'data'], + require: ['kibana', 'elasticsearch', 'visualizations', 'interpreter'], publicDir: resolve(__dirname, 'public'), uiExports: { hacks: [resolve(__dirname, 'public/legacy')], diff --git a/x-pack/legacy/plugins/lens/index.ts b/x-pack/legacy/plugins/lens/index.ts index 5eda6c4b4ff7a..b1c67fb81ba07 100644 --- a/x-pack/legacy/plugins/lens/index.ts +++ b/x-pack/legacy/plugins/lens/index.ts @@ -19,7 +19,7 @@ export const lens: LegacyPluginInitializer = kibana => { id: PLUGIN_ID, configPrefix: `xpack.${PLUGIN_ID}`, // task_manager could be required, but is only used for telemetry - require: ['kibana', 'elasticsearch', 'xpack_main', 'interpreter', 'data'], + require: ['kibana', 'elasticsearch', 'xpack_main', 'interpreter'], publicDir: resolve(__dirname, 'public'), uiExports: { diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx index d6312005a6c25..fbda18cc0e307 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx @@ -22,7 +22,6 @@ import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks' const dataStartMock = dataPluginMock.createStartContract(); import { TopNavMenuData } from '../../../../../../src/plugins/navigation/public'; -import { DataStart } from '../../../../../../src/legacy/core_plugins/data/public'; import { coreMock } from 'src/core/public/mocks'; jest.mock('ui/new_platform'); @@ -87,7 +86,6 @@ describe('Lens App', () => { editorFrame: EditorFrameInstance; data: typeof dataStartMock; core: typeof core; - dataShim: DataStart; storage: Storage; docId?: string; docStorage: SavedObjectStore; @@ -134,7 +132,6 @@ describe('Lens App', () => { editorFrame: EditorFrameInstance; data: typeof dataStartMock; core: typeof core; - dataShim: DataStart; storage: Storage; docId?: string; docStorage: SavedObjectStore; @@ -332,7 +329,6 @@ describe('Lens App', () => { editorFrame: EditorFrameInstance; data: typeof dataStartMock; core: typeof core; - dataShim: DataStart; storage: Storage; docId?: string; docStorage: SavedObjectStore; @@ -648,7 +644,6 @@ describe('Lens App', () => { editorFrame: EditorFrameInstance; data: typeof dataStartMock; core: typeof core; - dataShim: DataStart; storage: Storage; docId?: string; docStorage: SavedObjectStore; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 41c317ccab290..f4485774bc942 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -34,12 +34,6 @@ jest.mock('ui/new_platform'); jest.mock('../loader'); jest.mock('../state_helpers'); -// Used by indexpattern plugin, which is a dependency of a dependency -jest.mock('ui/chrome'); -// Contains old and new platform data plugins, used for interpreter and filter ratio -jest.mock('ui/new_platform'); -jest.mock('plugins/data/setup', () => ({ data: { query: { ui: {} } } })); - const expectedIndexPatterns = { 1: { id: '1', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index e86a16c1af9d6..4e48d0c0987b5 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -12,13 +12,9 @@ import { getDatasourceSuggestionsFromCurrentState, } from './indexpattern_suggestions'; +jest.mock('ui/new_platform'); jest.mock('./loader'); jest.mock('../id_generator'); -// chrome, notify, storage are used by ./plugin -jest.mock('ui/chrome'); -// Contains old and new platform data plugins, used for interpreter and filter ratio -jest.mock('ui/new_platform'); -jest.mock('plugins/data/setup', () => ({ data: { query: { ui: {} } } })); const expectedIndexPatterns = { 1: { From 85615bdb3f30da61882501a7a20a8e2dcb1af55b Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Mon, 23 Mar 2020 11:32:07 -0400 Subject: [PATCH 09/35] Fix formatter on range aggregation (#58651) * Fix formatter on range aggregation * Fix test that was using unformatted byte ranges * Fix test Co-authored-by: Elastic Machine --- .../data/public/field_formats/utils/deserialize.ts | 3 ++- test/functional/apps/visualize/_data_table.js | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/plugins/data/public/field_formats/utils/deserialize.ts b/src/plugins/data/public/field_formats/utils/deserialize.ts index c735ad196fbee..840e023a11589 100644 --- a/src/plugins/data/public/field_formats/utils/deserialize.ts +++ b/src/plugins/data/public/field_formats/utils/deserialize.ts @@ -70,7 +70,8 @@ export const deserializeFieldFormat: FormatFactory = function( const { id } = mapping; if (id === 'range') { const RangeFormat = FieldFormat.from((range: any) => { - const format = getFieldFormat(this, id, mapping.params); + const nestedFormatter = mapping.params as SerializedFieldFormat; + const format = getFieldFormat(this, nestedFormatter.id, nestedFormatter.params); const gte = '\u2265'; const lt = '\u003c'; return i18n.translate('data.aggTypes.buckets.ranges.rangesFormatMessage', { diff --git a/test/functional/apps/visualize/_data_table.js b/test/functional/apps/visualize/_data_table.js index 0a9ff1e77a2ef..a6305e158007d 100644 --- a/test/functional/apps/visualize/_data_table.js +++ b/test/functional/apps/visualize/_data_table.js @@ -99,9 +99,9 @@ export default function({ getService, getPageObjects }) { async function expectValidTableData() { const data = await PageObjects.visChart.getTableVisData(); expect(data.trim().split('\n')).to.be.eql([ - '≥ 0 and < 1000', + '≥ 0B and < 1,000B', '1,351 64.7%', - '≥ 1000 and < 2000', + '≥ 1,000B and < 1.953KB', '737 35.3%', ]); } @@ -144,9 +144,9 @@ export default function({ getService, getPageObjects }) { const data = await PageObjects.visChart.getTableVisData(); expect(data.trim().split('\n')).to.be.eql([ - '≥ 0 and < 1000', + '≥ 0B and < 1,000B', '344.094B', - '≥ 1000 and < 2000', + '≥ 1,000B and < 1.953KB', '1.697KB', ]); }); @@ -248,9 +248,9 @@ export default function({ getService, getPageObjects }) { await PageObjects.visEditor.clickGo(); const data = await PageObjects.visChart.getTableVisData(); expect(data.trim().split('\n')).to.be.eql([ - '≥ 0 and < 1000', + '≥ 0B and < 1,000B', '1,351', - '≥ 1000 and < 2000', + '≥ 1,000B and < 1.953KB', '737', ]); }); From 1b583a2e27174c5e81367da352aa8ae61534965a Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Mon, 23 Mar 2020 18:42:04 +0300 Subject: [PATCH 10/35] [TSVB] Fix percentiles band mode (#60741) * Fix percentiles band mode * Add support of bar chart, fix tests * Use accessor formatters * Fix tests --- .../public/components/aggs/percentile_ui.js | 2 + .../public/visualizations/constants/chart.js | 1 + .../timeseries/decorators/area_decorator.js | 7 ++- .../timeseries/decorators/bar_decorator.js | 7 ++- .../visualizations/views/timeseries/index.js | 6 +++ .../response_processors/series/percentile.js | 51 ++++++++++--------- .../series/percentile.test.js | 44 +++++----------- 7 files changed, 61 insertions(+), 57 deletions(-) diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_ui.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_ui.js index b931c8084a61e..f94c2f609da8f 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_ui.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_ui.js @@ -135,6 +135,8 @@ class PercentilesUi extends Component { { @@ -182,6 +184,8 @@ export const TimeSeries = ({ enableHistogramMode={enableHistogramMode} useDefaultGroupDomain={useDefaultGroupDomain} sortIndex={sortIndex} + y1AccessorFormat={y1AccessorFormat} + y0AccessorFormat={y0AccessorFormat} /> ); } @@ -206,6 +210,8 @@ export const TimeSeries = ({ enableHistogramMode={enableHistogramMode} useDefaultGroupDomain={useDefaultGroupDomain} sortIndex={sortIndex} + y1AccessorFormat={y1AccessorFormat} + y0AccessorFormat={y0AccessorFormat} /> ); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.js index 669a96a43ff8d..00fb48c88ec3f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.js @@ -17,7 +17,6 @@ * under the License. */ -import _ from 'lodash'; import { getAggValue } from '../../helpers/get_agg_value'; import { getDefaultDecoration } from '../../helpers/get_default_decoration'; import { getSplits } from '../../helpers/get_splits'; @@ -35,41 +34,45 @@ export function percentile(resp, panel, series, meta) { getSplits(resp, panel, series, meta).forEach(split => { metric.percentiles.forEach(percentile => { const percentileValue = percentile.value ? percentile.value : 0; - const label = `${split.label} (${percentileValue})`; + const id = `${split.id}:${percentile.id}`; const data = split.timeseries.buckets.map(bucket => { - const m = _.assign({}, metric, { percent: percentileValue }); - return [bucket.key, getAggValue(bucket, m)]; + const higherMetric = { ...metric, percent: percentileValue }; + const serieData = [bucket.key, getAggValue(bucket, higherMetric)]; + + if (percentile.mode === 'band') { + const lowerMetric = { ...metric, percent: percentile.percentile }; + serieData.push(getAggValue(bucket, lowerMetric)); + } + + return serieData; }); if (percentile.mode === 'band') { - const fillData = split.timeseries.buckets.map(bucket => { - const m = _.assign({}, metric, { percent: percentile.percentile }); - return [bucket.key, getAggValue(bucket, m)]; - }); results.push({ - id: `${split.id}:${percentile.id}`, + id, color: split.color, - label, + label: split.label, data, - lines: { show: true, fill: percentile.shade, lineWidth: 0 }, - points: { show: false }, - legend: false, - fillBetween: `${split.id}:${percentile.id}:${percentile.percentile}`, - }); - results.push({ - id: `${split.id}:${percentile.id}:${percentile.percentile}`, - color: split.color, - label, - data: fillData, - lines: { show: true, fill: false, lineWidth: 0 }, - legend: false, + lines: { + show: series.chart_type === 'line', + fill: Number(percentile.shade), + lineWidth: 0, + mode: 'band', + }, + bars: { + show: series.chart_type === 'bar', + fill: Number(percentile.shade), + mode: 'band', + }, points: { show: false }, + y1AccessorFormat: ` (${percentileValue})`, + y0AccessorFormat: ` (${percentile.percentile})`, }); } else { const decoration = getDefaultDecoration(series); results.push({ - id: `${split.id}:${percentile.id}`, + id, color: split.color, - label, + label: `${split.label} (${percentileValue})`, data, ...decoration, }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.test.js index 9cb08de8dad23..aec1c45cf97e1 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.test.js @@ -89,63 +89,45 @@ describe('percentile(resp, panel, series)', () => { test('creates a series', () => { const next = results => results; const results = percentile(resp, panel, series)(next)([]); - expect(results).toHaveLength(3); + expect(results).toHaveLength(2); expect(results[0]).toHaveProperty('id', 'test:10-90'); expect(results[0]).toHaveProperty('color', 'rgb(255, 0, 0)'); - expect(results[0]).toHaveProperty('fillBetween', 'test:10-90:90'); - expect(results[0]).toHaveProperty('label', 'Percentile of cpu (10)'); - expect(results[0]).toHaveProperty('legend', false); + expect(results[0]).toHaveProperty('label', 'Percentile of cpu'); expect(results[0]).toHaveProperty('lines'); expect(results[0].lines).toEqual({ fill: 0.2, lineWidth: 0, show: true, + mode: 'band', }); expect(results[0]).toHaveProperty('points'); expect(results[0].points).toEqual({ show: false }); expect(results[0].data).toEqual([ - [1, 1], - [2, 1.2], + [1, 1, 5], + [2, 1.2, 5.3], ]); - expect(results[1]).toHaveProperty('id', 'test:10-90:90'); + expect(results[1]).toHaveProperty('id', 'test:50'); expect(results[1]).toHaveProperty('color', 'rgb(255, 0, 0)'); - expect(results[1]).toHaveProperty('label', 'Percentile of cpu (10)'); - expect(results[1]).toHaveProperty('legend', false); + expect(results[1]).toHaveProperty('label', 'Percentile of cpu (50)'); + expect(results[1]).toHaveProperty('stack', false); expect(results[1]).toHaveProperty('lines'); expect(results[1].lines).toEqual({ - fill: false, - lineWidth: 0, - show: true, - }); - expect(results[1]).toHaveProperty('points'); - expect(results[1].points).toEqual({ show: false }); - expect(results[1].data).toEqual([ - [1, 5], - [2, 5.3], - ]); - - expect(results[2]).toHaveProperty('id', 'test:50'); - expect(results[2]).toHaveProperty('color', 'rgb(255, 0, 0)'); - expect(results[2]).toHaveProperty('label', 'Percentile of cpu (50)'); - expect(results[2]).toHaveProperty('stack', false); - expect(results[2]).toHaveProperty('lines'); - expect(results[2].lines).toEqual({ fill: 0, lineWidth: 1, show: true, steps: false, }); - expect(results[2]).toHaveProperty('bars'); - expect(results[2].bars).toEqual({ + expect(results[1]).toHaveProperty('bars'); + expect(results[1].bars).toEqual({ fill: 0, lineWidth: 1, show: false, }); - expect(results[2]).toHaveProperty('points'); - expect(results[2].points).toEqual({ show: true, lineWidth: 1, radius: 1 }); - expect(results[2].data).toEqual([ + expect(results[1]).toHaveProperty('points'); + expect(results[1].points).toEqual({ show: true, lineWidth: 1, radius: 1 }); + expect(results[1].data).toEqual([ [1, 2.5], [2, 2.7], ]); From 969811eb207a6d78a70d62f4549fb92f7b5fc700 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Mon, 23 Mar 2020 09:42:35 -0600 Subject: [PATCH 11/35] [SIEM] [Cases] Update case icons (#60812) --- .../public/pages/case/components/all_cases/actions.tsx | 4 ++-- .../public/pages/case/components/bulk_actions/index.tsx | 6 ++++-- .../siem/public/pages/case/components/case_view/index.tsx | 4 ++-- .../pages/case/components/user_action_tree/index.tsx | 4 ++-- .../case/components/user_action_tree/user_action_item.tsx | 6 +++--- .../components/user_action_tree/user_action_title.tsx | 8 ++++---- 6 files changed, 17 insertions(+), 15 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx index 6253d431f8401..93536077f3a4c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx @@ -32,7 +32,7 @@ export const getActions = ({ caseStatus === 'open' ? { description: i18n.CLOSE_CASE, - icon: 'magnet', + icon: 'folderCheck', name: i18n.CLOSE_CASE, onClick: (theCase: Case) => dispatchUpdate({ @@ -46,7 +46,7 @@ export const getActions = ({ } : { description: i18n.REOPEN_CASE, - icon: 'magnet', + icon: 'folderExclamation', name: i18n.REOPEN_CASE, onClick: (theCase: Case) => dispatchUpdate({ diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/index.tsx index b9da834b929ea..74a255bf5ad49 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/index.tsx @@ -27,8 +27,9 @@ export const getBulkItems = ({ caseStatus === 'open' ? ( { closePopover(); updateCaseStatus('closed'); @@ -39,8 +40,9 @@ export const getBulkItems = ({ ) : ( { closePopover(); updateCaseStatus('open'); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 08af603cb0dbf..0ac3adeb860ff 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -105,7 +105,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => title: i18n.CASE_OPENED, buttonLabel: i18n.CLOSE_CASE, status: caseData.status, - icon: 'checkInCircleFilled', + icon: 'folderCheck', badgeColor: 'secondary', isSelected: false, } @@ -115,7 +115,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => title: i18n.CASE_CLOSED, buttonLabel: i18n.REOPEN_CASE, status: caseData.status, - icon: 'magnet', + icon: 'folderExclamation', badgeColor: 'danger', isSelected: true, }, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 04697e63b7451..6a3d319561353 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -78,7 +78,7 @@ export const UserActionTree = React.memo( id={DescriptionId} isEditable={manageMarkdownEditIds.includes(DescriptionId)} isLoading={isLoadingDescription} - labelAction={i18n.EDIT_DESCRIPTION} + labelEditAction={i18n.EDIT_DESCRIPTION} labelTitle={i18n.ADDED_DESCRIPTION} fullName={caseData.createdBy.fullName ?? caseData.createdBy.username} markdown={MarkdownDescription} @@ -92,7 +92,7 @@ export const UserActionTree = React.memo( id={comment.id} isEditable={manageMarkdownEditIds.includes(comment.id)} isLoading={isLoadingIds.includes(comment.id)} - labelAction={i18n.EDIT_COMMENT} + labelEditAction={i18n.EDIT_COMMENT} labelTitle={i18n.ADDED_COMMENT} fullName={comment.createdBy.fullName ?? comment.createdBy.username} markdown={ diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index 7b99f2ef76ab3..ca73f200f1793 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -16,7 +16,7 @@ interface UserActionItemProps { id: string; isEditable: boolean; isLoading: boolean; - labelAction?: string; + labelEditAction?: string; labelTitle?: string; fullName: string; markdown: React.ReactNode; @@ -71,7 +71,7 @@ export const UserActionItem = ({ id, isEditable, isLoading, - labelAction, + labelEditAction, labelTitle, fullName, markdown, @@ -94,7 +94,7 @@ export const UserActionItem = ({ createdAt={createdAt} id={id} isLoading={isLoading} - labelAction={labelAction ?? ''} + labelEditAction={labelEditAction ?? ''} labelTitle={labelTitle ?? ''} userName={userName} onEdit={onEdit} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx index 6ad60fb9f963e..0ed081e8852f0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx @@ -25,7 +25,7 @@ interface UserActionTitleProps { createdAt: string; id: string; isLoading: boolean; - labelAction: string; + labelEditAction: string; labelTitle: string; userName: string; onEdit: (id: string) => void; @@ -35,7 +35,7 @@ export const UserActionTitle = ({ createdAt, id, isLoading, - labelAction, + labelEditAction, labelTitle, userName, onEdit, @@ -43,8 +43,8 @@ export const UserActionTitle = ({ const propertyActions = useMemo(() => { return [ { - iconType: 'documentEdit', - label: labelAction, + iconType: 'pencil', + label: labelEditAction, onClick: () => onEdit(id), }, ]; From 938ad3764024f618e01611d7162985e01796b7b5 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 23 Mar 2020 16:47:49 +0100 Subject: [PATCH 12/35] [Upgrade Assistant] Fix edge case where reindex op can falsely be seen as stale (#60770) * Fix edge case where reindex op is can falsely be seen as stale This is for multiple Kibana workers, to ensure that an item just coming off the queue is seen as "new" we set a "startedAt" field which will update the reindex op and give it the full timeout window. * Update tests to use new api too Co-authored-by: Elastic Machine --- .../plugins/upgrade_assistant/common/types.ts | 20 +++++++ .../server/lib/reindexing/error.ts | 2 + .../server/lib/reindexing/error_symbols.ts | 1 + .../server/lib/reindexing/op_utils.ts | 3 + .../server/lib/reindexing/reindex_service.ts | 59 +++++++++++++++---- .../server/lib/reindexing/worker.ts | 34 +++++++---- .../routes/reindex_indices/reindex_handler.ts | 12 +--- .../reindex_indices/reindex_indices.test.ts | 2 +- 8 files changed, 98 insertions(+), 35 deletions(-) diff --git a/x-pack/plugins/upgrade_assistant/common/types.ts b/x-pack/plugins/upgrade_assistant/common/types.ts index 1114e889882c2..6c1b24b677754 100644 --- a/x-pack/plugins/upgrade_assistant/common/types.ts +++ b/x-pack/plugins/upgrade_assistant/common/types.ts @@ -30,7 +30,27 @@ export enum ReindexStatus { export const REINDEX_OP_TYPE = 'upgrade-assistant-reindex-operation'; export interface QueueSettings extends SavedObjectAttributes { + /** + * A Unix timestamp of when the reindex operation was enqueued. + * + * @remark + * This is used by the reindexing scheduler to determine execution + * order. + */ queuedAt: number; + + /** + * A Unix timestamp of when the reindex operation was started. + * + * @remark + * Updating this field is useful for _also_ updating the saved object "updated_at" field + * which is used to determine stale or abandoned reindex operations. + * + * For now this is used by the reindex worker scheduler to determine whether we have + * A queue item at the start of the queue. + * + */ + startedAt?: number; } export interface ReindexOptions extends SavedObjectAttributes { diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts index 59922abd3e635..b1744c79bc26c 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts @@ -13,6 +13,7 @@ import { ReindexAlreadyInProgress, MultipleReindexJobsFound, ReindexCannotBeCancelled, + ReindexIsNotInQueue, } from './error_symbols'; export class ReindexError extends Error { @@ -32,6 +33,7 @@ export const error = { reindexTaskFailed: createErrorFactory(ReindexTaskFailed), reindexTaskCannotBeDeleted: createErrorFactory(ReindexTaskCannotBeDeleted), reindexAlreadyInProgress: createErrorFactory(ReindexAlreadyInProgress), + reindexIsNotInQueue: createErrorFactory(ReindexIsNotInQueue), multipleReindexJobsFound: createErrorFactory(MultipleReindexJobsFound), reindexCannotBeCancelled: createErrorFactory(ReindexCannotBeCancelled), }; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts index d5e8d643f4595..15d1b1bb9c6ae 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts @@ -11,6 +11,7 @@ export const CannotCreateIndex = Symbol('CannotCreateIndex'); export const ReindexTaskFailed = Symbol('ReindexTaskFailed'); export const ReindexTaskCannotBeDeleted = Symbol('ReindexTaskCannotBeDeleted'); export const ReindexAlreadyInProgress = Symbol('ReindexAlreadyInProgress'); +export const ReindexIsNotInQueue = Symbol('ReindexIsNotInQueue'); export const ReindexCannotBeCancelled = Symbol('ReindexCannotBeCancelled'); export const MultipleReindexJobsFound = Symbol('MultipleReindexJobsFound'); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts index dbed7de13f010..ecba02e0d5466 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts @@ -50,6 +50,9 @@ const orderQueuedReindexOperations = ({ ), }); +export const queuedOpHasStarted = (op: ReindexSavedObject) => + Boolean(op.attributes.reindexOptions?.queueSettings?.startedAt); + export const sortAndOrderReindexOperations = flow( sortReindexOperations, orderQueuedReindexOperations diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts index b270998658db8..47b7388131ff1 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts @@ -10,7 +10,6 @@ import { LicensingPluginSetup } from '../../../../licensing/server'; import { IndexGroup, - ReindexOptions, ReindexSavedObject, ReindexStatus, ReindexStep, @@ -59,7 +58,10 @@ export interface ReindexService { * @param indexName * @param opts Additional options when creating a new reindex operation */ - createReindexOperation(indexName: string, opts?: ReindexOptions): Promise; + createReindexOperation( + indexName: string, + opts?: { enqueue?: boolean } + ): Promise; /** * Retrieves all reindex operations that have the given status. @@ -92,7 +94,21 @@ export interface ReindexService { * @param indexName * @param opts As with {@link createReindexOperation} we support this setting. */ - resumeReindexOperation(indexName: string, opts?: ReindexOptions): Promise; + resumeReindexOperation( + indexName: string, + opts?: { enqueue?: boolean } + ): Promise; + + /** + * Update the update_at field on the reindex operation + * + * @remark + * Currently also sets a startedAt field on the SavedObject, not really used + * elsewhere, but is an indication that the object has started being processed. + * + * @param indexName + */ + startQueuedReindexOperation(indexName: string): Promise; /** * Cancel an in-progress reindex operation for a given index. Only allowed when the @@ -544,7 +560,7 @@ export const reindexServiceFactory = ( } }, - async createReindexOperation(indexName: string, opts?: ReindexOptions) { + async createReindexOperation(indexName: string, opts?: { enqueue: boolean }) { const indexExists = await callAsUser('indices.exists', { index: indexName }); if (!indexExists) { throw error.indexNotFound(`Index ${indexName} does not exist in this cluster.`); @@ -566,7 +582,10 @@ export const reindexServiceFactory = ( } } - return actions.createReindexOp(indexName, opts); + return actions.createReindexOp( + indexName, + opts?.enqueue ? { queueSettings: { queuedAt: Date.now() } } : undefined + ); }, async findReindexOperation(indexName: string) { @@ -654,7 +673,7 @@ export const reindexServiceFactory = ( }); }, - async resumeReindexOperation(indexName: string, opts?: ReindexOptions) { + async resumeReindexOperation(indexName: string, opts?: { enqueue: boolean }) { const reindexOp = await this.findReindexOperation(indexName); if (!reindexOp) { @@ -668,16 +687,30 @@ export const reindexServiceFactory = ( } else if (op.attributes.status !== ReindexStatus.paused) { throw new Error(`Reindex operation must be paused in order to be resumed.`); } - - const reindexOptions: ReindexOptions | undefined = opts - ? { - ...(op.attributes.reindexOptions ?? {}), - ...opts, - } - : undefined; + const queueSettings = opts?.enqueue ? { queuedAt: Date.now() } : undefined; return actions.updateReindexOp(op, { status: ReindexStatus.inProgress, + reindexOptions: queueSettings ? { queueSettings } : undefined, + }); + }); + }, + + async startQueuedReindexOperation(indexName: string) { + const reindexOp = await this.findReindexOperation(indexName); + + if (!reindexOp) { + throw error.indexNotFound(`No reindex operation found for index ${indexName}`); + } + + if (!reindexOp.attributes.reindexOptions?.queueSettings) { + throw error.reindexIsNotInQueue(`Reindex operation ${indexName} is not in the queue.`); + } + + return actions.runWhileLocked(reindexOp, async lockedReindexOp => { + const { reindexOptions } = lockedReindexOp.attributes; + reindexOptions!.queueSettings!.startedAt = Date.now(); + return actions.updateReindexOp(lockedReindexOp, { reindexOptions, }); }); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts index 482b9f280ad7e..d6051ce46312f 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts @@ -6,11 +6,11 @@ import { IClusterClient, Logger, SavedObjectsClientContract, FakeRequest } from 'src/core/server'; import moment from 'moment'; import { ReindexSavedObject, ReindexStatus } from '../../../common/types'; -import { CredentialStore } from './credential_store'; +import { Credential, CredentialStore } from './credential_store'; import { reindexActionsFactory } from './reindex_actions'; import { ReindexService, reindexServiceFactory } from './reindex_service'; import { LicensingPluginSetup } from '../../../../licensing/server'; -import { sortAndOrderReindexOperations } from './op_utils'; +import { sortAndOrderReindexOperations, queuedOpHasStarted } from './op_utils'; const POLL_INTERVAL = 30000; // If no nodes have been able to update this index in 2 minutes (due to missing credentials), set to paused. @@ -128,17 +128,34 @@ export class ReindexWorker { } }; + private getCredentialScopedReindexService = (credential: Credential) => { + const fakeRequest: FakeRequest = { headers: credential }; + const scopedClusterClient = this.clusterClient.asScoped(fakeRequest); + const callAsCurrentUser = scopedClusterClient.callAsCurrentUser.bind(scopedClusterClient); + const actions = reindexActionsFactory(this.client, callAsCurrentUser); + return reindexServiceFactory(callAsCurrentUser, actions, this.log, this.licensing); + }; + private updateInProgressOps = async () => { try { const inProgressOps = await this.reindexService.findAllByStatus(ReindexStatus.inProgress); const { parallel, queue } = sortAndOrderReindexOperations(inProgressOps); - const [firstOpInQueue] = queue; + let [firstOpInQueue] = queue; - if (firstOpInQueue) { + if (firstOpInQueue && !queuedOpHasStarted(firstOpInQueue)) { this.log.debug( `Queue detected; current length ${queue.length}, current item ReindexOperation(id: ${firstOpInQueue.id}, indexName: ${firstOpInQueue.attributes.indexName})` ); + const credential = this.credentialStore.get(firstOpInQueue); + if (credential) { + const service = this.getCredentialScopedReindexService(credential); + firstOpInQueue = await service.startQueuedReindexOperation( + firstOpInQueue.attributes.indexName + ); + // Re-associate the credentials + this.credentialStore.set(firstOpInQueue, credential); + } } this.inProgressOps = parallel.concat(firstOpInQueue ? [firstOpInQueue] : []); @@ -173,14 +190,7 @@ export class ReindexWorker { } } - // Setup a ReindexService specific to these credentials. - const fakeRequest: FakeRequest = { headers: credential }; - - const scopedClusterClient = this.clusterClient.asScoped(fakeRequest); - const callAsCurrentUser = scopedClusterClient.callAsCurrentUser.bind(scopedClusterClient); - const actions = reindexActionsFactory(this.client, callAsCurrentUser); - - const service = reindexServiceFactory(callAsCurrentUser, actions, this.log, this.licensing); + const service = this.getCredentialScopedReindexService(credential); reindexOp = await swallowExceptions(service.processNextStep, this.log)(reindexOp); // Update credential store with most recent state. diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts index e640d03791cce..74c349d894839 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts @@ -8,7 +8,7 @@ import { IScopedClusterClient, Logger, SavedObjectsClientContract } from 'kibana import { LicensingPluginSetup } from '../../../../licensing/server'; -import { ReindexOperation, ReindexOptions, ReindexStatus } from '../../../common/types'; +import { ReindexOperation, ReindexStatus } from '../../../common/types'; import { reindexActionsFactory } from '../../lib/reindexing/reindex_actions'; import { reindexServiceFactory } from '../../lib/reindexing'; @@ -53,17 +53,11 @@ export const reindexHandler = async ({ const existingOp = await reindexService.findReindexOperation(indexName); - const opts: ReindexOptions | undefined = reindexOptions - ? { - queueSettings: reindexOptions.enqueue ? { queuedAt: Date.now() } : undefined, - } - : undefined; - // If the reindexOp already exists and it's paused, resume it. Otherwise create a new one. const reindexOp = existingOp && existingOp.attributes.status === ReindexStatus.paused - ? await reindexService.resumeReindexOperation(indexName, opts) - : await reindexService.createReindexOperation(indexName, opts); + ? await reindexService.resumeReindexOperation(indexName, reindexOptions) + : await reindexService.createReindexOperation(indexName, reindexOptions); // Add users credentials for the worker to use credentialStore.set(reindexOp, headers); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts index df8b2fa80a25a..e739531e0e22c 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts @@ -261,7 +261,7 @@ describe('reindex API', () => { describe('POST /api/upgrade_assistant/reindex/batch', () => { const queueSettingsArg = { - queueSettings: { queuedAt: expect.any(Number) }, + enqueue: true, }; it('creates a collection of index operations', async () => { mockReindexService.createReindexOperation From 05c995a939a8a8fea80f2e5447e5ce43648f9f07 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Mon, 23 Mar 2020 11:53:51 -0400 Subject: [PATCH 13/35] Support Histogram Data Type (#59387) Added the histogram field type to Kibana, to be used in the percentiles, percentiles ranks, and median aggregations. --- ...ugin-plugins-data-public.es_field_types.md | 1 + ...gin-plugins-data-public.kbn_field_types.md | 1 + ...ugin-plugins-data-server.es_field_types.md | 1 + ...gin-plugins-data-server.kbn_field_types.md | 1 + .../__snapshots__/field_editor.test.js.snap | 4 + .../kbn_field_types/kbn_field_types.test.ts | 1 + .../kbn_field_types_factory.ts | 5 + .../data/common/kbn_field_types/types.ts | 3 + src/plugins/data/public/public.api.md | 4 + .../public/search/aggs/metrics/cardinality.ts | 3 + .../data/public/search/aggs/metrics/median.ts | 2 +- .../search/aggs/metrics/percentile_ranks.ts | 2 +- .../public/search/aggs/metrics/percentiles.ts | 2 +- .../public/search/aggs/metrics/top_hit.ts | 4 +- src/plugins/data/server/server.api.md | 4 + .../test/functional/apps/visualize/index.ts | 1 + .../apps/visualize/precalculated_histogram.ts | 60 ++++++ .../pre_calculated_histogram/data.json | 197 ++++++++++++++++++ .../pre_calculated_histogram/mappings.json | 29 +++ 19 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 x-pack/test/functional/apps/visualize/precalculated_histogram.ts create mode 100644 x-pack/test/functional/es_archives/pre_calculated_histogram/data.json create mode 100644 x-pack/test/functional/es_archives/pre_calculated_histogram/mappings.json diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.es_field_types.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.es_field_types.md index e7341caf7b3cd..c5e01715534d1 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.es_field_types.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.es_field_types.md @@ -30,6 +30,7 @@ export declare enum ES_FIELD_TYPES | GEO\_POINT | "geo_point" | | | GEO\_SHAPE | "geo_shape" | | | HALF\_FLOAT | "half_float" | | +| HISTOGRAM | "histogram" | | | INTEGER | "integer" | | | IP | "ip" | | | KEYWORD | "keyword" | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.kbn_field_types.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.kbn_field_types.md index e5ae8ffbd2877..30c3aa946c1ce 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.kbn_field_types.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.kbn_field_types.md @@ -23,6 +23,7 @@ export declare enum KBN_FIELD_TYPES | DATE | "date" | | | GEO\_POINT | "geo_point" | | | GEO\_SHAPE | "geo_shape" | | +| HISTOGRAM | "histogram" | | | IP | "ip" | | | MURMUR3 | "murmur3" | | | NESTED | "nested" | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.es_field_types.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.es_field_types.md index 81a7cbca77c48..d071955f4f522 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.es_field_types.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.es_field_types.md @@ -30,6 +30,7 @@ export declare enum ES_FIELD_TYPES | GEO\_POINT | "geo_point" | | | GEO\_SHAPE | "geo_shape" | | | HALF\_FLOAT | "half_float" | | +| HISTOGRAM | "histogram" | | | INTEGER | "integer" | | | IP | "ip" | | | KEYWORD | "keyword" | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kbn_field_types.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kbn_field_types.md index 40b81d2f6ac4d..a0a64190497c8 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kbn_field_types.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kbn_field_types.md @@ -23,6 +23,7 @@ export declare enum KBN_FIELD_TYPES | DATE | "date" | | | GEO\_POINT | "geo_point" | | | GEO\_SHAPE | "geo_shape" | | +| HISTOGRAM | "histogram" | | | IP | "ip" | | | MURMUR3 | "murmur3" | | | NESTED | "nested" | | diff --git a/src/legacy/ui/public/field_editor/__snapshots__/field_editor.test.js.snap b/src/legacy/ui/public/field_editor/__snapshots__/field_editor.test.js.snap index 6c454370f59f5..19d12f4bbbd4c 100644 --- a/src/legacy/ui/public/field_editor/__snapshots__/field_editor.test.js.snap +++ b/src/legacy/ui/public/field_editor/__snapshots__/field_editor.test.js.snap @@ -945,6 +945,10 @@ exports[`FieldEditor should show deprecated lang warning 1`] = ` "text": "_source", "value": "_source", }, + Object { + "text": "histogram", + "value": "histogram", + }, Object { "text": "conflict", "value": "conflict", diff --git a/src/plugins/data/common/kbn_field_types/kbn_field_types.test.ts b/src/plugins/data/common/kbn_field_types/kbn_field_types.test.ts index 09fc4555992a8..a3fe19fa9b2fc 100644 --- a/src/plugins/data/common/kbn_field_types/kbn_field_types.test.ts +++ b/src/plugins/data/common/kbn_field_types/kbn_field_types.test.ts @@ -87,6 +87,7 @@ describe('utils/kbn_field_types', () => { KBN_FIELD_TYPES.DATE, KBN_FIELD_TYPES.GEO_POINT, KBN_FIELD_TYPES.GEO_SHAPE, + KBN_FIELD_TYPES.HISTOGRAM, KBN_FIELD_TYPES.IP, KBN_FIELD_TYPES.MURMUR3, KBN_FIELD_TYPES.NESTED, diff --git a/src/plugins/data/common/kbn_field_types/kbn_field_types_factory.ts b/src/plugins/data/common/kbn_field_types/kbn_field_types_factory.ts index 192e8bc4f3727..cb9357eb9865e 100644 --- a/src/plugins/data/common/kbn_field_types/kbn_field_types_factory.ts +++ b/src/plugins/data/common/kbn_field_types/kbn_field_types_factory.ts @@ -95,6 +95,11 @@ export const createKbnFieldTypes = (): KbnFieldType[] => [ name: KBN_FIELD_TYPES._SOURCE, esTypes: [ES_FIELD_TYPES._SOURCE], }), + new KbnFieldType({ + name: KBN_FIELD_TYPES.HISTOGRAM, + filterable: true, + esTypes: [ES_FIELD_TYPES.HISTOGRAM], + }), new KbnFieldType({ name: KBN_FIELD_TYPES.CONFLICT, }), diff --git a/src/plugins/data/common/kbn_field_types/types.ts b/src/plugins/data/common/kbn_field_types/types.ts index 11c62e8f86dce..acd7a36b01fb3 100644 --- a/src/plugins/data/common/kbn_field_types/types.ts +++ b/src/plugins/data/common/kbn_field_types/types.ts @@ -59,6 +59,8 @@ export enum ES_FIELD_TYPES { ATTACHMENT = 'attachment', TOKEN_COUNT = 'token_count', MURMUR3 = 'murmur3', + + HISTOGRAM = 'histogram', } /** @public **/ @@ -77,4 +79,5 @@ export enum KBN_FIELD_TYPES { CONFLICT = 'conflict', OBJECT = 'object', NESTED = 'nested', + HISTOGRAM = 'histogram', } diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index dad3a8e639bc5..fac16973f92a3 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -284,6 +284,8 @@ export enum ES_FIELD_TYPES { // (undocumented) HALF_FLOAT = "half_float", // (undocumented) + HISTOGRAM = "histogram", + // (undocumented) _ID = "_id", // (undocumented) _INDEX = "_index", @@ -1126,6 +1128,8 @@ export enum KBN_FIELD_TYPES { // (undocumented) GEO_SHAPE = "geo_shape", // (undocumented) + HISTOGRAM = "histogram", + // (undocumented) IP = "ip", // (undocumented) MURMUR3 = "murmur3", diff --git a/src/plugins/data/public/search/aggs/metrics/cardinality.ts b/src/plugins/data/public/search/aggs/metrics/cardinality.ts index aa41307b2a052..88cdf3175665e 100644 --- a/src/plugins/data/public/search/aggs/metrics/cardinality.ts +++ b/src/plugins/data/public/search/aggs/metrics/cardinality.ts @@ -45,6 +45,9 @@ export const cardinalityMetricAgg = new MetricAggType({ { name: 'field', type: 'field', + filterFieldTypes: Object.values(KBN_FIELD_TYPES).filter( + type => type !== KBN_FIELD_TYPES.HISTOGRAM + ), }, ], }); diff --git a/src/plugins/data/public/search/aggs/metrics/median.ts b/src/plugins/data/public/search/aggs/metrics/median.ts index f2636d52e3484..faa0694cd5312 100644 --- a/src/plugins/data/public/search/aggs/metrics/median.ts +++ b/src/plugins/data/public/search/aggs/metrics/median.ts @@ -40,7 +40,7 @@ export const medianMetricAgg = new MetricAggType({ { name: 'field', type: 'field', - filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE], + filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE, KBN_FIELD_TYPES.HISTOGRAM], write(agg, output) { output.params.field = agg.getParam('field').name; output.params.percents = [50]; diff --git a/src/plugins/data/public/search/aggs/metrics/percentile_ranks.ts b/src/plugins/data/public/search/aggs/metrics/percentile_ranks.ts index 71b1c1415d98e..7dc0f70ea7b80 100644 --- a/src/plugins/data/public/search/aggs/metrics/percentile_ranks.ts +++ b/src/plugins/data/public/search/aggs/metrics/percentile_ranks.ts @@ -59,7 +59,7 @@ export const percentileRanksMetricAgg = new MetricAggType({ { name: 'field', type: 'field', - filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE], + filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE, KBN_FIELD_TYPES.HISTOGRAM], }, { name: 'percents', diff --git a/src/plugins/data/public/search/aggs/metrics/top_hit.ts b/src/plugins/data/public/search/aggs/metrics/top_hit.ts index 738de6b62bccb..d0c668c577e62 100644 --- a/src/plugins/data/public/search/aggs/metrics/top_hit.ts +++ b/src/plugins/data/public/search/aggs/metrics/top_hit.ts @@ -60,7 +60,9 @@ export const topHitMetricAgg = new MetricAggType({ name: 'field', type: 'field', onlyAggregatable: false, - filterFieldTypes: '*', + filterFieldTypes: Object.values(KBN_FIELD_TYPES).filter( + type => type !== KBN_FIELD_TYPES.HISTOGRAM + ), write(agg, output) { const field = agg.getParam('field'); output.params = {}; diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 178b2949a9456..5c231cdc05e61 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -176,6 +176,8 @@ export enum ES_FIELD_TYPES { // (undocumented) HALF_FLOAT = "half_float", // (undocumented) + HISTOGRAM = "histogram", + // (undocumented) _ID = "_id", // (undocumented) _INDEX = "_index", @@ -547,6 +549,8 @@ export enum KBN_FIELD_TYPES { // (undocumented) GEO_SHAPE = "geo_shape", // (undocumented) + HISTOGRAM = "histogram", + // (undocumented) IP = "ip", // (undocumented) MURMUR3 = "murmur3", diff --git a/x-pack/test/functional/apps/visualize/index.ts b/x-pack/test/functional/apps/visualize/index.ts index 29b1ef9870d7d..4335690b6a70e 100644 --- a/x-pack/test/functional/apps/visualize/index.ts +++ b/x-pack/test/functional/apps/visualize/index.ts @@ -13,5 +13,6 @@ export default function visualize({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./feature_controls/visualize_security')); loadTestFile(require.resolve('./feature_controls/visualize_spaces')); loadTestFile(require.resolve('./hybrid_visualization')); + loadTestFile(require.resolve('./precalculated_histogram')); }); } diff --git a/x-pack/test/functional/apps/visualize/precalculated_histogram.ts b/x-pack/test/functional/apps/visualize/precalculated_histogram.ts new file mode 100644 index 0000000000000..5d362d29b640c --- /dev/null +++ b/x-pack/test/functional/apps/visualize/precalculated_histogram.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common', 'visualize', 'discover', 'visChart', 'visEditor']); + const kibanaServer = getService('kibanaServer'); + const log = getService('log'); + + describe('pre_calculated_histogram', function() { + before(async function() { + log.debug('Starting pre_calculated_histogram before method'); + await esArchiver.load('pre_calculated_histogram'); + await kibanaServer.uiSettings.replace({ defaultIndex: 'test-histogram' }); + }); + + after(function() { + return esArchiver.unload('pre_calculated_histogram'); + }); + + const initHistogramBarChart = async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVerticalBarChart(); + await PageObjects.visualize.clickNewSearch('histogram-test'); + await PageObjects.visChart.waitForVisualization(); + }; + + const getFieldOptionsForAggregation = async (aggregation: string): Promise => { + await PageObjects.visEditor.clickBucket('Y-axis', 'metrics'); + await PageObjects.visEditor.selectAggregation(aggregation, 'metrics'); + const fieldValues = await PageObjects.visEditor.getField(); + return fieldValues; + }; + + it('appears correctly in discover', async function() { + await PageObjects.common.navigateToApp('discover'); + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData.includes('"values": [ 0.3, 1, 3, 4.2, 4.8 ]')).to.be.ok(); + }); + + it('appears in the field options of a Percentiles aggregation', async function() { + await initHistogramBarChart(); + const fieldValues: string[] = await getFieldOptionsForAggregation('Percentiles'); + log.debug('Percentiles Fields = ' + fieldValues); + expect(fieldValues[0]).to.be('histogram-content'); + }); + + it('appears in the field options of a Percentile Ranks aggregation', async function() { + const fieldValues: string[] = await getFieldOptionsForAggregation('Percentile Ranks'); + log.debug('Percentile Ranks Fields = ' + fieldValues); + expect(fieldValues[0]).to.be('histogram-content'); + }); + }); +} diff --git a/x-pack/test/functional/es_archives/pre_calculated_histogram/data.json b/x-pack/test/functional/es_archives/pre_calculated_histogram/data.json new file mode 100644 index 0000000000000..cab1dbdf84483 --- /dev/null +++ b/x-pack/test/functional/es_archives/pre_calculated_histogram/data.json @@ -0,0 +1,197 @@ +{ + "type": "doc", + "value": { + "id": "index-pattern:histogram-test", + "index": ".kibana", + "source": { + "index-pattern": { + "title": "histogram-test", + "fields": "[{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"esTypes\":[\"_type\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"histogram-content\",\"type\":\"histogram\",\"esTypes\":[\"histogram\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"histogram-title\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]" + }, + "type": "index-pattern" + } + } +} + +{ + "type": "doc", + "value": { + "id": "5e69404d93193e4074f0ec1a", + "index": "histogram-test", + "source": { + "histogram-title": "incididunt reprehenderit mollit", + "histogram-content": { + "values": [ + 0.3, + 1, + 3, + 4.2, + 4.8 + ], + "counts": [ + 237, + 170, + 33, + 149, + 241 + ] + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "5e69408f2fc61f57fd5bc762", + "index": "histogram-test", + "source": { + "histogram-title": "culpa cillum ullamco", + "histogram-content": { + "values": [ + 0.5, + 1, + 1.2, + 1.3, + 2.8, + 3.9, + 4.3 + ], + "counts": [ + 113, + 197, + 20, + 66, + 20, + 39, + 178 + ] + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "5e6940b979b57ad343114cc3", + "index": "histogram-test", + "source": { + "histogram-title": "enim veniam et", + "histogram-content": { + "values": [ + 3.7, + 4.2 + ], + "counts": [ + 227, + 141 + ] + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "5e6940d3e95de786eeb7586d", + "index": "histogram-test", + "source": { + "histogram-title": "est incididunt sunt", + "histogram-content": { + "values": [ + 1.8, + 2.4, + 2.6, + 4.9 + ], + "counts": [ + 92, + 101, + 122, + 244 + ] + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "5e694119fb2f956a822b93b9", + "index": "histogram-test", + "source": { + "histogram-title": "qui qui tempor", + "histogram-content": { + "values": [ + 0.5, + 2.1, + 2.7, + 3, + 3.2, + 3.5, + 4.2, + 5 + ], + "counts": [ + 210, + 168, + 182, + 181, + 97, + 164, + 77, + 2 + ] + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "5e694145ad3c741aa12d6e8e", + "index": "histogram-test", + "source": { + "histogram-title": "ullamco nisi sunt", + "histogram-content": { + "values": [ + 1.7, + 4.5, + 4.8 + ], + "counts": [ + 74, + 146, + 141 + ] + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "5e694159d909d9d99b5e12d1", + "index": "histogram-test", + "source": { + "histogram-title": "magna eu incididunt", + "histogram-content": { + "values": [ + 1, + 3.4, + 4.8 + ], + "counts": [ + 103, + 205, + 11 + ] + } + } + } +} diff --git a/x-pack/test/functional/es_archives/pre_calculated_histogram/mappings.json b/x-pack/test/functional/es_archives/pre_calculated_histogram/mappings.json new file mode 100644 index 0000000000000..f616daf9d5ccb --- /dev/null +++ b/x-pack/test/functional/es_archives/pre_calculated_histogram/mappings.json @@ -0,0 +1,29 @@ +{ + "type": "index", + "value": { + "aliases": {}, + "index": "histogram-test", + "mappings": { + "properties": { + "histogram-title": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "histogram-content": { + "type": "histogram" + } + } + }, + "settings": { + "index": { + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} From cca23c26fc1ca5f439826813c6dc0eb41b12141d Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Mon, 23 Mar 2020 09:03:13 -0700 Subject: [PATCH 14/35] Adding `authc.grantAPIKeyAsInternalUser` (#60423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Parsing the Authorization HTTP header to grant API keys * Using HTTPAuthorizationHeader and BasicHTTPAuthorizationHeaderCredentials * Adding tests for grantAPIKey * Adding http_authentication/ folder * Removing test route * Using new classes to create the headers we pass to ES * No longer .toLowerCase() when parsing the scheme from the request * Updating snapshots * Update x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.ts Co-Authored-By: Aleh Zasypkin * Updating another inline snapshot * Adding JSDoc * Renaming `grant` to `grantAsInternalUser` * Adding forgotten test. Fixing snapshot * Fixing mock * Apply suggestions from code review Co-Authored-By: Aleh Zasypkin Co-Authored-By: Mike Côté * Using new classes for changing password * Removing unneeded asScoped call Co-authored-by: Aleh Zasypkin Co-authored-by: Elastic Machine Co-authored-by: Mike Côté --- .../server/authentication/api_keys.test.ts | 83 ++++++++++++++++++ .../server/authentication/api_keys.ts | 79 +++++++++++++++++ .../get_http_authentication_scheme.test.ts | 58 ------------- .../get_http_authentication_scheme.ts | 21 ----- ...p_authorization_header_credentials.test.ts | 56 ++++++++++++ ...c_http_authorization_header_credentials.ts | 44 ++++++++++ .../http_authorization_header.test.ts | 85 +++++++++++++++++++ .../http_authorization_header.ts | 45 ++++++++++ .../http_authentication/index.ts | 8 ++ .../server/authentication/index.mock.ts | 1 + .../server/authentication/index.test.ts | 18 ++++ .../security/server/authentication/index.ts | 5 ++ .../server/authentication/providers/basic.ts | 12 ++- .../server/authentication/providers/http.ts | 18 ++-- .../authentication/providers/kerberos.ts | 25 ++++-- .../server/authentication/providers/oidc.ts | 15 +++- .../server/authentication/providers/pki.ts | 12 ++- .../server/authentication/providers/saml.ts | 15 +++- .../server/authentication/providers/token.ts | 19 +++-- .../server/elasticsearch_client_plugin.ts | 18 ++++ x-pack/plugins/security/server/plugin.test.ts | 1 + .../server/routes/users/change_password.ts | 14 ++- 22 files changed, 534 insertions(+), 118 deletions(-) delete mode 100644 x-pack/plugins/security/server/authentication/get_http_authentication_scheme.test.ts delete mode 100644 x-pack/plugins/security/server/authentication/get_http_authentication_scheme.ts create mode 100644 x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.test.ts create mode 100644 x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.ts create mode 100644 x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.test.ts create mode 100644 x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.ts create mode 100644 x-pack/plugins/security/server/authentication/http_authentication/index.ts diff --git a/x-pack/plugins/security/server/authentication/api_keys.test.ts b/x-pack/plugins/security/server/authentication/api_keys.test.ts index bcb212e7bbf94..78b1d5f8e30b8 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.test.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.test.ts @@ -15,6 +15,8 @@ import { } from '../../../../../src/core/server/mocks'; import { licenseMock } from '../../common/licensing/index.mock'; +const encodeToBase64 = (str: string) => Buffer.from(str).toString('base64'); + describe('API Keys', () => { let apiKeys: APIKeys; let mockClusterClient: jest.Mocked; @@ -81,6 +83,87 @@ describe('API Keys', () => { }); }); + describe('grantAsInternalUser()', () => { + it('returns null when security feature is disabled', async () => { + mockLicense.isEnabled.mockReturnValue(false); + const result = await apiKeys.grantAsInternalUser(httpServerMock.createKibanaRequest()); + expect(result).toBeNull(); + + expect(mockClusterClient.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('calls callAsInternalUser with proper parameters for the Basic scheme', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockClusterClient.callAsInternalUser.mockResolvedValueOnce({ + id: '123', + name: 'key-name', + api_key: 'abc123', + }); + const result = await apiKeys.grantAsInternalUser( + httpServerMock.createKibanaRequest({ + headers: { + authorization: `Basic ${encodeToBase64('foo:bar')}`, + }, + }) + ); + expect(result).toEqual({ + api_key: 'abc123', + id: '123', + name: 'key-name', + }); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.grantAPIKey', { + body: { + grant_type: 'password', + username: 'foo', + password: 'bar', + }, + }); + }); + + it('calls callAsInternalUser with proper parameters for the Bearer scheme', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockClusterClient.callAsInternalUser.mockResolvedValueOnce({ + id: '123', + name: 'key-name', + api_key: 'abc123', + }); + const result = await apiKeys.grantAsInternalUser( + httpServerMock.createKibanaRequest({ + headers: { + authorization: `Bearer foo-access-token`, + }, + }) + ); + expect(result).toEqual({ + api_key: 'abc123', + id: '123', + name: 'key-name', + }); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.grantAPIKey', { + body: { + grant_type: 'access_token', + access_token: 'foo-access-token', + }, + }); + }); + + it('throw error for other schemes', async () => { + mockLicense.isEnabled.mockReturnValue(true); + await expect( + apiKeys.grantAsInternalUser( + httpServerMock.createKibanaRequest({ + headers: { + authorization: `Digest username="foo"`, + }, + }) + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unsupported scheme \\"Digest\\" for granting API Key"` + ); + expect(mockClusterClient.callAsInternalUser).not.toHaveBeenCalled(); + }); + }); + describe('invalidate()', () => { it('returns null when security feature is disabled', async () => { mockLicense.isEnabled.mockReturnValue(false); diff --git a/x-pack/plugins/security/server/authentication/api_keys.ts b/x-pack/plugins/security/server/authentication/api_keys.ts index 2b1a93d907471..0d77207e390ae 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.ts @@ -6,6 +6,8 @@ import { IClusterClient, KibanaRequest, Logger } from '../../../../../src/core/server'; import { SecurityLicense } from '../../common/licensing'; +import { HTTPAuthorizationHeader } from './http_authentication'; +import { BasicHTTPAuthorizationHeaderCredentials } from './http_authentication'; /** * Represents the options to create an APIKey class instance that will be @@ -26,6 +28,13 @@ export interface CreateAPIKeyParams { expiration?: string; } +interface GrantAPIKeyParams { + grant_type: 'password' | 'access_token'; + username?: string; + password?: string; + access_token?: string; +} + /** * Represents the params for invalidating an API key */ @@ -58,6 +67,21 @@ export interface CreateAPIKeyResult { api_key: string; } +export interface GrantAPIKeyResult { + /** + * Unique id for this API key + */ + id: string; + /** + * Name for this API key + */ + name: string; + /** + * Generated API key + */ + api_key: string; +} + /** * The return value when invalidating an API key in Elasticsearch. */ @@ -131,6 +155,39 @@ export class APIKeys { return result; } + /** + * Tries to grant an API key for the current user. + * @param request Request instance. + */ + async grantAsInternalUser(request: KibanaRequest) { + if (!this.license.isEnabled()) { + return null; + } + + this.logger.debug('Trying to grant an API key'); + const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); + if (authorizationHeader == null) { + throw new Error( + `Unable to grant an API Key, request does not contain an authorization header` + ); + } + const params = this.getGrantParams(authorizationHeader); + + // User needs `manage_api_key` or `grant_api_key` privilege to use this API + let result: GrantAPIKeyResult; + try { + result = (await this.clusterClient.callAsInternalUser('shield.grantAPIKey', { + body: params, + })) as GrantAPIKeyResult; + this.logger.debug('API key was granted successfully'); + } catch (e) { + this.logger.error(`Failed to grant API key: ${e.message}`); + throw e; + } + + return result; + } + /** * Tries to invalidate an API key. * @param request Request instance. @@ -164,4 +221,26 @@ export class APIKeys { return result; } + + private getGrantParams(authorizationHeader: HTTPAuthorizationHeader): GrantAPIKeyParams { + if (authorizationHeader.scheme.toLowerCase() === 'bearer') { + return { + grant_type: 'access_token', + access_token: authorizationHeader.credentials, + }; + } + + if (authorizationHeader.scheme.toLowerCase() === 'basic') { + const basicCredentials = BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials( + authorizationHeader.credentials + ); + return { + grant_type: 'password', + username: basicCredentials.username, + password: basicCredentials.password, + }; + } + + throw new Error(`Unsupported scheme "${authorizationHeader.scheme}" for granting API Key`); + } } diff --git a/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.test.ts b/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.test.ts deleted file mode 100644 index 6a63634394ec0..0000000000000 --- a/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.test.ts +++ /dev/null @@ -1,58 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { httpServerMock } from '../../../../../src/core/server/http/http_server.mocks'; - -import { getHTTPAuthenticationScheme } from './get_http_authentication_scheme'; - -describe('getHTTPAuthenticationScheme', () => { - it('returns `null` if request does not have authorization header', () => { - expect(getHTTPAuthenticationScheme(httpServerMock.createKibanaRequest())).toBeNull(); - }); - - it('returns `null` if authorization header value isn not a string', () => { - expect( - getHTTPAuthenticationScheme( - httpServerMock.createKibanaRequest({ - headers: { authorization: ['Basic xxx', 'Bearer xxx'] as any }, - }) - ) - ).toBeNull(); - }); - - it('returns `null` if authorization header value is an empty string', () => { - expect( - getHTTPAuthenticationScheme( - httpServerMock.createKibanaRequest({ headers: { authorization: '' } }) - ) - ).toBeNull(); - }); - - it('returns only scheme portion of the authorization header value in lower case', () => { - const headerValueAndSchemeMap = [ - ['Basic xxx', 'basic'], - ['Basic xxx yyy', 'basic'], - ['basic xxx', 'basic'], - ['basic', 'basic'], - // We don't trim leading whitespaces in scheme. - [' Basic xxx', ''], - ['Negotiate xxx', 'negotiate'], - ['negotiate xxx', 'negotiate'], - ['negotiate', 'negotiate'], - ['ApiKey xxx', 'apikey'], - ['apikey xxx', 'apikey'], - ['Api Key xxx', 'api'], - ]; - - for (const [authorization, scheme] of headerValueAndSchemeMap) { - expect( - getHTTPAuthenticationScheme( - httpServerMock.createKibanaRequest({ headers: { authorization } }) - ) - ).toBe(scheme); - } - }); -}); diff --git a/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.ts b/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.ts deleted file mode 100644 index b9c53f34dbcab..0000000000000 --- a/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.ts +++ /dev/null @@ -1,21 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { KibanaRequest } from '../../../../../src/core/server'; - -/** - * Parses request's `Authorization` HTTP header if present and extracts authentication scheme. - * https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml#authschemes - * @param request Request instance to extract authentication scheme for. - */ -export function getHTTPAuthenticationScheme(request: KibanaRequest) { - const authorizationHeaderValue = request.headers.authorization; - if (!authorizationHeaderValue || typeof authorizationHeaderValue !== 'string') { - return null; - } - - return authorizationHeaderValue.split(/\s+/)[0].toLowerCase(); -} diff --git a/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.test.ts b/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.test.ts new file mode 100644 index 0000000000000..bd3c7047e77e7 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { BasicHTTPAuthorizationHeaderCredentials } from './basic_http_authorization_header_credentials'; + +const encodeToBase64 = (str: string) => Buffer.from(str).toString('base64'); + +describe('BasicHTTPAuthorizationHeaderCredentials.parseFromRequest()', () => { + it('parses username from the left-side of the single colon', () => { + const basicCredentials = BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials( + encodeToBase64('fOo:bAr') + ); + expect(basicCredentials.username).toBe('fOo'); + }); + + it('parses username from the left-side of the first colon', () => { + const basicCredentials = BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials( + encodeToBase64('fOo:bAr:bAz') + ); + expect(basicCredentials.username).toBe('fOo'); + }); + + it('parses password from the right-side of the single colon', () => { + const basicCredentials = BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials( + encodeToBase64('fOo:bAr') + ); + expect(basicCredentials.password).toBe('bAr'); + }); + + it('parses password from the right-side of the first colon', () => { + const basicCredentials = BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials( + encodeToBase64('fOo:bAr:bAz') + ); + expect(basicCredentials.password).toBe('bAr:bAz'); + }); + + it('throws error if there is no colon', () => { + expect(() => { + BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials(encodeToBase64('fOobArbAz')); + }).toThrowErrorMatchingInlineSnapshot( + `"Unable to parse basic authentication credentials without a colon"` + ); + }); +}); + +describe(`toString()`, () => { + it('concatenates username and password using a colon and then base64 encodes the string', () => { + const basicCredentials = new BasicHTTPAuthorizationHeaderCredentials('elastic', 'changeme'); + + expect(basicCredentials.toString()).toEqual(Buffer.from(`elastic:changeme`).toString('base64')); // I don't like that this so closely mirror the actual implementation + expect(basicCredentials.toString()).toEqual('ZWxhc3RpYzpjaGFuZ2VtZQ=='); // and I don't like that this is so opaque. Both together seem reasonable... + }); +}); diff --git a/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.ts b/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.ts new file mode 100644 index 0000000000000..b8c3f1dadf1b2 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.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; + * you may not use this file except in compliance with the Elastic License. + */ + +export class BasicHTTPAuthorizationHeaderCredentials { + /** + * Username, referred to as the `user-id` in https://tools.ietf.org/html/rfc7617. + */ + readonly username: string; + + /** + * Password used to authenticate + */ + readonly password: string; + + constructor(username: string, password: string) { + this.username = username; + this.password = password; + } + + /** + * Parses the username and password from the credentials included in a HTTP Authorization header + * for the Basic scheme https://tools.ietf.org/html/rfc7617 + * @param credentials The credentials extracted from the HTTP Authorization header + */ + static parseFromCredentials(credentials: string) { + const decoded = Buffer.from(credentials, 'base64').toString(); + if (decoded.indexOf(':') === -1) { + throw new Error('Unable to parse basic authentication credentials without a colon'); + } + + const [username] = decoded.split(':'); + // according to https://tools.ietf.org/html/rfc7617, everything + // after the first colon is considered to be part of the password + const password = decoded.substring(username.length + 1); + return new BasicHTTPAuthorizationHeaderCredentials(username, password); + } + + toString() { + return Buffer.from(`${this.username}:${this.password}`).toString('base64'); + } +} diff --git a/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.test.ts b/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.test.ts new file mode 100644 index 0000000000000..d47a0c70f608a --- /dev/null +++ b/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServerMock } from '../../../../../../src/core/server/mocks'; + +import { HTTPAuthorizationHeader } from './http_authorization_header'; + +describe('HTTPAuthorizationHeader.parseFromRequest()', () => { + it('returns `null` if request does not have authorization header', () => { + expect( + HTTPAuthorizationHeader.parseFromRequest(httpServerMock.createKibanaRequest()) + ).toBeNull(); + }); + + it('returns `null` if authorization header value is not a string', () => { + expect( + HTTPAuthorizationHeader.parseFromRequest( + httpServerMock.createKibanaRequest({ + headers: { authorization: ['Basic xxx', 'Bearer xxx'] as any }, + }) + ) + ).toBeNull(); + }); + + it('returns `null` if authorization header value is an empty string', () => { + expect( + HTTPAuthorizationHeader.parseFromRequest( + httpServerMock.createKibanaRequest({ headers: { authorization: '' } }) + ) + ).toBeNull(); + }); + + it('parses scheme portion of the authorization header value', () => { + const headerValueAndSchemeMap = [ + ['Basic xxx', 'Basic'], + ['Basic xxx yyy', 'Basic'], + ['basic xxx', 'basic'], + ['basic', 'basic'], + // We don't trim leading whitespaces in scheme. + [' Basic xxx', ''], + ['Negotiate xxx', 'Negotiate'], + ['negotiate xxx', 'negotiate'], + ['negotiate', 'negotiate'], + ['ApiKey xxx', 'ApiKey'], + ['apikey xxx', 'apikey'], + ['Api Key xxx', 'Api'], + ]; + + for (const [authorization, scheme] of headerValueAndSchemeMap) { + const header = HTTPAuthorizationHeader.parseFromRequest( + httpServerMock.createKibanaRequest({ headers: { authorization } }) + ); + expect(header).not.toBeNull(); + expect(header!.scheme).toBe(scheme); + } + }); + + it('parses credentials portion of the authorization header value', () => { + const headerValueAndCredentialsMap = [ + ['xxx fOo', 'fOo'], + ['xxx fOo bAr', 'fOo bAr'], + // We don't trim leading whitespaces in scheme. + [' xxx fOo', 'xxx fOo'], + ]; + + for (const [authorization, credentials] of headerValueAndCredentialsMap) { + const header = HTTPAuthorizationHeader.parseFromRequest( + httpServerMock.createKibanaRequest({ headers: { authorization } }) + ); + expect(header).not.toBeNull(); + expect(header!.credentials).toBe(credentials); + } + }); +}); + +describe('toString()', () => { + it('concatenates scheme and credentials using a space', () => { + const header = new HTTPAuthorizationHeader('Bearer', 'some-access-token'); + + expect(header.toString()).toEqual('Bearer some-access-token'); + }); +}); diff --git a/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.ts b/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.ts new file mode 100644 index 0000000000000..bfc757734ec72 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaRequest } from '../../../../../../src/core/server'; + +export class HTTPAuthorizationHeader { + /** + * The authentication scheme. Should be consumed in a case-insensitive manner. + * https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml#authschemes + */ + readonly scheme: string; + + /** + * The authentication credentials for the scheme. + */ + readonly credentials: string; + + constructor(scheme: string, credentials: string) { + this.scheme = scheme; + this.credentials = credentials; + } + + /** + * Parses request's `Authorization` HTTP header if present. + * @param request Request instance to extract the authorization header from. + */ + static parseFromRequest(request: KibanaRequest) { + const authorizationHeaderValue = request.headers.authorization; + if (!authorizationHeaderValue || typeof authorizationHeaderValue !== 'string') { + return null; + } + + const [scheme] = authorizationHeaderValue.split(/\s+/); + const credentials = authorizationHeaderValue.substring(scheme.length + 1); + + return new HTTPAuthorizationHeader(scheme, credentials); + } + + toString() { + return `${this.scheme} ${this.credentials}`; + } +} diff --git a/x-pack/plugins/security/server/authentication/http_authentication/index.ts b/x-pack/plugins/security/server/authentication/http_authentication/index.ts new file mode 100644 index 0000000000000..94eb8762ecaf0 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/http_authentication/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { BasicHTTPAuthorizationHeaderCredentials } from './basic_http_authorization_header_credentials'; +export { HTTPAuthorizationHeader } from './http_authorization_header'; diff --git a/x-pack/plugins/security/server/authentication/index.mock.ts b/x-pack/plugins/security/server/authentication/index.mock.ts index c634e2c80c299..512de9626a986 100644 --- a/x-pack/plugins/security/server/authentication/index.mock.ts +++ b/x-pack/plugins/security/server/authentication/index.mock.ts @@ -13,6 +13,7 @@ export const authenticationMock = { isProviderEnabled: jest.fn(), createAPIKey: jest.fn(), getCurrentUser: jest.fn(), + grantAPIKeyAsInternalUser: jest.fn(), invalidateAPIKey: jest.fn(), isAuthenticated: jest.fn(), getSessionInfo: jest.fn(), diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 30929ba98d33b..e364dbf39db65 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -369,6 +369,24 @@ describe('setupAuthentication()', () => { }); }); + describe('grantAPIKeyAsInternalUser()', () => { + let grantAPIKeyAsInternalUser: (request: KibanaRequest) => Promise; + beforeEach(async () => { + grantAPIKeyAsInternalUser = (await setupAuthentication(mockSetupAuthenticationParams)) + .grantAPIKeyAsInternalUser; + }); + + it('calls grantAsInternalUser', async () => { + const request = httpServerMock.createKibanaRequest(); + const apiKeysInstance = jest.requireMock('./api_keys').APIKeys.mock.instances[0]; + apiKeysInstance.grantAsInternalUser.mockResolvedValueOnce({ api_key: 'foo' }); + await expect(grantAPIKeyAsInternalUser(request)).resolves.toEqual({ + api_key: 'foo', + }); + expect(apiKeysInstance.grantAsInternalUser).toHaveBeenCalledWith(request); + }); + }); + describe('invalidateAPIKey()', () => { let invalidateAPIKey: ( request: KibanaRequest, diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 1eed53efc6441..8b42b2325ee1e 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -28,6 +28,10 @@ export { CreateAPIKeyParams, InvalidateAPIKeyParams, } from './api_keys'; +export { + BasicHTTPAuthorizationHeaderCredentials, + HTTPAuthorizationHeader, +} from './http_authentication'; interface SetupAuthenticationParams { http: CoreSetup['http']; @@ -169,6 +173,7 @@ export async function setupAuthentication({ getCurrentUser, createAPIKey: (request: KibanaRequest, params: CreateAPIKeyParams) => apiKeys.create(request, params), + grantAPIKeyAsInternalUser: (request: KibanaRequest) => apiKeys.grantAsInternalUser(request), invalidateAPIKey: (request: KibanaRequest, params: InvalidateAPIKeyParams) => apiKeys.invalidate(request, params), isAuthenticated: (request: KibanaRequest) => http.auth.isAuthenticated(request), diff --git a/x-pack/plugins/security/server/authentication/providers/basic.ts b/x-pack/plugins/security/server/authentication/providers/basic.ts index ad46aff8afa51..76a9f936eca48 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.ts @@ -8,7 +8,10 @@ import { KibanaRequest } from '../../../../../../src/core/server'; import { canRedirectRequest } from '../can_redirect_request'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { + HTTPAuthorizationHeader, + BasicHTTPAuthorizationHeaderCredentials, +} from '../http_authentication'; import { BaseAuthenticationProvider } from './base'; /** @@ -54,7 +57,10 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Trying to perform a login.'); const authHeaders = { - authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, + authorization: new HTTPAuthorizationHeader( + 'Basic', + new BasicHTTPAuthorizationHeaderCredentials(username, password).toString() + ).toString(), }; try { @@ -76,7 +82,7 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - if (getHTTPAuthenticationScheme(request) != null) { + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { this.logger.debug('Cannot authenticate requests with `Authorization` header.'); return AuthenticationResult.notHandled(); } diff --git a/x-pack/plugins/security/server/authentication/providers/http.ts b/x-pack/plugins/security/server/authentication/providers/http.ts index 57163bf8145b8..6b75ae2d48156 100644 --- a/x-pack/plugins/security/server/authentication/providers/http.ts +++ b/x-pack/plugins/security/server/authentication/providers/http.ts @@ -7,7 +7,7 @@ import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base'; interface HTTPAuthenticationProviderOptions { @@ -38,7 +38,9 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider { if ((httpOptions?.supportedSchemes?.size ?? 0) === 0) { throw new Error('Supported schemes should be specified'); } - this.supportedSchemes = httpOptions.supportedSchemes; + this.supportedSchemes = new Set( + [...httpOptions.supportedSchemes].map(scheme => scheme.toLowerCase()) + ); } /** @@ -56,26 +58,26 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - const authenticationScheme = getHTTPAuthenticationScheme(request); - if (authenticationScheme == null) { + const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); + if (authorizationHeader == null) { this.logger.debug('Authorization header is not presented.'); return AuthenticationResult.notHandled(); } - if (!this.supportedSchemes.has(authenticationScheme)) { - this.logger.debug(`Unsupported authentication scheme: ${authenticationScheme}`); + if (!this.supportedSchemes.has(authorizationHeader.scheme.toLowerCase())) { + this.logger.debug(`Unsupported authentication scheme: ${authorizationHeader.scheme}`); return AuthenticationResult.notHandled(); } try { const user = await this.getUser(request); this.logger.debug( - `Request to ${request.url.path} has been authenticated via authorization header with "${authenticationScheme}" scheme.` + `Request to ${request.url.path} has been authenticated via authorization header with "${authorizationHeader.scheme}" scheme.` ); return AuthenticationResult.succeeded(user); } catch (err) { this.logger.debug( - `Failed to authenticate request to ${request.url.path} via authorization header with "${authenticationScheme}" scheme: ${err.message}` + `Failed to authenticate request to ${request.url.path} via authorization header with "${authorizationHeader.scheme}" scheme: ${err.message}` ); return AuthenticationResult.failed(err); } diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index 632a07ca2b21a..dbd0a438d71c9 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -12,7 +12,7 @@ import { } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { Tokens, TokenPair } from '../tokens'; import { BaseAuthenticationProvider } from './base'; @@ -44,13 +44,13 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - const authenticationScheme = getHTTPAuthenticationScheme(request); - if (authenticationScheme && authenticationScheme !== 'negotiate') { - this.logger.debug(`Unsupported authentication scheme: ${authenticationScheme}`); + const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); + if (authorizationHeader && authorizationHeader.scheme.toLowerCase() !== 'negotiate') { + this.logger.debug(`Unsupported authentication scheme: ${authorizationHeader.scheme}`); return AuthenticationResult.notHandled(); } - let authenticationResult = authenticationScheme + let authenticationResult = authorizationHeader ? await this.authenticateWithNegotiateScheme(request) : AuthenticationResult.notHandled(); @@ -175,7 +175,9 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { try { // Then attempt to query for the user details using the new token - const authHeaders = { authorization: `Bearer ${tokens.access_token}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', tokens.access_token).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('User has been authenticated with new access token'); @@ -205,7 +207,9 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via state.'); @@ -242,7 +246,12 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader( + 'Bearer', + refreshedTokenPair.accessToken + ).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via refreshed token.'); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index d52466826c2be..21bce028b0d98 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -10,7 +10,7 @@ import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { Tokens, TokenPair } from '../tokens'; import { AuthenticationProviderOptions, @@ -131,7 +131,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - if (getHTTPAuthenticationScheme(request) != null) { + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { this.logger.debug('Cannot authenticate requests with `Authorization` header.'); return AuthenticationResult.notHandled(); } @@ -289,7 +289,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via state.'); @@ -345,7 +347,12 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader( + 'Bearer', + refreshedTokenPair.accessToken + ).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via refreshed token.'); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts index 252ab8cc67144..db022ff355702 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.ts @@ -9,7 +9,7 @@ import { DetailedPeerCertificate } from 'tls'; import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { Tokens } from '../tokens'; import { BaseAuthenticationProvider } from './base'; @@ -45,7 +45,7 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - if (getHTTPAuthenticationScheme(request) != null) { + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { this.logger.debug('Cannot authenticate requests with `Authorization` header.'); return AuthenticationResult.notHandled(); } @@ -156,7 +156,9 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via state.'); @@ -207,7 +209,9 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { try { // Then attempt to query for the user details using the new token - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('User has been authenticated with new access token'); diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index 1152ee5048699..ddf6814989a49 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -10,7 +10,7 @@ import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { canRedirectRequest } from '../can_redirect_request'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { Tokens, TokenPair } from '../tokens'; import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base'; @@ -181,7 +181,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - if (getHTTPAuthenticationScheme(request) != null) { + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { this.logger.debug('Cannot authenticate requests with `Authorization` header.'); return AuthenticationResult.notHandled(); } @@ -390,7 +390,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via state.'); @@ -445,7 +447,12 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader( + 'Bearer', + refreshedTokenPair.accessToken + ).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via refreshed token.'); diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index fffac254ed30a..91808c22c4300 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -9,7 +9,7 @@ import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { canRedirectRequest } from '../can_redirect_request'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { Tokens, TokenPair } from '../tokens'; import { BaseAuthenticationProvider } from './base'; @@ -60,7 +60,9 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Get token API request to Elasticsearch successful'); // Then attempt to query for the user details using the new token - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Login has been successfully performed.'); @@ -82,7 +84,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - if (getHTTPAuthenticationScheme(request) != null) { + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { this.logger.debug('Cannot authenticate requests with `Authorization` header.'); return AuthenticationResult.notHandled(); } @@ -152,7 +154,9 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Trying to authenticate via state.'); try { - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via state.'); @@ -199,7 +203,12 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader( + 'Bearer', + refreshedTokenPair.accessToken + ).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via refreshed token.'); diff --git a/x-pack/plugins/security/server/elasticsearch_client_plugin.ts b/x-pack/plugins/security/server/elasticsearch_client_plugin.ts index 996dcb685f29b..529e8a8aa6e9c 100644 --- a/x-pack/plugins/security/server/elasticsearch_client_plugin.ts +++ b/x-pack/plugins/security/server/elasticsearch_client_plugin.ts @@ -538,6 +538,24 @@ export function elasticsearchClientPlugin(Client: any, config: unknown, componen }, }); + /** + * Grants an API key in Elasticsearch for the current user. + * + * @param {string} type The type of grant, either "password" or "access_token" + * @param {string} username Required when using the "password" type + * @param {string} password Required when using the "password" type + * @param {string} access_token Required when using the "access_token" type + * + * @returns {{api_key: string}} + */ + shield.grantAPIKey = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_security/api_key/grant', + }, + }); + /** * Invalidates an API key in Elasticsearch. * diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index a1ef352056d6a..b817bcc0858a9 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -74,6 +74,7 @@ describe('Security Plugin', () => { "createAPIKey": [Function], "getCurrentUser": [Function], "getSessionInfo": [Function], + "grantAPIKeyAsInternalUser": [Function], "invalidateAPIKey": [Function], "isAuthenticated": [Function], "isProviderEnabled": [Function], diff --git a/x-pack/plugins/security/server/routes/users/change_password.ts b/x-pack/plugins/security/server/routes/users/change_password.ts index fc3ca4573d500..aa7e8bc26cc1f 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.ts @@ -8,6 +8,10 @@ import { schema } from '@kbn/config-schema'; import { canUserChangePassword } from '../../../common/model'; import { getErrorStatusCode, wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { + HTTPAuthorizationHeader, + BasicHTTPAuthorizationHeaderCredentials, +} from '../../authentication'; import { RouteDefinitionParams } from '..'; export function defineChangeUserPasswordRoutes({ @@ -43,9 +47,13 @@ export function defineChangeUserPasswordRoutes({ ? { headers: { ...request.headers, - authorization: `Basic ${Buffer.from(`${username}:${currentPassword}`).toString( - 'base64' - )}`, + authorization: new HTTPAuthorizationHeader( + 'Basic', + new BasicHTTPAuthorizationHeaderCredentials( + username, + currentPassword || '' + ).toString() + ).toString(), }, } : request From 21e8cea183081d294b4ff323b43da87dc82d07bf Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Mon, 23 Mar 2020 11:10:40 -0500 Subject: [PATCH 15/35] [SIEM] Add license check to ML Rule form (#60691) * Gate ML Rules behind a license check If they don't have a Platinum or Trial license, then we disable the ML Card and provide them a link to the subscriptions marketing page. * Add aria-describedby for new ML input fields * Add data-test-subj to new ML input fields * Remove unused prop This is already passed as isLoading * Fix capitalization on translation id * Declare defaulted props as optional * Gray out entire ML card when ML Rules are disabled If we're editing an existing rule, or if the user has an insufficient license, we disable both the card and its selectability. This is more visually striking, and a more obvious CTA. --- .../anomaly_threshold_slider/index.tsx | 13 +++- .../rules/components/ml_job_select/index.tsx | 12 +++- .../components/select_rule_type/index.tsx | 60 ++++++++++++++++--- .../select_rule_type/translations.ts | 7 --- .../components/step_define_rule/index.tsx | 23 +++++-- 5 files changed, 92 insertions(+), 23 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx index 18970ff935b8d..1e18023e0c326 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx @@ -10,12 +10,16 @@ import { EuiFlexGrid, EuiFlexItem, EuiRange, EuiFormRow } from '@elastic/eui'; import { FieldHook } from '../../../../../shared_imports'; interface AnomalyThresholdSliderProps { + describedByIds: string[]; field: FieldHook; } type Event = React.ChangeEvent; type EventArg = Event | React.MouseEvent; -export const AnomalyThresholdSlider: React.FC = ({ field }) => { +export const AnomalyThresholdSlider: React.FC = ({ + describedByIds = [], + field, +}) => { const threshold = field.value as number; const onThresholdChange = useCallback( (event: EventArg) => { @@ -26,7 +30,12 @@ export const AnomalyThresholdSlider: React.FC = ({ ); return ( - + = ({ field }) => { +export const MlJobSelect: React.FC = ({ describedByIds = [], field }) => { const jobId = field.value as string; const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); const [isLoading, siemJobs] = useSiemJobs(false); @@ -41,7 +42,14 @@ export const MlJobSelect: React.FC = ({ field }) => { })); return ( - + ( + + {hasValidLicense ? ( + i18n.ML_TYPE_DESCRIPTION + ) : ( + + + + ), + }} + /> + )} + +); + interface SelectRuleTypeProps { + describedByIds?: string[]; field: FieldHook; - isReadOnly: boolean; + hasValidLicense?: boolean; + isReadOnly?: boolean; } -export const SelectRuleType: React.FC = ({ field, isReadOnly = false }) => { +export const SelectRuleType: React.FC = ({ + describedByIds = [], + field, + hasValidLicense = false, + isReadOnly = false, +}) => { const ruleType = field.value as RuleType; const setType = useCallback( (type: RuleType) => { @@ -27,10 +66,15 @@ export const SelectRuleType: React.FC = ({ field, isReadOnl ); const setMl = useCallback(() => setType('machine_learning'), [setType]); const setQuery = useCallback(() => setType('query'), [setType]); - const license = true; // TODO + const mlCardDisabled = isReadOnly || !hasValidLicense; return ( - + = ({ field, isReadOnl } icon={} + isDisabled={mlCardDisabled} selectable={{ - isDisabled: isReadOnly, + isDisabled: mlCardDisabled, onClick: setMl, isSelected: isMlRule(ruleType), }} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/translations.ts index 32b860e8f703e..4dc0a89af4a49 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/translations.ts @@ -33,10 +33,3 @@ export const ML_TYPE_DESCRIPTION = i18n.translate( defaultMessage: 'Select ML job to detect anomalous activity.', } ); - -export const ML_TYPE_DISABLED_DESCRIPTION = i18n.translate( - 'xpack.siem.detectionEngine.createRule.stepDefineRule.ruleTypeField.mlTypeDisabledDescription', - { - defaultMessage: 'Access to ML requires a Platinum subscription.', - } -); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index d3ef185f3786b..cf8cc4b87b388 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -13,13 +13,14 @@ import { EuiButton, } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; -import React, { FC, memo, useCallback, useState, useEffect } from 'react'; +import React, { FC, memo, useCallback, useState, useEffect, useContext } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/public'; import { useFetchIndexPatterns } from '../../../../../containers/detection_engine/rules'; import { DEFAULT_INDEX_KEY } from '../../../../../../common/constants'; +import { MlCapabilitiesContext } from '../../../../../components/ml/permissions/ml_capabilities_provider'; import { useUiSetting$ } from '../../../../../lib/kibana'; import { setFieldValue, isMlRule } from '../../helpers'; import * as RuleI18n from '../../translations'; @@ -103,6 +104,7 @@ const StepDefineRuleComponent: FC = ({ setForm, setStepData, }) => { + const mlCapabilities = useContext(MlCapabilitiesContext); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false); const [localIsMlRule, setIsMlRule] = useState(false); @@ -182,6 +184,8 @@ const StepDefineRuleComponent: FC = ({ path="ruleType" component={SelectRuleType} componentProps={{ + describedByIds: ['detectionEngineStepDefineRuleType'], + hasValidLicense: mlCapabilities.isPlatinumOrTrialLicense, isReadOnly: isUpdateView, }} /> @@ -220,7 +224,6 @@ const StepDefineRuleComponent: FC = ({ component={QueryBarDefineRule} componentProps={{ browserFields, - loading: indexPatternLoadingQueryBar, idAria: 'detectionEngineStepDefineRuleQueryBar', indexPattern: indexPatternQueryBar, isDisabled: isLoading, @@ -234,8 +237,20 @@ const StepDefineRuleComponent: FC = ({ <> - - + + From 91e8e3e883d51634a6b958c0ccd8d0011fdc5559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Mon, 23 Mar 2020 12:39:55 -0400 Subject: [PATCH 16/35] Adding `authc.invalidateAPIKeyAsInternalUser` (#60717) * Initial work * Fix type check issues * Fix test failures * Fix ESLint issues * Add back comment * PR feedback Co-authored-by: Elastic Machine --- .../server/authentication/api_keys.test.ts | 52 +++++++++++++++++++ .../server/authentication/api_keys.ts | 41 ++++++++++++--- .../server/authentication/index.mock.ts | 1 + .../server/authentication/index.test.ts | 23 +++++++- .../security/server/authentication/index.ts | 2 + x-pack/plugins/security/server/plugin.test.ts | 1 + 6 files changed, 111 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/security/server/authentication/api_keys.test.ts b/x-pack/plugins/security/server/authentication/api_keys.test.ts index 78b1d5f8e30b8..836740d0a547f 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.test.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.test.ts @@ -225,4 +225,56 @@ describe('API Keys', () => { ); }); }); + + describe('invalidateAsInternalUser()', () => { + it('returns null when security feature is disabled', async () => { + mockLicense.isEnabled.mockReturnValue(false); + const result = await apiKeys.invalidateAsInternalUser({ id: '123' }); + expect(result).toBeNull(); + expect(mockClusterClient.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('calls callCluster with proper parameters', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockClusterClient.callAsInternalUser.mockResolvedValueOnce({ + invalidated_api_keys: ['api-key-id-1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + const result = await apiKeys.invalidateAsInternalUser({ id: '123' }); + expect(result).toEqual({ + invalidated_api_keys: ['api-key-id-1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.invalidateAPIKey', { + body: { + id: '123', + }, + }); + }); + + it('Only passes id as a parameter', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockClusterClient.callAsInternalUser.mockResolvedValueOnce({ + invalidated_api_keys: ['api-key-id-1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + const result = await apiKeys.invalidateAsInternalUser({ + id: '123', + name: 'abc', + } as any); + expect(result).toEqual({ + invalidated_api_keys: ['api-key-id-1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.invalidateAPIKey', { + body: { + id: '123', + }, + }); + }); + }); }); diff --git a/x-pack/plugins/security/server/authentication/api_keys.ts b/x-pack/plugins/security/server/authentication/api_keys.ts index 0d77207e390ae..9df7219cec334 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.ts @@ -193,26 +193,51 @@ export class APIKeys { * @param request Request instance. * @param params The params to invalidate an API key. */ - async invalidate( - request: KibanaRequest, - params: InvalidateAPIKeyParams - ): Promise { + async invalidate(request: KibanaRequest, params: InvalidateAPIKeyParams) { if (!this.license.isEnabled()) { return null; } - this.logger.debug('Trying to invalidate an API key'); + this.logger.debug('Trying to invalidate an API key as current user'); - // User needs `manage_api_key` privilege to use this API let result: InvalidateAPIKeyResult; try { - result = (await this.clusterClient + // User needs `manage_api_key` privilege to use this API + result = await this.clusterClient .asScoped(request) .callAsCurrentUser('shield.invalidateAPIKey', { body: { id: params.id, }, - })) as InvalidateAPIKeyResult; + }); + this.logger.debug('API key was invalidated successfully as current user'); + } catch (e) { + this.logger.error(`Failed to invalidate API key as current user: ${e.message}`); + throw e; + } + + return result; + } + + /** + * Tries to invalidate an API key by using the internal user. + * @param params The params to invalidate an API key. + */ + async invalidateAsInternalUser(params: InvalidateAPIKeyParams) { + if (!this.license.isEnabled()) { + return null; + } + + this.logger.debug('Trying to invalidate an API key'); + + let result: InvalidateAPIKeyResult; + try { + // Internal user needs `cluster:admin/xpack/security/api_key/invalidate` privilege to use this API + result = await this.clusterClient.callAsInternalUser('shield.invalidateAPIKey', { + body: { + id: params.id, + }, + }); this.logger.debug('API key was invalidated successfully'); } catch (e) { this.logger.error(`Failed to invalidate API key: ${e.message}`); diff --git a/x-pack/plugins/security/server/authentication/index.mock.ts b/x-pack/plugins/security/server/authentication/index.mock.ts index 512de9626a986..43892753f0d3f 100644 --- a/x-pack/plugins/security/server/authentication/index.mock.ts +++ b/x-pack/plugins/security/server/authentication/index.mock.ts @@ -15,6 +15,7 @@ export const authenticationMock = { getCurrentUser: jest.fn(), grantAPIKeyAsInternalUser: jest.fn(), invalidateAPIKey: jest.fn(), + invalidateAPIKeyAsInternalUser: jest.fn(), isAuthenticated: jest.fn(), getSessionInfo: jest.fn(), }), diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index e364dbf39db65..21e5f18bc0282 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -33,7 +33,7 @@ import { import { AuthenticatedUser } from '../../common/model'; import { ConfigType, createConfig$ } from '../config'; import { AuthenticationResult } from './authentication_result'; -import { setupAuthentication } from '.'; +import { Authentication, setupAuthentication } from '.'; import { CreateAPIKeyResult, CreateAPIKeyParams, @@ -410,4 +410,25 @@ describe('setupAuthentication()', () => { expect(apiKeysInstance.invalidate).toHaveBeenCalledWith(request, params); }); }); + + describe('invalidateAPIKeyAsInternalUser()', () => { + let invalidateAPIKeyAsInternalUser: Authentication['invalidateAPIKeyAsInternalUser']; + + beforeEach(async () => { + invalidateAPIKeyAsInternalUser = (await setupAuthentication(mockSetupAuthenticationParams)) + .invalidateAPIKeyAsInternalUser; + }); + + it('calls invalidateAPIKeyAsInternalUser with given arguments', async () => { + const apiKeysInstance = jest.requireMock('./api_keys').APIKeys.mock.instances[0]; + const params = { + id: '123', + }; + apiKeysInstance.invalidateAsInternalUser.mockResolvedValueOnce({ success: true }); + await expect(invalidateAPIKeyAsInternalUser(params)).resolves.toEqual({ + success: true, + }); + expect(apiKeysInstance.invalidateAsInternalUser).toHaveBeenCalledWith(params); + }); + }); }); diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 8b42b2325ee1e..c5c72853e68e1 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -176,6 +176,8 @@ export async function setupAuthentication({ grantAPIKeyAsInternalUser: (request: KibanaRequest) => apiKeys.grantAsInternalUser(request), invalidateAPIKey: (request: KibanaRequest, params: InvalidateAPIKeyParams) => apiKeys.invalidate(request, params), + invalidateAPIKeyAsInternalUser: (params: InvalidateAPIKeyParams) => + apiKeys.invalidateAsInternalUser(params), isAuthenticated: (request: KibanaRequest) => http.auth.isAuthenticated(request), }; } diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index b817bcc0858a9..a011f7e7be11e 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -76,6 +76,7 @@ describe('Security Plugin', () => { "getSessionInfo": [Function], "grantAPIKeyAsInternalUser": [Function], "invalidateAPIKey": [Function], + "invalidateAPIKeyAsInternalUser": [Function], "isAuthenticated": [Function], "isProviderEnabled": [Function], "login": [Function], From de7151e2040ed88129d58acdae69963292a83aea Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Mon, 23 Mar 2020 16:40:56 +0000 Subject: [PATCH 17/35] [ML] Disabling datafeed editing when job is running (#60751) * [ML] Disabling datafeed editing when job is running * changing variable Co-authored-by: Elastic Machine --- .../edit_job_flyout/edit_job_flyout.js | 7 +++++ .../edit_job_flyout/tabs/datafeed.js | 26 ++++++++++++++++++- .../edit_job_flyout/tabs/job_details.js | 11 ++++++++ .../server/routes/schemas/datafeeds_schema.ts | 2 +- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js index aec57e0d33cdd..29c79458fe431 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js @@ -31,6 +31,7 @@ import { withKibana } from '../../../../../../../../../src/plugins/kibana_react/ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { collapseLiteralStrings } from '../../../../../../shared_imports'; +import { DATAFEED_STATE } from '../../../../../../common/constants/states'; export class EditJobFlyoutUI extends Component { _initialJobFormState = null; @@ -41,6 +42,7 @@ export class EditJobFlyoutUI extends Component { this.state = { job: {}, hasDatafeed: false, + datafeedRunning: false, isFlyoutVisible: false, isConfirmationModalVisible: false, jobDescription: '', @@ -157,10 +159,12 @@ export class EditJobFlyoutUI extends Component { extractJob(job, hasDatafeed) { this.extractInitialJobFormState(job, hasDatafeed); + const datafeedRunning = hasDatafeed && job.datafeed_config.state !== DATAFEED_STATE.STOPPED; this.setState({ job, hasDatafeed, + datafeedRunning, jobModelMemoryLimitValidationError: '', jobGroupsValidationError: '', ...cloneDeep(this._initialJobFormState), @@ -283,6 +287,7 @@ export class EditJobFlyoutUI extends Component { jobModelMemoryLimitValidationError, isValidJobDetails, isValidJobCustomUrls, + datafeedRunning, } = this.state; const tabs = [ @@ -293,6 +298,7 @@ export class EditJobFlyoutUI extends Component { }), content: ( ), }, diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/datafeed.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/datafeed.js index 096a03621d422..3d81b767021a0 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/datafeed.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/datafeed.js @@ -7,7 +7,14 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { EuiFieldText, EuiForm, EuiFormRow, EuiSpacer, EuiFieldNumber } from '@elastic/eui'; +import { + EuiFieldText, + EuiForm, + EuiFormRow, + EuiSpacer, + EuiFieldNumber, + EuiCallOut, +} from '@elastic/eui'; import { calculateDatafeedFrequencyDefaultSeconds } from '../../../../../../../common/util/job_utils'; import { getNewJobDefaults } from '../../../../../services/ml_server_info'; @@ -72,9 +79,21 @@ export class Datafeed extends Component { render() { const { query, queryDelay, frequency, scrollSize, defaults } = this.state; + const { datafeedRunning } = this.props; return ( + {datafeedRunning && ( + <> + + + + + + )} @@ -140,6 +163,7 @@ export class Datafeed extends Component { } } Datafeed.propTypes = { + datafeedRunning: PropTypes.bool.isRequired, datafeedQuery: PropTypes.string.isRequired, datafeedQueryDelay: PropTypes.string.isRequired, datafeedFrequency: PropTypes.string.isRequired, diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/job_details.js index a609d6a7c3fba..672fd8cefaaba 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/job_details.js @@ -105,6 +105,7 @@ export class JobDetails extends Component { mmlValidationError, groupsValidationError, } = this.state; + const { datafeedRunning } = this.props; return ( @@ -152,6 +153,14 @@ export class JobDetails extends Component { defaultMessage="Model memory limit" /> } + helpText={ + datafeedRunning ? ( + + ) : null + } isInvalid={mmlValidationError !== ''} error={mmlValidationError} > @@ -160,6 +169,7 @@ export class JobDetails extends Component { onChange={this.onMmlChange} isInvalid={mmlValidationError !== ''} error={mmlValidationError} + disabled={datafeedRunning} /> @@ -168,6 +178,7 @@ export class JobDetails extends Component { } } JobDetails.propTypes = { + datafeedRunning: PropTypes.bool.isRequired, jobDescription: PropTypes.string.isRequired, jobGroups: PropTypes.array.isRequired, jobModelMemoryLimit: PropTypes.string.isRequired, diff --git a/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts b/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts index ee49da6538460..466e70197e3d1 100644 --- a/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts @@ -24,7 +24,7 @@ export const datafeedConfigSchema = schema.object({ }) ), frequency: schema.maybe(schema.string()), - indices: schema.arrayOf(schema.string()), + indices: schema.maybe(schema.arrayOf(schema.string())), indexes: schema.maybe(schema.arrayOf(schema.string())), job_id: schema.maybe(schema.string()), query: schema.maybe(schema.any()), From 8143c078b6fffa9319a7809e2a6ccd30f099ac17 Mon Sep 17 00:00:00 2001 From: Andrew Cholakian Date: Mon, 23 Mar 2020 11:54:49 -0500 Subject: [PATCH 18/35] [Uptime] Skip failing location test temporarily (#60938) --- x-pack/test/functional/apps/uptime/locations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/uptime/locations.ts b/x-pack/test/functional/apps/uptime/locations.ts index 7f6932ab50319..96c7fad89a85d 100644 --- a/x-pack/test/functional/apps/uptime/locations.ts +++ b/x-pack/test/functional/apps/uptime/locations.ts @@ -10,7 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['uptime']); - describe('location', () => { + describe.skip('location', () => { const start = new Date().toISOString(); const end = new Date().toISOString(); From 3c6666263064830cea7a4d3b9c522a3bb7feafe7 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Mon, 23 Mar 2020 12:33:00 -0500 Subject: [PATCH 19/35] [Metrics Alerts] Remove metric field from doc count on backend (#60679) * Remove metric field from doc count on backend * Fix tests * Type fix Co-authored-by: Elastic Machine --- .../metric_threshold_executor.test.ts | 3 +- .../metric_threshold_executor.ts | 41 +++++++++++------ .../register_metric_threshold_alert_type.ts | 44 ++++++++++++++----- .../lib/alerting/metric_threshold/types.ts | 16 +++++-- .../apis/infra/metrics_alerting.ts | 35 +++++---------- 5 files changed, 86 insertions(+), 53 deletions(-) diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index a6b9b70feede2..feaa404ae960a 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -17,7 +17,7 @@ const alertInstances = new Map(); const services = { callCluster(_: string, { body }: any) { - const metric = body.query.bool.filter[1].exists.field; + const metric = body.query.bool.filter[1]?.exists.field; if (body.aggs.groupings) { if (body.aggs.groupings.composite.after) { return mocks.compositeEndResponse; @@ -228,6 +228,7 @@ describe('The metric threshold alert type', () => { comparator, threshold, aggType: 'count', + metric: undefined, }, ], }, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index 8c509c017cf20..778889ba0c7a5 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -63,6 +63,12 @@ export const getElasticsearchMetricQuery = ( groupBy?: string, filterQuery?: string ) => { + if (aggType === 'count' && metric) { + throw new Error('Cannot aggregate document count with a metric'); + } + if (aggType !== 'count' && !metric) { + throw new Error('Can only aggregate without a metric if using the document count aggregator'); + } const interval = `${timeSize}${timeUnit}`; const aggregations = @@ -108,25 +114,32 @@ export const getElasticsearchMetricQuery = ( } : baseAggs; + const rangeFilters = [ + { + range: { + '@timestamp': { + gte: `now-${interval}`, + }, + }, + }, + ]; + + const metricFieldFilters = metric + ? [ + { + exists: { + field: metric, + }, + }, + ] + : []; + const parsedFilterQuery = getParsedFilterQuery(filterQuery); return { query: { bool: { - filter: [ - { - range: { - '@timestamp': { - gte: `now-${interval}`, - }, - }, - }, - { - exists: { - field: metric, - }, - }, - ], + filter: [...rangeFilters, ...metricFieldFilters], ...parsedFilterQuery, }, }, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index 501d7549e1712..ed3a9b2f4fe36 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -17,22 +17,44 @@ export async function registerMetricThresholdAlertType(alertingPlugin: PluginSet } const alertUUID = uuid.v4(); + const baseCriterion = { + threshold: schema.arrayOf(schema.number()), + comparator: schema.oneOf([ + schema.literal('>'), + schema.literal('<'), + schema.literal('>='), + schema.literal('<='), + schema.literal('between'), + ]), + timeUnit: schema.string(), + timeSize: schema.number(), + indexPattern: schema.string(), + }; + + const nonCountCriterion = schema.object({ + ...baseCriterion, + metric: schema.string(), + aggType: schema.oneOf([ + schema.literal('avg'), + schema.literal('min'), + schema.literal('max'), + schema.literal('rate'), + schema.literal('cardinality'), + ]), + }); + + const countCriterion = schema.object({ + ...baseCriterion, + aggType: schema.literal('count'), + metric: schema.never(), + }); + alertingPlugin.registerType({ id: METRIC_THRESHOLD_ALERT_TYPE_ID, name: 'Metric Alert - Threshold', validate: { params: schema.object({ - criteria: schema.arrayOf( - schema.object({ - threshold: schema.arrayOf(schema.number()), - comparator: schema.string(), - aggType: schema.string(), - metric: schema.string(), - timeUnit: schema.string(), - timeSize: schema.number(), - indexPattern: schema.string(), - }) - ), + criteria: schema.arrayOf(schema.oneOf([countCriterion, nonCountCriterion])), groupBy: schema.maybe(schema.string()), filterQuery: schema.maybe(schema.string()), }), diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts index 07739c9d81bc4..557a071ec9175 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts @@ -25,12 +25,22 @@ export enum AlertStates { export type TimeUnit = 's' | 'm' | 'h' | 'd'; -export interface MetricExpressionParams { - aggType: MetricsExplorerAggregation; - metric: string; +interface BaseMetricExpressionParams { timeSize: number; timeUnit: TimeUnit; indexPattern: string; threshold: number[]; comparator: Comparator; } + +interface NonCountMetricExpressionParams extends BaseMetricExpressionParams { + aggType: Exclude; + metric: string; +} + +interface CountMetricExpressionParams extends BaseMetricExpressionParams { + aggType: 'count'; + metric: never; +} + +export type MetricExpressionParams = NonCountMetricExpressionParams | CountMetricExpressionParams; diff --git a/x-pack/test/api_integration/apis/infra/metrics_alerting.ts b/x-pack/test/api_integration/apis/infra/metrics_alerting.ts index 09f5a498ddc00..4f17f9db67483 100644 --- a/x-pack/test/api_integration/apis/infra/metrics_alerting.ts +++ b/x-pack/test/api_integration/apis/infra/metrics_alerting.ts @@ -13,11 +13,13 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function({ getService }: FtrProviderContext) { const client = getService('legacyEs'); const index = 'test-index'; - const baseParams = { - metric: 'test.metric', - timeUnit: 'm', - timeSize: 5, - }; + const getSearchParams = (aggType: string) => + ({ + aggType, + timeUnit: 'm', + timeSize: 5, + ...(aggType !== 'count' ? { metric: 'test.metric' } : {}), + } as MetricExpressionParams); describe('Metrics Threshold Alerts', () => { before(async () => { await client.index({ @@ -30,10 +32,7 @@ export default function({ getService }: FtrProviderContext) { describe('querying the entire infrastructure', () => { for (const aggType of aggs) { it(`should work with the ${aggType} aggregator`, async () => { - const searchBody = getElasticsearchMetricQuery({ - ...baseParams, - aggType, - } as MetricExpressionParams); + const searchBody = getElasticsearchMetricQuery(getSearchParams(aggType)); const result = await client.search({ index, body: searchBody, @@ -44,10 +43,7 @@ export default function({ getService }: FtrProviderContext) { } it('should work with a filterQuery', async () => { const searchBody = getElasticsearchMetricQuery( - { - ...baseParams, - aggType: 'avg', - } as MetricExpressionParams, + getSearchParams('avg'), undefined, '{"bool":{"should":[{"match_phrase":{"agent.hostname":"foo"}}],"minimum_should_match":1}}' ); @@ -62,13 +58,7 @@ export default function({ getService }: FtrProviderContext) { describe('querying with a groupBy parameter', () => { for (const aggType of aggs) { it(`should work with the ${aggType} aggregator`, async () => { - const searchBody = getElasticsearchMetricQuery( - { - ...baseParams, - aggType, - } as MetricExpressionParams, - 'agent.id' - ); + const searchBody = getElasticsearchMetricQuery(getSearchParams(aggType), 'agent.id'); const result = await client.search({ index, body: searchBody, @@ -79,10 +69,7 @@ export default function({ getService }: FtrProviderContext) { } it('should work with a filterQuery', async () => { const searchBody = getElasticsearchMetricQuery( - { - ...baseParams, - aggType: 'avg', - } as MetricExpressionParams, + getSearchParams('avg'), 'agent.id', '{"bool":{"should":[{"match_phrase":{"agent.hostname":"foo"}}],"minimum_should_match":1}}' ); From 85481a7017cd9083e4b6288a8d7ce3febf1b2193 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Mon, 23 Mar 2020 13:35:27 -0400 Subject: [PATCH 20/35] [UA] Upgrade assistant migration meta data can become stale (#60789) --- .../server/lib/reindexing/reindex_service.ts | 23 +++++++++++++++++++ .../server/routes/cluster_checkup.ts | 23 +++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts index 47b7388131ff1..1fd022bce4dcf 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts @@ -76,6 +76,12 @@ export interface ReindexService { */ findReindexOperation(indexName: string): Promise; + /** + * Delete reindex operations for completed indices with deprecations. + * @param indexNames + */ + cleanupReindexOperations(indexNames: string[]): Promise | null; + /** * Process the reindex operation through one step of the state machine and resolves * to the updated reindex operation. @@ -603,6 +609,23 @@ export const reindexServiceFactory = ( return findResponse.saved_objects[0]; }, + async cleanupReindexOperations(indexNames: string[]) { + const performCleanup = async (indexName: string) => { + const existingReindexOps = await actions.findReindexOperations(indexName); + + if (existingReindexOps && existingReindexOps.total !== 0) { + const existingOp = existingReindexOps.saved_objects[0]; + if (existingOp.attributes.status === ReindexStatus.completed) { + // Delete the existing one if its status is completed, but still contains deprecation warnings + // example scenario: index was upgraded, but then deleted and restored with an old snapshot + await actions.deleteReindexOp(existingOp); + } + } + }; + + await Promise.all(indexNames.map(performCleanup)); + }, + findAllByStatus: actions.findAllByStatus, async processNextStep(reindexOp: ReindexSavedObject) { diff --git a/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.ts b/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.ts index 22a121ab78683..fa4649f1c5dcd 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.ts @@ -7,8 +7,10 @@ import { getUpgradeAssistantStatus } from '../lib/es_migration_apis'; import { versionCheckHandlerWrapper } from '../lib/es_version_precheck'; import { RouteDependencies } from '../types'; +import { reindexActionsFactory } from '../lib/reindexing/reindex_actions'; +import { reindexServiceFactory } from '../lib/reindexing'; -export function registerClusterCheckupRoutes({ cloud, router }: RouteDependencies) { +export function registerClusterCheckupRoutes({ cloud, router, licensing, log }: RouteDependencies) { const isCloudEnabled = Boolean(cloud?.isCloudEnabled); router.get( @@ -20,6 +22,7 @@ export function registerClusterCheckupRoutes({ cloud, router }: RouteDependencie async ( { core: { + savedObjects: { client: savedObjectsClient }, elasticsearch: { dataClient }, }, }, @@ -27,8 +30,24 @@ export function registerClusterCheckupRoutes({ cloud, router }: RouteDependencie response ) => { try { + const status = await getUpgradeAssistantStatus(dataClient, isCloudEnabled); + + const callAsCurrentUser = dataClient.callAsCurrentUser.bind(dataClient); + const reindexActions = reindexActionsFactory(savedObjectsClient, callAsCurrentUser); + const reindexService = reindexServiceFactory( + callAsCurrentUser, + reindexActions, + log, + licensing + ); + const indexNames = status.indices + .filter(({ index }) => typeof index !== 'undefined') + .map(({ index }) => index as string); + + await reindexService.cleanupReindexOperations(indexNames); + return response.ok({ - body: await getUpgradeAssistantStatus(dataClient, isCloudEnabled), + body: status, }); } catch (e) { if (e.status === 403) { From 10afcf4be89cf01da3c9d942deaa2c0479be5691 Mon Sep 17 00:00:00 2001 From: MadameSheema Date: Mon, 23 Mar 2020 18:46:35 +0100 Subject: [PATCH 21/35] [SIEM] Adds 'Open one signal' Cypress test (#60484) * adds data for having closed signals * adds 'Open one signal when more than one closed signals are selected' test' Co-authored-by: Elastic Machine --- .../cypress/integration/detections.spec.ts | 299 +- .../plugins/siem/cypress/tasks/detections.ts | 6 + .../es_archives/closed_signals/data.json.gz | Bin 0 -> 55877 bytes .../es_archives/closed_signals/mappings.json | 7605 +++++++++++++++++ 4 files changed, 7787 insertions(+), 123 deletions(-) create mode 100644 x-pack/test/siem_cypress/es_archives/closed_signals/data.json.gz create mode 100644 x-pack/test/siem_cypress/es_archives/closed_signals/mappings.json diff --git a/x-pack/legacy/plugins/siem/cypress/integration/detections.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/detections.spec.ts index de17f40a3ac71..646132c3f88eb 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/detections.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/detections.spec.ts @@ -16,6 +16,7 @@ import { closeSignals, goToClosedSignals, goToOpenedSignals, + openFirstSignal, openSignals, selectNumberOfSignals, waitForSignalsPanelToBeLoaded, @@ -28,129 +29,181 @@ import { loginAndWaitForPage } from '../tasks/login'; import { DETECTIONS } from '../urls/navigation'; describe('Detections', () => { - beforeEach(() => { - esArchiverLoad('signals'); - loginAndWaitForPage(DETECTIONS); + context('Closing signals', () => { + beforeEach(() => { + esArchiverLoad('signals'); + loginAndWaitForPage(DETECTIONS); + }); + + it('Closes and opens signals', () => { + waitForSignalsPanelToBeLoaded(); + waitForSignalsToBeLoaded(); + + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .then(numberOfSignals => { + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should('eql', `Showing ${numberOfSignals} signals`); + + const numberOfSignalsToBeClosed = 3; + selectNumberOfSignals(numberOfSignalsToBeClosed); + + cy.get(SELECTED_SIGNALS) + .invoke('text') + .should('eql', `Selected ${numberOfSignalsToBeClosed} signals`); + + closeSignals(); + waitForSignals(); + cy.reload(); + waitForSignals(); + + const expectedNumberOfSignalsAfterClosing = +numberOfSignals - numberOfSignalsToBeClosed; + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .should('eq', expectedNumberOfSignalsAfterClosing.toString()); + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should('eql', `Showing ${expectedNumberOfSignalsAfterClosing.toString()} signals`); + + goToClosedSignals(); + waitForSignals(); + + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .should('eql', numberOfSignalsToBeClosed.toString()); + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should('eql', `Showing ${numberOfSignalsToBeClosed.toString()} signals`); + cy.get(SIGNALS).should('have.length', numberOfSignalsToBeClosed); + + const numberOfSignalsToBeOpened = 1; + selectNumberOfSignals(numberOfSignalsToBeOpened); + + cy.get(SELECTED_SIGNALS) + .invoke('text') + .should('eql', `Selected ${numberOfSignalsToBeOpened} signal`); + + openSignals(); + waitForSignals(); + cy.reload(); + waitForSignalsToBeLoaded(); + waitForSignals(); + goToClosedSignals(); + waitForSignals(); + + const expectedNumberOfClosedSignalsAfterOpened = 2; + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .should('eql', expectedNumberOfClosedSignalsAfterOpened.toString()); + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should( + 'eql', + `Showing ${expectedNumberOfClosedSignalsAfterOpened.toString()} signals` + ); + cy.get(SIGNALS).should('have.length', expectedNumberOfClosedSignalsAfterOpened); + + goToOpenedSignals(); + waitForSignals(); + + const expectedNumberOfOpenedSignals = + +numberOfSignals - expectedNumberOfClosedSignalsAfterOpened; + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should('eql', `Showing ${expectedNumberOfOpenedSignals.toString()} signals`); + + cy.get('[data-test-subj="server-side-event-count"]') + .invoke('text') + .should('eql', expectedNumberOfOpenedSignals.toString()); + }); + }); + + it('Closes one signal when more than one opened signals are selected', () => { + waitForSignalsToBeLoaded(); + + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .then(numberOfSignals => { + const numberOfSignalsToBeClosed = 1; + const numberOfSignalsToBeSelected = 3; + + cy.get(OPEN_CLOSE_SIGNALS_BTN).should('have.attr', 'disabled'); + selectNumberOfSignals(numberOfSignalsToBeSelected); + cy.get(OPEN_CLOSE_SIGNALS_BTN).should('not.have.attr', 'disabled'); + + closeFirstSignal(); + cy.reload(); + waitForSignalsToBeLoaded(); + waitForSignals(); + + const expectedNumberOfSignals = +numberOfSignals - numberOfSignalsToBeClosed; + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .should('eq', expectedNumberOfSignals.toString()); + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should('eql', `Showing ${expectedNumberOfSignals.toString()} signals`); + + goToClosedSignals(); + waitForSignals(); + + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .should('eql', numberOfSignalsToBeClosed.toString()); + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should('eql', `Showing ${numberOfSignalsToBeClosed.toString()} signal`); + cy.get(SIGNALS).should('have.length', numberOfSignalsToBeClosed); + }); + }); }); - - it('Closes and opens signals', () => { - waitForSignalsPanelToBeLoaded(); - waitForSignalsToBeLoaded(); - - cy.get(NUMBER_OF_SIGNALS) - .invoke('text') - .then(numberOfSignals => { - cy.get(SHOWING_SIGNALS) - .invoke('text') - .should('eql', `Showing ${numberOfSignals} signals`); - - const numberOfSignalsToBeClosed = 3; - selectNumberOfSignals(numberOfSignalsToBeClosed); - - cy.get(SELECTED_SIGNALS) - .invoke('text') - .should('eql', `Selected ${numberOfSignalsToBeClosed} signals`); - - closeSignals(); - waitForSignals(); - cy.reload(); - waitForSignals(); - waitForSignalsToBeLoaded(); - - const expectedNumberOfSignalsAfterClosing = +numberOfSignals - numberOfSignalsToBeClosed; - cy.get(NUMBER_OF_SIGNALS) - .invoke('text') - .should('eq', expectedNumberOfSignalsAfterClosing.toString()); - cy.get(SHOWING_SIGNALS) - .invoke('text') - .should('eql', `Showing ${expectedNumberOfSignalsAfterClosing.toString()} signals`); - - goToClosedSignals(); - waitForSignals(); - - cy.get(NUMBER_OF_SIGNALS) - .invoke('text') - .should('eql', numberOfSignalsToBeClosed.toString()); - cy.get(SHOWING_SIGNALS) - .invoke('text') - .should('eql', `Showing ${numberOfSignalsToBeClosed.toString()} signals`); - cy.get(SIGNALS).should('have.length', numberOfSignalsToBeClosed); - - const numberOfSignalsToBeOpened = 1; - selectNumberOfSignals(numberOfSignalsToBeOpened); - - cy.get(SELECTED_SIGNALS) - .invoke('text') - .should('eql', `Selected ${numberOfSignalsToBeOpened} signal`); - - openSignals(); - waitForSignals(); - cy.reload(); - waitForSignalsToBeLoaded(); - waitForSignals(); - goToClosedSignals(); - waitForSignals(); - - const expectedNumberOfClosedSignalsAfterOpened = 2; - cy.get(NUMBER_OF_SIGNALS) - .invoke('text') - .should('eql', expectedNumberOfClosedSignalsAfterOpened.toString()); - cy.get(SHOWING_SIGNALS) - .invoke('text') - .should('eql', `Showing ${expectedNumberOfClosedSignalsAfterOpened.toString()} signals`); - cy.get(SIGNALS).should('have.length', expectedNumberOfClosedSignalsAfterOpened); - - goToOpenedSignals(); - waitForSignals(); - - const expectedNumberOfOpenedSignals = - +numberOfSignals - expectedNumberOfClosedSignalsAfterOpened; - cy.get(SHOWING_SIGNALS) - .invoke('text') - .should('eql', `Showing ${expectedNumberOfOpenedSignals.toString()} signals`); - - cy.get('[data-test-subj="server-side-event-count"]') - .invoke('text') - .should('eql', expectedNumberOfOpenedSignals.toString()); - }); - }); - - it('Closes one signal when more than one opened signals are selected', () => { - waitForSignalsToBeLoaded(); - - cy.get(NUMBER_OF_SIGNALS) - .invoke('text') - .then(numberOfSignals => { - const numberOfSignalsToBeClosed = 1; - const numberOfSignalsToBeSelected = 3; - - cy.get(OPEN_CLOSE_SIGNALS_BTN).should('have.attr', 'disabled'); - selectNumberOfSignals(numberOfSignalsToBeSelected); - cy.get(OPEN_CLOSE_SIGNALS_BTN).should('not.have.attr', 'disabled'); - - closeFirstSignal(); - cy.reload(); - waitForSignalsToBeLoaded(); - waitForSignals(); - - const expectedNumberOfSignals = +numberOfSignals - numberOfSignalsToBeClosed; - cy.get(NUMBER_OF_SIGNALS) - .invoke('text') - .should('eq', expectedNumberOfSignals.toString()); - cy.get(SHOWING_SIGNALS) - .invoke('text') - .should('eql', `Showing ${expectedNumberOfSignals.toString()} signals`); - - goToClosedSignals(); - waitForSignals(); - - cy.get(NUMBER_OF_SIGNALS) - .invoke('text') - .should('eql', numberOfSignalsToBeClosed.toString()); - cy.get(SHOWING_SIGNALS) - .invoke('text') - .should('eql', `Showing ${numberOfSignalsToBeClosed.toString()} signal`); - cy.get(SIGNALS).should('have.length', numberOfSignalsToBeClosed); - }); + context('Opening signals', () => { + beforeEach(() => { + esArchiverLoad('closed_signals'); + loginAndWaitForPage(DETECTIONS); + }); + + it('Open one signal when more than one closed signals are selected', () => { + waitForSignals(); + goToClosedSignals(); + waitForSignalsToBeLoaded(); + + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .then(numberOfSignals => { + const numberOfSignalsToBeOpened = 1; + const numberOfSignalsToBeSelected = 3; + + cy.get(OPEN_CLOSE_SIGNALS_BTN).should('have.attr', 'disabled'); + selectNumberOfSignals(numberOfSignalsToBeSelected); + cy.get(OPEN_CLOSE_SIGNALS_BTN).should('not.have.attr', 'disabled'); + + openFirstSignal(); + cy.reload(); + goToClosedSignals(); + waitForSignalsToBeLoaded(); + waitForSignals(); + + const expectedNumberOfSignals = +numberOfSignals - numberOfSignalsToBeOpened; + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .should('eq', expectedNumberOfSignals.toString()); + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should('eql', `Showing ${expectedNumberOfSignals.toString()} signals`); + + goToOpenedSignals(); + waitForSignals(); + + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .should('eql', numberOfSignalsToBeOpened.toString()); + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should('eql', `Showing ${numberOfSignalsToBeOpened.toString()} signal`); + cy.get(SIGNALS).should('have.length', numberOfSignalsToBeOpened); + }); + }); }); }); diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts b/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts index 3416e3eb81de3..abea4a887b8ba 100644 --- a/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts +++ b/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts @@ -40,6 +40,12 @@ export const goToOpenedSignals = () => { cy.get(OPENED_SIGNALS_BTN).click({ force: true }); }; +export const openFirstSignal = () => { + cy.get(OPEN_CLOSE_SIGNAL_BTN) + .first() + .click({ force: true }); +}; + export const openSignals = () => { cy.get(OPEN_CLOSE_SIGNALS_BTN).click({ force: true }); }; diff --git a/x-pack/test/siem_cypress/es_archives/closed_signals/data.json.gz b/x-pack/test/siem_cypress/es_archives/closed_signals/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..117c829b31d6e4fad894f589c5244678206ec21a GIT binary patch literal 55877 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 z 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 iySM2NRC!hZruyFqFJU18H~yD# Date: Mon, 23 Mar 2020 19:14:14 +0100 Subject: [PATCH 22/35] Inline timezoneProvider function, remove ui/vis/lib/timezone (#60475) * Inline getTimezone in discover, vis_type_timeseries, timelion app & vis_type_timelion --- .../kibana/public/discover/kibana_services.ts | 3 --- .../np_ready/angular/directives/histogram.tsx | 15 +++++++++++++-- .../core_plugins/timelion/public/app.js | 7 +++---- .../public/helpers/get_timezone.ts} | 19 +++++++++---------- .../helpers/timelion_request_handler.ts | 4 ++-- .../vis_type_timelion/public/index.ts | 2 ++ .../public/legacy_imports.ts | 3 --- .../get_timezone.ts} | 14 ++++++++++++-- .../public/request_handler.js | 4 ++-- .../visualizations/views/timeseries/index.js | 5 ++--- 10 files changed, 45 insertions(+), 31 deletions(-) rename src/legacy/{ui/public/vis/lib/timezone.js => core_plugins/vis_type_timelion/public/helpers/get_timezone.ts} (70%) rename src/legacy/core_plugins/vis_type_timeseries/public/{legacy_imports.ts => lib/get_timezone.ts} (66%) diff --git a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts index cf76a9355e384..d369eb9679de6 100644 --- a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts @@ -52,9 +52,6 @@ export { angular }; export { wrapInI18nContext } from 'ui/i18n'; import { search } from '../../../../../plugins/data/public'; export const { getRequestInspectorStats, getResponseInspectorStats, tabifyAggResponse } = search; -// @ts-ignore -// @ts-ignore -export { timezoneProvider } from 'ui/vis/lib/timezone'; export { unhashUrl, redirectWhenMissing, diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx index 107c30ec5e688..f788347ac016c 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx @@ -42,9 +42,10 @@ import { } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; +import { IUiSettingsClient } from 'kibana/public'; import { EuiChartThemeType } from '@elastic/eui/dist/eui_charts_theme'; import { Subscription } from 'rxjs'; -import { getServices, timezoneProvider } from '../../../kibana_services'; +import { getServices } from '../../../kibana_services'; export interface DiscoverHistogramProps { chartData: any; @@ -86,6 +87,16 @@ function getIntervalInMs( } } +function getTimezone(uiSettings: IUiSettingsClient) { + if (uiSettings.isDefault('dateFormat:tz')) { + const detectedTimezone = moment.tz.guess(); + if (detectedTimezone) return detectedTimezone; + else return moment().format('Z'); + } else { + return uiSettings.get('dateFormat:tz', 'Browser'); + } +} + export function findMinInterval( xValues: number[], esValue: number, @@ -193,7 +204,7 @@ export class DiscoverHistogram extends Component { const config = getUISettings(); - const timezone = timezoneProvider(config)(); + const timezone = getTimezone(config); const uiStateObj = uiState.get(visParams.type, {}); const parsedTimeRange = getDataStart().query.timefilter.timefilter.calculateBounds(timeRange); const scaledDataFormat = config.get('dateFormat:scaled'); diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js index bec76433eb8ac..3ce3aae2649e1 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js @@ -31,8 +31,7 @@ import { TooltipType, } from '@elastic/charts'; import { EuiIcon } from '@elastic/eui'; - -import { timezoneProvider } from '../../../legacy_imports'; +import { getTimezone } from '../../../lib/get_timezone'; import { eventBus, ACTIVE_CURSOR } from '../../lib/active_cursor'; import { getUISettings } from '../../../services'; import { GRID_LINE_CONFIG, ICON_TYPES_MAP, STACKED_OPTIONS } from '../../constants'; @@ -87,7 +86,7 @@ export const TimeSeries = ({ const tooltipFormatter = decorateFormatter(xAxisFormatter); const uiSettings = getUISettings(); - const timeZone = timezoneProvider(uiSettings)(); + const timeZone = getTimezone(uiSettings); const hasBarChart = series.some(({ bars }) => bars.show); // compute the theme based on the bg color From ed5553120716c0918b3d66762fe9b4b7cc3d9d48 Mon Sep 17 00:00:00 2001 From: Peter Pisljar Date: Mon, 23 Mar 2020 19:19:39 +0100 Subject: [PATCH 23/35] cleanup visualizations api (#59958) --- ...ns-data-public.aggconfigoptions.enabled.md | 11 + ...plugins-data-public.aggconfigoptions.id.md | 11 + ...in-plugins-data-public.aggconfigoptions.md | 22 ++ ...ins-data-public.aggconfigoptions.params.md | 11 + ...ins-data-public.aggconfigoptions.schema.md | 11 + ...ugins-data-public.aggconfigoptions.type.md | 11 + .../kibana-plugin-plugins-data-public.md | 1 + .../public/input_control_vis_type.ts | 2 - .../public/vis_controller.tsx | 2 +- .../discover/np_ready/angular/discover.js | 44 ++-- .../public/visualize/np_ready/breadcrumbs.ts | 2 +- .../visualize/np_ready/editor/editor.html | 9 +- .../visualize/np_ready/editor/editor.js | 144 ++++++----- .../np_ready/editor/visualization.js | 34 +-- .../np_ready/editor/visualization_editor.js | 19 +- .../public/visualize/np_ready/legacy_app.js | 63 +++-- .../public/visualize/np_ready/types.d.ts | 9 +- .../__tests__/region_map_visualization.js | 11 +- .../region_map/public/region_map_type.js | 8 +- .../public/region_map_visualization.js | 4 +- .../coordinate_maps_visualization.js | 11 +- .../public/base_maps_visualization.js | 17 +- .../tile_map/public/tile_map_type.js | 9 +- .../public/components/agg.test.tsx | 4 +- .../public/components/agg_common_props.ts | 5 +- .../public/components/agg_group.test.tsx | 10 +- .../public/components/agg_group.tsx | 7 +- .../public/components/agg_param_props.ts | 4 +- .../public/components/agg_params.test.tsx | 4 +- .../components/agg_params_helper.test.ts | 4 +- .../public/components/agg_params_helper.ts | 4 +- .../public/components/controls/field.test.tsx | 4 +- .../components/controls/percentiles.test.tsx | 4 +- .../public/components/controls/test_utils.ts | 4 +- .../public/components/sidebar/data_tab.tsx | 4 +- .../public/components/sidebar/sidebar.tsx | 47 ++-- .../components/sidebar/sidebar_title.tsx | 18 +- .../public/components/sidebar/state/index.ts | 32 +-- .../components/sidebar/state/reducers.ts | 67 +++-- .../public/default_editor.tsx | 67 ++--- .../public/default_editor_controller.tsx | 16 +- .../public/vis_options_props.tsx | 2 +- .../components/metric_vis_component.test.tsx | 6 +- .../components/metric_vis_component.tsx | 5 +- .../public/metric_vis_type.test.ts | 15 +- .../public/agg_table/__tests__/agg_table.js | 84 ++++--- .../agg_table/__tests__/agg_table_group.js | 35 ++- .../public/table_vis_controller.test.ts | 32 +-- .../vis_type_table/public/vis_controller.ts | 17 +- .../__tests__/tag_cloud_visualization.js | 10 +- .../components/tag_cloud_visualization.js | 15 +- .../public/tag_cloud_type.ts | 2 - .../public/components/timelion_vis.tsx | 4 +- .../public/components/vis_editor.js | 17 +- .../components/vis_editor_visualization.js | 13 +- .../public/editor_controller.js | 16 +- .../views/timeseries/utils/theme.ts | 1 + .../public/__tests__/vega_visualization.js | 7 +- .../vis_type_vega/public/vega_type.ts | 2 - .../public/vega_visualization.js | 11 +- .../__snapshots__/index.test.tsx.snap | 6 +- .../options/metrics_axes/index.test.tsx | 6 +- .../components/options/metrics_axes/index.tsx | 2 +- .../options/point_series/point_series.tsx | 4 +- .../vis_type_vislib/public/vis_controller.tsx | 5 +- .../__tests__/visualizations/pie_chart.js | 34 ++- .../vislib/components/legend/legend.tsx | 16 +- .../public/components/visualization.test.js | 11 +- .../public/components/visualization.tsx | 20 +- .../public/components/visualization_chart.tsx | 24 +- .../public/embeddable/get_index_pattern.ts | 8 +- .../public/embeddable/visualize_embeddable.ts | 130 ++++------ .../visualize_embeddable_factory.tsx | 19 +- .../public/expressions/{vis.js => vis.ts} | 81 +++--- .../expressions/visualization_renderer.tsx | 9 +- .../public/np_ready/public/index.ts | 3 +- .../public/legacy/build_pipeline.test.ts | 234 ++++++++---------- .../np_ready/public/legacy/build_pipeline.ts | 109 ++++---- .../public/legacy/update_status.test.js | 102 -------- .../np_ready/public/legacy/update_status.ts | 117 --------- .../public/np_ready/public/mocks.ts | 2 + .../public/np_ready/public/plugin.ts | 21 +- .../public/saved_visualizations/_saved_vis.ts | 135 +++++----- .../saved_visualization_references.test.ts | 6 +- .../public/np_ready/public/services.ts | 9 + .../public/np_ready/public/types.ts | 34 ++- .../public/np_ready/public/vis.ts | 201 ++++++++++++--- .../public/np_ready/public/vis_impl.d.ts | 55 ---- .../public/np_ready/public/vis_impl.js | 223 ----------------- src/plugins/data/public/index.ts | 1 + src/plugins/data/public/public.api.md | 46 ++-- .../public/persisted_state/persisted_state.ts | 2 +- 92 files changed, 1245 insertions(+), 1495 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.enabled.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.id.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.params.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.schema.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.type.md rename src/legacy/core_plugins/visualizations/public/np_ready/public/expressions/{vis.js => vis.ts} (67%) delete mode 100644 src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/update_status.test.js delete mode 100644 src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/update_status.ts delete mode 100644 src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.d.ts delete mode 100644 src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.js diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.enabled.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.enabled.md new file mode 100644 index 0000000000000..2ef8c797f4054 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.enabled.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigOptions](./kibana-plugin-plugins-data-public.aggconfigoptions.md) > [enabled](./kibana-plugin-plugins-data-public.aggconfigoptions.enabled.md) + +## AggConfigOptions.enabled property + +Signature: + +```typescript +enabled?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.id.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.id.md new file mode 100644 index 0000000000000..8939854ab19ca --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigOptions](./kibana-plugin-plugins-data-public.aggconfigoptions.md) > [id](./kibana-plugin-plugins-data-public.aggconfigoptions.id.md) + +## AggConfigOptions.id property + +Signature: + +```typescript +id?: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.md new file mode 100644 index 0000000000000..b841d9b04d6a7 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigOptions](./kibana-plugin-plugins-data-public.aggconfigoptions.md) + +## AggConfigOptions interface + +Signature: + +```typescript +export interface AggConfigOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [enabled](./kibana-plugin-plugins-data-public.aggconfigoptions.enabled.md) | boolean | | +| [id](./kibana-plugin-plugins-data-public.aggconfigoptions.id.md) | string | | +| [params](./kibana-plugin-plugins-data-public.aggconfigoptions.params.md) | Record<string, any> | | +| [schema](./kibana-plugin-plugins-data-public.aggconfigoptions.schema.md) | string | | +| [type](./kibana-plugin-plugins-data-public.aggconfigoptions.type.md) | IAggType | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.params.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.params.md new file mode 100644 index 0000000000000..45219a837cc33 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.params.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigOptions](./kibana-plugin-plugins-data-public.aggconfigoptions.md) > [params](./kibana-plugin-plugins-data-public.aggconfigoptions.params.md) + +## AggConfigOptions.params property + +Signature: + +```typescript +params?: Record; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.schema.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.schema.md new file mode 100644 index 0000000000000..b2b42eb2e5b4d --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.schema.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigOptions](./kibana-plugin-plugins-data-public.aggconfigoptions.md) > [schema](./kibana-plugin-plugins-data-public.aggconfigoptions.schema.md) + +## AggConfigOptions.schema property + +Signature: + +```typescript +schema?: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.type.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.type.md new file mode 100644 index 0000000000000..866065ce52ba6 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigOptions](./kibana-plugin-plugins-data-public.aggconfigoptions.md) > [type](./kibana-plugin-plugins-data-public.aggconfigoptions.type.md) + +## AggConfigOptions.type property + +Signature: + +```typescript +type: IAggType; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index f8516ec476e88..ea77d6f39389b 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -48,6 +48,7 @@ | Interface | Description | | --- | --- | +| [AggConfigOptions](./kibana-plugin-plugins-data-public.aggconfigoptions.md) | | | [AggParamOption](./kibana-plugin-plugins-data-public.aggparamoption.md) | | | [DataPublicPluginSetup](./kibana-plugin-plugins-data-public.datapublicpluginsetup.md) | | | [DataPublicPluginStart](./kibana-plugin-plugins-data-public.datapublicpluginstart.md) | | diff --git a/src/legacy/core_plugins/input_control_vis/public/input_control_vis_type.ts b/src/legacy/core_plugins/input_control_vis/public/input_control_vis_type.ts index dae6c9abb625e..023e6ebb7125c 100644 --- a/src/legacy/core_plugins/input_control_vis/public/input_control_vis_type.ts +++ b/src/legacy/core_plugins/input_control_vis/public/input_control_vis_type.ts @@ -22,7 +22,6 @@ import { i18n } from '@kbn/i18n'; import { createInputControlVisController } from './vis_controller'; import { getControlsTab } from './components/editor/controls_tab'; import { OptionsTab } from './components/editor/options_tab'; -import { Status } from '../../visualizations/public'; import { InputControlVisDependencies } from './plugin'; import { defaultFeedbackMessage } from '../../../../plugins/kibana_utils/common'; @@ -40,7 +39,6 @@ export function createInputControlVisTypeDefinition(deps: InputControlVisDepende defaultMessage: 'Create interactive controls for easy dashboard manipulation.', }), stage: 'experimental', - requiresUpdateStatus: [Status.PARAMS, Status.TIME], feedbackMessage: defaultFeedbackMessage, visualization: InputControlVisController, visConfig: { diff --git a/src/legacy/core_plugins/input_control_vis/public/vis_controller.tsx b/src/legacy/core_plugins/input_control_vis/public/vis_controller.tsx index 624d000dd8d7a..c0ab235c1b9d1 100644 --- a/src/legacy/core_plugins/input_control_vis/public/vis_controller.tsx +++ b/src/legacy/core_plugins/input_control_vis/public/vis_controller.tsx @@ -54,7 +54,7 @@ export const createInputControlVisController = (deps: InputControlVisDependencie .subscribe(this.queryBarUpdateHandler); } - async render(visData: any, visParams: VisParams, status: any) { + async render(visData: any, visParams: VisParams) { this.visParams = visParams; this.controls = []; this.controls = await this.initControls(); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js index 278317ec2e87b..8e4e77b2d18a6 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js @@ -672,7 +672,7 @@ function discoverController( // no timefield, no vis, nothing to update if (!getTimeField() || !$scope.vis) return; - const buckets = $scope.vis.getAggConfig().byTypeName('buckets'); + const buckets = $scope.vis.data.aggs.byTypeName('buckets'); if (buckets && buckets.length === 1) { $scope.bucketInterval = buckets[0].buckets.getInterval(); @@ -876,11 +876,11 @@ function discoverController( inspectorRequest.stats(getResponseInspectorStats($scope.searchSource, resp)).ok({ json: resp }); if (getTimeField()) { - const tabifiedData = tabifyAggResponse($scope.vis.aggs, resp); + const tabifiedData = tabifyAggResponse($scope.vis.data.aggs, resp); $scope.searchSource.rawResponse = resp; $scope.histogramData = discoverResponseHandler( tabifiedData, - getDimensions($scope.vis.aggs.aggs, $scope.timeRange) + getDimensions($scope.vis.data.aggs.aggs, $scope.timeRange) ); } @@ -1023,41 +1023,27 @@ function discoverController( }, ]; - if ($scope.vis) { - const visState = $scope.vis.getEnabledState(); - visState.aggs = visStateAggs; - - $scope.vis.setState(visState); - return; - } - - const visSavedObject = { - indexPattern: $scope.indexPattern.id, - visState: { - type: 'histogram', - title: savedSearch.title, - params: { - addLegend: false, - addTimeMarker: true, - }, + $scope.vis = visualizations.createVis('histogram', { + title: savedSearch.title, + params: { + addLegend: false, + addTimeMarker: true, + }, + data: { aggs: visStateAggs, + indexPattern: $scope.searchSource.getField('index').id, + searchSource: $scope.searchSource, }, - }; - - $scope.vis = visualizations.createVis( - $scope.searchSource.getField('index'), - visSavedObject.visState - ); - visSavedObject.vis = $scope.vis; + }); $scope.searchSource.onRequestStart((searchSource, options) => { if (!$scope.vis) return; - return $scope.vis.getAggConfig().onSearchRequestStart(searchSource, options); + return $scope.vis.data.aggs.onSearchRequestStart(searchSource, options); }); $scope.searchSource.setField('aggs', function() { if (!$scope.vis) return; - return $scope.vis.getAggConfig().toDsl(); + return $scope.vis.data.aggs.toDsl(); }); } diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/breadcrumbs.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/breadcrumbs.ts index c334172805b9f..b6a63d50b205b 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/breadcrumbs.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/breadcrumbs.ts @@ -69,7 +69,7 @@ export function getEditBreadcrumbs($route: any) { return [ ...getLandingBreadcrumbs(), { - text: $route.current.locals.savedVis.title, + text: $route.current.locals.resolved.savedVis.title, }, ]; } diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html index 28baf21925cbe..0dcacd30fba4e 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html @@ -69,7 +69,8 @@ diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js index 7d1c29fbf48da..c5325ca3108b4 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js @@ -22,6 +22,7 @@ import _ from 'lodash'; import { Subscription } from 'rxjs'; import { map } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; +import { EventEmitter } from 'events'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -84,6 +85,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState uiSettings, I18nContext, setActiveUrl, + visualizations, } = getServices(); const { @@ -98,27 +100,63 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState ); // Retrieve the resolved SavedVis instance. - const savedVis = $route.current.locals.savedVis; + const { vis, savedVis, savedSearch, embeddableHandler } = $route.current.locals.resolved; + $scope.eventEmitter = new EventEmitter(); const _applyVis = () => { $scope.$apply(); }; // This will trigger a digest cycle. This is needed when vis is updated from a global angular like in visualize_embeddable.js. - savedVis.vis.on('apply', _applyVis); + $scope.eventEmitter.on('apply', _applyVis); // vis is instance of src/legacy/ui/public/vis/vis.js. // SearchSource is a promise-based stream of search results that can inherit from other search sources. - const { vis, searchSource, savedSearch } = savedVis; + const searchSource = vis.data.searchSource; $scope.vis = vis; + $scope.savedSearch = savedSearch; const $appStatus = { dirty: !savedVis.id, }; - vis.on('dirtyStateChange', ({ isDirty }) => { - vis.dirty = isDirty; - $scope.$digest(); + const defaultQuery = { + query: '', + language: + localStorage.get('kibana.userQueryLanguage') || uiSettings.get('search:queryLanguage'), + }; + + const visStateToEditorState = () => { + const savedVisState = visualizations.convertFromSerializedVis(vis.serialize()); + return { + uiState: vis.uiState.toJSON(), + query: vis.data.searchSource.getOwnField('query') || defaultQuery, + filters: vis.data.searchSource.getOwnField('filter') || [], + vis: { ...savedVisState.visState, title: vis.title }, + linked: !!savedVis.savedSearchId, + }; + }; + + const stateDefaults = visStateToEditorState(); + + const { stateContainer, stopStateSync } = useVisualizeAppState({ + stateDefaults, + kbnUrlStateStorage, + }); + + $scope.eventEmitter.on('dirtyStateChange', ({ isDirty }) => { + if (!isDirty) { + stateContainer.transitions.updateVisState(visStateToEditorState().vis); + } + $timeout(() => { + $scope.dirty = isDirty; + }); + }); + + $scope.eventEmitter.on('updateVis', () => { + embeddableHandler.reload(); }); + $scope.embeddableHandler = embeddableHandler; + $scope.topNavMenu = [ ...(visualizeCapabilities.save ? [ @@ -135,10 +173,10 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState ), testId: 'visualizeSaveButton', disableButton() { - return Boolean(vis.dirty); + return Boolean($scope.dirty); }, tooltip() { - if (vis.dirty) { + if ($scope.dirty) { return i18n.translate( 'kbn.visualize.topNavMenu.saveVisualizationDisabledButtonTooltip', { @@ -207,7 +245,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState }), testId: 'shareTopNavButton', run: anchorElement => { - const hasUnappliedChanges = vis.dirty; + const hasUnappliedChanges = $scope.dirty; const hasUnsavedChanges = $appStatus.dirty; share.toggleShareContextMenu({ anchorElement, @@ -233,17 +271,17 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState }), testId: 'openInspectorButton', disableButton() { - return !vis.hasInspector || !vis.hasInspector(); + return !embeddableHandler.hasInspector || !embeddableHandler.hasInspector(); }, run() { - const inspectorSession = vis.openInspector(); + const inspectorSession = embeddableHandler.openInspector(); // Close the inspector if this scope is destroyed (e.g. because the user navigates away). const removeWatch = $scope.$on('$destroy', () => inspectorSession.close()); // Remove that watch in case the user closes the inspector session herself. inspectorSession.onClose.finally(removeWatch); }, tooltip() { - if (!vis.hasInspector || !vis.hasInspector()) { + if (!embeddableHandler.hasInspector || !embeddableHandler.hasInspector()) { return i18n.translate('kbn.visualize.topNavMenu.openInspectorDisabledButtonTooltip', { defaultMessage: `This visualization doesn't support any inspectors.`, }); @@ -257,7 +295,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState defaultMessage: 'Refresh', }), run: function() { - vis.forceReload(); + embeddableHandler.reload(); }, testId: 'visualizeRefreshButton', }, @@ -267,28 +305,6 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState chrome.docTitle.change(savedVis.title); } - const defaultQuery = { - query: '', - language: - localStorage.get('kibana.userQueryLanguage') || uiSettings.get('search:queryLanguage'), - }; - - // Extract visualization state with filtered aggs. You can see these filtered aggs in the URL. - // Consists of things like aggs, params, listeners, title, type, etc. - const savedVisState = vis.getState(); - const stateDefaults = { - uiState: savedVis.uiStateJSON ? JSON.parse(savedVis.uiStateJSON) : {}, - query: searchSource.getOwnField('query') || defaultQuery, - filters: searchSource.getOwnField('filter') || [], - vis: savedVisState, - linked: !!savedVis.savedSearchId, - }; - - const { stateContainer, stopStateSync } = useVisualizeAppState({ - stateDefaults, - kbnUrlStateStorage, - }); - // sync initial app filters from state to filterManager filterManager.setAppFilters(_.cloneDeep(stateContainer.getState().filters)); // setup syncing of app filters between appState and filterManager @@ -315,7 +331,8 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState // appState then they won't be equal. if (!_.isEqual(stateContainer.getState().vis, stateDefaults.vis)) { try { - vis.setState(stateContainer.getState().vis); + const { aggs, ...visState } = stateContainer.getState().vis; + vis.setState({ ...visState, data: { aggs } }); } catch (error) { // stop syncing url updtes with the state to prevent extra syncing stopAllSyncing(); @@ -369,8 +386,8 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState }; function init() { - if (vis.indexPattern) { - $scope.indexPattern = vis.indexPattern; + if (vis.data.indexPattern) { + $scope.indexPattern = vis.data.indexPattern; } else { indexPatterns.getDefault().then(defaultIndexPattern => { $scope.indexPattern = defaultIndexPattern; @@ -379,22 +396,14 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState const initialState = stateContainer.getState(); - $scope.appState = { - // mock implementation of the legacy appState.save() - // this could be even replaced by passing only "updateAppState" callback - save() { - stateContainer.transitions.updateVisState(vis.getState()); - }, - }; - const handleLinkedSearch = linked => { if (linked && !savedVis.savedSearchId && savedSearch) { savedVis.savedSearchId = savedSearch.id; - vis.savedSearchId = savedSearch.id; + vis.data.savedSearchId = savedSearch.id; searchSource.setParent(savedSearch.searchSource); } else if (!linked && savedVis.savedSearchId) { delete savedVis.savedSearchId; - delete vis.savedSearchId; + delete vis.data.savedSearchId; } }; @@ -403,6 +412,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState 'uiState', stateContainer ); + vis.uiState = persistedState; $scope.uiState = persistedState; $scope.savedVis = savedVis; $scope.query = initialState.query; @@ -427,7 +437,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState $scope.showQueryBarTimePicker = () => { // tsvb loads without an indexPattern initially (TODO investigate). // hide timefilter only if timeFieldName is explicitly undefined. - const hasTimeField = vis.indexPattern ? !!vis.indexPattern.timeFieldName : true; + const hasTimeField = vis.data.indexPattern ? !!vis.data.indexPattern.timeFieldName : true; return vis.type.options.showTimePicker && hasTimeField; }; @@ -442,10 +452,24 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState updateSavedQueryFromUrl(state.savedQuery); // if the browser history was changed manually we need to reflect changes in the editor - if (!_.isEqual(vis.getState(), state.vis)) { - vis.setState(state.vis); - vis.forceReload(); - vis.emit('updateEditor'); + if ( + !_.isEqual( + { + ...visualizations.convertFromSerializedVis(vis.serialize()).visState, + title: vis.title, + }, + state.vis + ) + ) { + const { aggs, ...visState } = state.vis; + vis.setState({ + ...visState, + data: { + aggs, + }, + }); + embeddableHandler.reload(); + $scope.eventEmitter.emit('updateEditor'); } $appStatus.dirty = true; @@ -498,8 +522,8 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState const { query, linked, filters } = stateContainer.getState(); $scope.query = query; handleLinkedSearch(linked); - savedVis.searchSource.setField('query', query); - savedVis.searchSource.setField('filter', filters); + vis.data.searchSource.setField('query', query); + vis.data.searchSource.setField('filter', filters); $scope.$broadcast('render'); }; @@ -533,7 +557,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState } savedVis.destroy(); subscriptions.unsubscribe(); - $scope.vis.off('apply', _applyVis); + $scope.eventEmitter.off('apply', _applyVis); unsubscribePersisted(); unsubscribeStateUpdates(); @@ -556,7 +580,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState // If nothing has changed, trigger the fetch manually, otherwise it will happen as a result of the changes if (!isUpdate) { - $scope.vis.forceReload(); + embeddableHandler.reload(); } }; @@ -605,8 +629,10 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState title: savedVis.title, type: savedVis.type || stateContainer.getState().vis.type, }); + savedVis.searchSource.setField('query', stateContainer.getState().query); + savedVis.searchSource.setField('filter', stateContainer.getState().filters); savedVis.visState = stateContainer.getState().vis; - savedVis.uiStateJSON = angular.toJson($scope.uiState.getChanges()); + savedVis.uiStateJSON = angular.toJson($scope.uiState.toJSON()); $appStatus.dirty = false; return savedVis.save(saveOptions).then( @@ -720,7 +746,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState ); }; - vis.on('unlinkFromSavedSearch', unlinkFromSavedSearch); + $scope.eventEmitter.on('unlinkFromSavedSearch', unlinkFromSavedSearch); addHelpMenuToAppChrome(chrome, docLinks); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization.js index c8acea168444f..fbabd6fc87c98 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization.js @@ -17,12 +17,12 @@ * under the License. */ -export function initVisualizationDirective(app, deps) { +export function initVisualizationDirective(app) { app.directive('visualizationEmbedded', function($timeout) { return { restrict: 'E', scope: { - savedObj: '=', + embeddableHandler: '=', uiState: '=?', timeRange: '=', filters: '=', @@ -31,24 +31,16 @@ export function initVisualizationDirective(app, deps) { }, link: function($scope, element) { $scope.renderFunction = async () => { - if (!$scope._handler) { - $scope._handler = await deps.embeddable - .getEmbeddableFactory('visualization') - .createFromObject($scope.savedObj, { - timeRange: $scope.timeRange, - filters: $scope.filters || [], - query: $scope.query, - appState: $scope.appState, - uiState: $scope.uiState, - }); - $scope._handler.render(element[0]); - } else { - $scope._handler.updateInput({ - timeRange: $scope.timeRange, - filters: $scope.filters || [], - query: $scope.query, - }); + if (!$scope.rendered) { + $scope.embeddableHandler.render(element[0]); + $scope.rendered = true; } + + $scope.embeddableHandler.updateInput({ + timeRange: $scope.timeRange, + filters: $scope.filters || [], + query: $scope.query, + }); }; $scope.$on('render', event => { @@ -59,8 +51,8 @@ export function initVisualizationDirective(app, deps) { }); $scope.$on('$destroy', () => { - if ($scope._handler) { - $scope._handler.destroy(); + if ($scope.embeddableHandler) { + $scope.embeddableHandler.destroy(); } }); }, diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js index f2d9cbe2ad84c..ef174dbaa5865 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js @@ -22,16 +22,23 @@ export function initVisEditorDirective(app, deps) { return { restrict: 'E', scope: { - savedObj: '=', + vis: '=', uiState: '=?', timeRange: '=', filters: '=', query: '=', - appState: '=', + savedSearch: '=', + embeddableHandler: '=', + eventEmitter: '=', }, link: function($scope, element) { - const Editor = $scope.savedObj.vis.type.editor || deps.DefaultVisualizationEditor; - const editor = new Editor(element[0], $scope.savedObj); + const Editor = $scope.vis.type.editor || deps.DefaultVisualizationEditor; + const editor = new Editor( + element[0], + $scope.vis, + $scope.eventEmitter, + $scope.embeddableHandler + ); $scope.renderFunction = () => { editor.render({ @@ -42,8 +49,8 @@ export function initVisEditorDirective(app, deps) { timeRange: $scope.timeRange, filters: $scope.filters, query: $scope.query, - appState: $scope.appState, - linked: !!$scope.savedObj.savedSearchId, + linked: !!$scope.vis.data.savedSearchId, + savedSearch: $scope.savedSearch, }); }; diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/legacy_app.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/legacy_app.js index 0f1d50b149cd9..b0b1ae31a02a5 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/legacy_app.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/legacy_app.js @@ -40,6 +40,50 @@ import { getCreateBreadcrumbs, getEditBreadcrumbs, } from './breadcrumbs'; +import { createSavedSearchesLoader } from '../../../../../../plugins/discover/public'; + +const getResolvedResults = deps => { + const { core, data, visualizations } = deps; + + const results = {}; + + return savedVis => { + results.savedVis = savedVis; + return visualizations + .convertToSerializedVis(savedVis) + .then(serializedVis => visualizations.createVis(serializedVis.type, serializedVis)) + .then(vis => { + if (vis.type.setup) { + return vis.type.setup(vis).catch(() => vis); + } + return vis; + }) + .then(vis => { + results.vis = vis; + return deps.embeddable.getEmbeddableFactory('visualization').createFromObject(results.vis, { + timeRange: data.query.timefilter.timefilter.getTime(), + filters: data.query.filterManager.getFilters(), + }); + }) + .then(embeddableHandler => { + results.embeddableHandler = embeddableHandler; + if (results.vis.data.savedSearchId) { + return createSavedSearchesLoader({ + savedObjectsClient: core.savedObjects.client, + indexPatterns: data.indexPatterns, + chrome: core.chrome, + overlays: core.overlays, + }).get(results.vis.data.savedSearchId); + } + }) + .then(savedSearch => { + if (savedSearch) { + results.savedSearch = savedSearch; + } + return results; + }); + }; +}; export function initVisualizeApp(app, deps) { initVisualizeAppDirective(app, deps); @@ -101,7 +145,7 @@ export function initVisualizeApp(app, deps) { template: editorTemplate, k7Breadcrumbs: getCreateBreadcrumbs, resolve: { - savedVis: function($route, history) { + resolved: function($route, history) { const { core, data, savedVisualizations, visualizations, toastNotifications } = deps; const visTypes = visualizations.all(); const visType = find(visTypes, { name: $route.current.params.type }); @@ -121,12 +165,7 @@ export function initVisualizeApp(app, deps) { return ensureDefaultIndexPattern(core, data, history) .then(() => savedVisualizations.get($route.current.params)) - .then(savedVis => { - if (savedVis.vis.type.setup) { - return savedVis.vis.type.setup(savedVis).catch(() => savedVis); - } - return savedVis; - }) + .then(getResolvedResults(deps)) .catch( redirectWhenMissing({ history, @@ -142,20 +181,16 @@ export function initVisualizeApp(app, deps) { template: editorTemplate, k7Breadcrumbs: getEditBreadcrumbs, resolve: { - savedVis: function($route, history) { + resolved: function($route, history) { const { chrome, core, data, savedVisualizations, toastNotifications } = deps; + return ensureDefaultIndexPattern(core, data, history) .then(() => savedVisualizations.get($route.current.params.id)) .then(savedVis => { chrome.recentlyAccessed.add(savedVis.getFullPath(), savedVis.title, savedVis.id); return savedVis; }) - .then(savedVis => { - if (savedVis.vis.type.setup) { - return savedVis.vis.type.setup(savedVis).catch(() => savedVis); - } - return savedVis; - }) + .then(getResolvedResults(deps)) .catch( redirectWhenMissing({ history, diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts index 01ce872aeb679..246a031f05769 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts @@ -29,8 +29,10 @@ import { PersistedState } from 'src/plugins/visualizations/public'; import { LegacyCoreStart } from 'kibana/public'; import { Vis } from 'src/legacy/core_plugins/visualizations/public'; import { VisSavedObject } from '../legacy_imports'; +import { SavedVisState } from '../../../../visualizations/public/np_ready/public/types'; +import { SavedSearch } from '../../../../../../plugins/discover/public'; -export type PureVisState = ReturnType; +export type PureVisState = SavedVisState; export interface VisualizeAppState { filters: Filter[]; @@ -58,14 +60,13 @@ export interface VisualizeAppStateTransitions { } export interface EditorRenderProps { - appState: { save(): void }; core: LegacyCoreStart; data: DataPublicPluginStart; - embeddable: EmbeddableStart; filters: Filter[]; - uiState: PersistedState; timeRange: TimeRange; query?: Query; + savedSearch?: SavedSearch; + uiState: PersistedState; /** * Flag to determine if visualiztion is linked to the saved search */ diff --git a/src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js b/src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js index 6bdb5d00e67d8..23ca99791e92e 100644 --- a/src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js +++ b/src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js @@ -21,7 +21,6 @@ import expect from '@kbn/expect'; import ngMock from 'ng_mock'; import _ from 'lodash'; import ChoroplethLayer from '../choropleth_layer'; -import LogstashIndexPatternStubProvider from 'fixtures/stubbed_logstash_index_pattern'; import { ImageComparator } from 'test_utils/image_comparator'; import worldJson from './world.json'; import EMS_CATALOGUE from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_manifest.json'; @@ -38,13 +37,11 @@ import afterdatachangePng from './afterdatachange.png'; import afterdatachangeandresizePng from './afterdatachangeandresize.png'; import aftercolorchangePng from './aftercolorchange.png'; import changestartupPng from './changestartup.png'; -import { - setup as visualizationsSetup, - start as visualizationsStart, -} from '../../../visualizations/public/np_ready/public/legacy'; +import { setup as visualizationsSetup } from '../../../visualizations/public/np_ready/public/legacy'; import { createRegionMapVisualization } from '../region_map_visualization'; import { createRegionMapTypeDefinition } from '../region_map_type'; +import { ExprVis } from '../../../visualizations/public/np_ready/public/expressions/vis'; const THRESHOLD = 0.45; const PIXEL_DIFF = 96; @@ -52,7 +49,6 @@ const PIXEL_DIFF = 96; describe('RegionMapsVisualizationTests', function() { let domNode; let RegionMapsVisualization; - let indexPattern; let vis; let dependencies; @@ -115,7 +111,6 @@ describe('RegionMapsVisualizationTests', function() { } RegionMapsVisualization = createRegionMapVisualization(dependencies); - indexPattern = Private(LogstashIndexPatternStubProvider); ChoroplethLayer.prototype._makeJsonAjaxCall = async function() { //simulate network call @@ -158,7 +153,7 @@ describe('RegionMapsVisualizationTests', function() { imageComparator = new ImageComparator(); - vis = visualizationsStart.createVis(indexPattern, { + vis = new ExprVis({ type: 'region_map', }); diff --git a/src/legacy/core_plugins/region_map/public/region_map_type.js b/src/legacy/core_plugins/region_map/public/region_map_type.js index 9a1a76362e094..4faa3f92abb5a 100644 --- a/src/legacy/core_plugins/region_map/public/region_map_type.js +++ b/src/legacy/core_plugins/region_map/public/region_map_type.js @@ -20,7 +20,6 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { mapToLayerWithId } from './util'; import { createRegionMapVisualization } from './region_map_visualization'; -import { Status } from '../../visualizations/public'; import { RegionMapOptions } from './components/region_map_options'; import { truncatedColorSchemas } from '../../../../plugins/charts/public'; import { Schemas } from '../../vis_default_editor/public'; @@ -55,7 +54,6 @@ provided base maps, or add your own. Darker colors represent higher values.', showAllShapes: true, //still under consideration }, }, - requiresUpdateStatus: [Status.AGGS, Status.PARAMS, Status.RESIZE, Status.DATA, Status.UI_STATE], visualization, editorConfig: { optionsTemplate: props => , @@ -100,9 +98,7 @@ provided base maps, or add your own. Darker colors represent higher values.', }, ]), }, - setup: async savedVis => { - const vis = savedVis.vis; - + setup: async vis => { const tmsLayers = await serviceSettings.getTMSServices(); vis.type.editorConfig.collections.tmsLayers = tmsLayers; if (!vis.params.wms.selectedTmsLayer && tmsLayers.length) { @@ -146,7 +142,7 @@ provided base maps, or add your own. Darker colors represent higher values.', vis.params.selectedJoinField = selectedJoinField; } - return savedVis; + return vis; }, }; } diff --git a/src/legacy/core_plugins/region_map/public/region_map_visualization.js b/src/legacy/core_plugins/region_map/public/region_map_visualization.js index 8b5812052a395..25641ea76809d 100644 --- a/src/legacy/core_plugins/region_map/public/region_map_visualization.js +++ b/src/legacy/core_plugins/region_map/public/region_map_visualization.js @@ -39,8 +39,8 @@ export function createRegionMapVisualization({ serviceSettings, $injector, uiSet this._choroplethLayer = null; } - async render(esResponse, visParams, status) { - await super.render(esResponse, visParams, status); + async render(esResponse, visParams) { + await super.render(esResponse, visParams); if (this._choroplethLayer) { await this._choroplethLayer.whenDataLoaded(); } diff --git a/src/legacy/core_plugins/tile_map/public/__tests__/coordinate_maps_visualization.js b/src/legacy/core_plugins/tile_map/public/__tests__/coordinate_maps_visualization.js index 6a08405b5b6a5..3b8a7dfbed313 100644 --- a/src/legacy/core_plugins/tile_map/public/__tests__/coordinate_maps_visualization.js +++ b/src/legacy/core_plugins/tile_map/public/__tests__/coordinate_maps_visualization.js @@ -19,7 +19,6 @@ import expect from '@kbn/expect'; import ngMock from 'ng_mock'; -import LogstashIndexPatternStubProvider from 'fixtures/stubbed_logstash_index_pattern'; import { ImageComparator } from 'test_utils/image_comparator'; import dummyESResponse from './dummy_es_response.json'; import initial from './initial.png'; @@ -32,13 +31,11 @@ import EMS_TILES from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_ import EMS_STYLE_ROAD_MAP_BRIGHT from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_style_bright'; import EMS_STYLE_ROAD_MAP_DESATURATED from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_style_desaturated'; import EMS_STYLE_DARK_MAP from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_style_dark'; -import { - setup as visualizationsSetup, - start as visualizationsStart, -} from '../../../visualizations/public/np_ready/public/legacy'; +import { setup as visualizationsSetup } from '../../../visualizations/public/np_ready/public/legacy'; import { createTileMapVisualization } from '../tile_map_visualization'; import { createTileMapTypeDefinition } from '../tile_map_type'; +import { ExprVis } from '../../../visualizations/public/np_ready/public/expressions/vis'; function mockRawData() { const stack = [dummyESResponse]; @@ -67,7 +64,6 @@ let visRegComplete = false; describe('CoordinateMapsVisualizationTest', function() { let domNode; let CoordinateMapsVisualization; - let indexPattern; let vis; let dependencies; @@ -92,7 +88,6 @@ describe('CoordinateMapsVisualizationTest', function() { } CoordinateMapsVisualization = createTileMapVisualization(dependencies); - indexPattern = Private(LogstashIndexPatternStubProvider); getManifestStub = serviceSettings.__debugStubManifestCalls(async url => { //simulate network calls @@ -124,7 +119,7 @@ describe('CoordinateMapsVisualizationTest', function() { setupDOM('512px', '512px'); imageComparator = new ImageComparator(); - vis = visualizationsStart.createVis(indexPattern, { + vis = new ExprVis({ type: 'tile_map', }); vis.params = { diff --git a/src/legacy/core_plugins/tile_map/public/base_maps_visualization.js b/src/legacy/core_plugins/tile_map/public/base_maps_visualization.js index ebb0c24243263..d38159c91ef9f 100644 --- a/src/legacy/core_plugins/tile_map/public/base_maps_visualization.js +++ b/src/legacy/core_plugins/tile_map/public/base_maps_visualization.js @@ -63,28 +63,21 @@ export function BaseMapsVisualizationProvider(serviceSettings) { * @param status * @return {Promise} */ - async render(esResponse, visParams, status) { + async render(esResponse, visParams) { if (!this._kibanaMap) { //the visualization has been destroyed; return; } await this._mapIsLoaded; - - if (status.resize) { - this._kibanaMap.resize(); - } - if (status.params || status.aggs) { - this._params = visParams; - await this._updateParams(); - } + this._kibanaMap.resize(); + this._params = visParams; + await this._updateParams(); if (this._hasESResponseChanged(esResponse)) { await this._updateData(esResponse); } - if (status.uiState) { - this._kibanaMap.useUiStateFromVisualization(this.vis); - } + this._kibanaMap.useUiStateFromVisualization(this.vis); await this._whenBaseLayerIsLoaded(); } diff --git a/src/legacy/core_plugins/tile_map/public/tile_map_type.js b/src/legacy/core_plugins/tile_map/public/tile_map_type.js index 0809bf6ecbab6..39d39a4c8f8fc 100644 --- a/src/legacy/core_plugins/tile_map/public/tile_map_type.js +++ b/src/legacy/core_plugins/tile_map/public/tile_map_type.js @@ -23,7 +23,6 @@ import { i18n } from '@kbn/i18n'; import { convertToGeoJson } from 'ui/vis/map/convert_to_geojson'; import { Schemas } from '../../vis_default_editor/public'; -import { Status } from '../../visualizations/public'; import { createTileMapVisualization } from './tile_map_visualization'; import { TileMapOptions } from './components/tile_map_options'; import { MapTypes } from './map_types'; @@ -57,7 +56,6 @@ export function createTileMapTypeDefinition(dependencies) { wms: uiSettings.get('visualization:tileMap:WMSdefaults'), }, }, - requiresUpdateStatus: [Status.AGGS, Status.PARAMS, Status.RESIZE, Status.UI_STATE], requiresPartialRows: true, visualization: CoordinateMapsVisualization, responseHandler: convertToGeoJson, @@ -143,21 +141,20 @@ export function createTileMapTypeDefinition(dependencies) { }, ]), }, - setup: async savedVis => { - const vis = savedVis.vis; + setup: async vis => { let tmsLayers; try { tmsLayers = await serviceSettings.getTMSServices(); } catch (e) { - return savedVis; + return vis; } vis.type.editorConfig.collections.tmsLayers = tmsLayers; if (!vis.params.wms.selectedTmsLayer && tmsLayers.length) { vis.params.wms.selectedTmsLayer = tmsLayers[0]; } - return savedVis; + return vis; }, }; } diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg.test.tsx index 7e715be25bff3..feb5b3caa023b 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg.test.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg.test.tsx @@ -22,12 +22,12 @@ import { mount, shallow } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { IndexPattern, IAggType, AggGroupNames } from 'src/plugins/data/public'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { DefaultEditorAgg, DefaultEditorAggProps } from './agg'; import { DefaultEditorAggParams } from './agg_params'; import { AGGS_ACTION_KEYS } from './agg_group_state'; import { Schema } from '../schemas'; +import { EditorVisState } from './sidebar/state/reducers'; jest.mock('./agg_params', () => ({ DefaultEditorAggParams: () => null, @@ -67,7 +67,7 @@ describe('DefaultEditorAgg component', () => { isLastBucket: false, isRemovable: false, metricAggs: [], - state: { params: {} } as VisState, + state: { params: {} } as EditorVisState, setAggParamValue, setStateParamValue, onAggTypeChange: () => {}, diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_common_props.ts b/src/legacy/core_plugins/vis_default_editor/public/components/agg_common_props.ts index ec92f511b6eee..3aae10879138a 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_common_props.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_common_props.ts @@ -17,9 +17,10 @@ * under the License. */ -import { VisState, VisParams } from 'src/legacy/core_plugins/visualizations/public'; +import { VisParams } from 'src/legacy/core_plugins/visualizations/public'; import { IAggType, IAggConfig, IAggGroupNames } from 'src/plugins/data/public'; import { Schema } from '../schemas'; +import { EditorVisState } from './sidebar/state/reducers'; type AggId = IAggConfig['id']; type AggParams = IAggConfig['params']; @@ -31,7 +32,7 @@ export interface DefaultEditorCommonProps { formIsTouched: boolean; groupName: IAggGroupNames; metricAggs: IAggConfig[]; - state: VisState; + state: EditorVisState; setAggParamValue: ( aggId: AggId, paramName: T, diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.test.tsx index 63f5e696c99f4..5d02f0a2c759e 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.test.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.test.tsx @@ -20,12 +20,12 @@ import React from 'react'; import { mount, shallow } from 'enzyme'; import { act } from 'react-dom/test-utils'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { IAggConfigs, IAggConfig } from 'src/plugins/data/public'; import { DefaultEditorAggGroup, DefaultEditorAggGroupProps } from './agg_group'; import { DefaultEditorAgg } from './agg'; import { DefaultEditorAggAdd } from './agg_add'; import { Schema } from '../schemas'; +import { EditorVisState } from './sidebar/state/reducers'; jest.mock('@elastic/eui', () => ({ EuiTitle: 'eui-title', @@ -93,8 +93,8 @@ describe('DefaultEditorAgg component', () => { metricAggs: [], groupName: 'metrics', state: { - aggs, - } as VisState, + data: { aggs }, + } as EditorVisState, schemas: [ { name: 'metrics', @@ -147,8 +147,8 @@ describe('DefaultEditorAgg component', () => { }); expect(reorderAggs).toHaveBeenCalledWith( - defaultProps.state.aggs.aggs[0], - defaultProps.state.aggs.aggs[1] + defaultProps.state.data.aggs!.aggs[0], + defaultProps.state.data.aggs!.aggs[1] ); }); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.tsx index 600612f2cf9d8..08b69ef37f528 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.tsx @@ -73,9 +73,10 @@ function DefaultEditorAggGroup({ const schemaNames = getSchemasByGroup(schemas, groupName).map(s => s.name); const group: IAggConfig[] = useMemo( () => - state.aggs.aggs.filter((agg: IAggConfig) => agg.schema && schemaNames.includes(agg.schema)) || - [], - [state.aggs.aggs, schemaNames] + state.data.aggs!.aggs.filter( + (agg: IAggConfig) => agg.schema && schemaNames.includes(agg.schema) + ) || [], + [state.data.aggs, schemaNames] ); const stats = { diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_param_props.ts b/src/legacy/core_plugins/vis_default_editor/public/components/agg_param_props.ts index 7c2852798b403..aec332e8674d7 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_param_props.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_param_props.ts @@ -18,10 +18,10 @@ */ import { IAggConfig, AggParam, IndexPatternField } from 'src/plugins/data/public'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { ComboBoxGroupedOptions } from '../utils'; import { EditorConfig } from './utils'; import { Schema } from '../schemas'; +import { EditorVisState } from './sidebar/state/reducers'; // NOTE: we cannot export the interface with export { InterfaceName } // as there is currently a bug on babel typescript transform plugin for it @@ -35,7 +35,7 @@ export interface AggParamCommonProps { formIsTouched: boolean; indexedFields?: ComboBoxGroupedOptions; showValidation: boolean; - state: VisState; + state: EditorVisState; value?: T; metricAggs: IAggConfig[]; schemas: Schema[]; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params.test.tsx index cd6486b6a1532..1c49ebf40640e 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params.test.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params.test.tsx @@ -20,7 +20,6 @@ import React from 'react'; import { mount } from 'enzyme'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { IndexPattern, IAggConfig, AggGroupNames } from 'src/plugins/data/public'; import { DefaultEditorAggParams as PureDefaultEditorAggParams, @@ -28,6 +27,7 @@ import { } from './agg_params'; import { KibanaContextProvider } from '../../../../../plugins/kibana_react/public'; import { dataPluginMock } from '../../../../../plugins/data/public/mocks'; +import { EditorVisState } from './sidebar/state/reducers'; const mockEditorConfig = { useNormalizedEsInterval: { hidden: false, fixedValue: false }, @@ -108,7 +108,7 @@ describe('DefaultEditorAggParams component', () => { formIsTouched: false, indexPattern: {} as IndexPattern, metricAggs: [], - state: {} as VisState, + state: {} as EditorVisState, setAggParamValue, onAggTypeChange, setTouched, diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.test.ts b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.test.ts index f2ebbdc87a60a..bed2561341737 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.test.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.test.ts @@ -25,7 +25,6 @@ import { IndexPattern, IndexPatternField, } from 'src/plugins/data/public'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { getAggParamsToRender, getAggTypeOptions, @@ -34,6 +33,7 @@ import { import { FieldParamEditor, OrderByParamEditor } from './controls'; import { EditorConfig } from './utils'; import { Schema } from '../schemas'; +import { EditorVisState } from './sidebar/state/reducers'; jest.mock('../utils', () => ({ groupAndSortBy: jest.fn(() => ['indexedFields']), @@ -58,7 +58,7 @@ describe('DefaultEditorAggParams helpers', () => { hideCustomLabel: true, } as Schema, ]; - const state = {} as VisState; + const state = {} as EditorVisState; const metricAggs: IAggConfig[] = []; const emptyParams = { basic: [], diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.ts b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.ts index e07bf81697579..10590e1a59f4a 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.ts @@ -28,7 +28,6 @@ import { IndexPattern, IndexPatternField, } from 'src/plugins/data/public'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { groupAndSortBy, ComboBoxGroupedOptions } from '../utils'; import { AggTypeState, AggParamsState } from './agg_params_state'; import { AggParamEditorProps } from './agg_param_props'; @@ -36,12 +35,13 @@ import { aggParamsMap } from './agg_params_map'; import { EditorConfig } from './utils'; import { Schema, getSchemaByName } from '../schemas'; import { search } from '../../../../../plugins/data/public'; +import { EditorVisState } from './sidebar/state/reducers'; interface ParamInstanceBase { agg: IAggConfig; editorConfig: EditorConfig; metricAggs: IAggConfig[]; - state: VisState; + state: EditorVisState; schemas: Schema[]; hideCustomLabel?: boolean; } diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.test.tsx index 1043431475494..b33149dc51a19 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.test.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.test.tsx @@ -23,9 +23,9 @@ import { mount, shallow, ReactWrapper } from 'enzyme'; import { EuiComboBoxProps, EuiComboBox } from '@elastic/eui'; import { IAggConfig, IndexPatternField } from 'src/plugins/data/public'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { ComboBoxGroupedOptions } from '../../utils'; import { FieldParamEditor, FieldParamEditorProps } from './field'; +import { EditorVisState } from '../sidebar/state/reducers'; function callComboBoxOnChange(comp: ReactWrapper, value: any = []) { const comboBoxProps = comp.find(EuiComboBox).props() as EuiComboBoxProps; @@ -78,7 +78,7 @@ describe('FieldParamEditor component', () => { setValue, setValidity, setTouched, - state: {} as VisState, + state: {} as EditorVisState, metricAggs: [] as IAggConfig[], schemas: [], }; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.test.tsx index 76eb12af8c4e2..82166440cf8e8 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.test.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.test.tsx @@ -20,9 +20,9 @@ import React from 'react'; import { AggParamEditorProps } from '../agg_param_props'; import { IAggConfig } from 'src/plugins/data/public'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { mount } from 'enzyme'; import { PercentilesEditor } from './percentiles'; +import { EditorVisState } from '../sidebar/state/reducers'; describe('PercentilesEditor component', () => { let setValue: jest.Mock; @@ -45,7 +45,7 @@ describe('PercentilesEditor component', () => { setValue, setValidity, setTouched, - state: {} as VisState, + state: {} as EditorVisState, metricAggs: [] as IAggConfig[], schemas: [], }; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/test_utils.ts b/src/legacy/core_plugins/vis_default_editor/public/components/controls/test_utils.ts index b816e61cce355..7c7431015d175 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/test_utils.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/test_utils.ts @@ -17,9 +17,9 @@ * under the License. */ -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { IAggConfig, AggParam } from 'src/plugins/data/public'; import { EditorConfig } from '../utils'; +import { EditorVisState } from '../sidebar/state/reducers'; export const aggParamCommonPropsMock = { agg: {} as IAggConfig, @@ -27,7 +27,7 @@ export const aggParamCommonPropsMock = { editorConfig: {} as EditorConfig, formIsTouched: false, metricAggs: [] as IAggConfig[], - state: {} as VisState, + state: {} as EditorVisState, showValidation: false, schemas: [], }; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/data_tab.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/data_tab.tsx index 6f92c27e90ec1..a6a1980210be4 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/data_tab.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/data_tab.tsx @@ -21,7 +21,6 @@ import React, { useMemo, useCallback } from 'react'; import { findLast } from 'lodash'; import { EuiSpacer } from '@elastic/eui'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { AggGroupNames, IAggConfig, @@ -40,6 +39,7 @@ import { } from './state'; import { AddSchema, ReorderAggs, DefaultEditorAggCommonProps } from '../agg_common_props'; import { ISchemas } from '../../schemas'; +import { EditorVisState } from './state/reducers'; export interface DefaultEditorDataTabProps { dispatch: React.Dispatch; @@ -47,7 +47,7 @@ export interface DefaultEditorDataTabProps { isTabSelected: boolean; metricAggs: IAggConfig[]; schemas: ISchemas; - state: VisState; + state: EditorVisState; setTouched(isTouched: boolean): void; setValidity(modelName: string, value: boolean): void; setStateValue: DefaultEditorAggCommonProps['setStateParamValue']; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx index 2508ef3a55537..04c931f593e5a 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx @@ -21,6 +21,7 @@ import React, { useMemo, useState, useCallback, KeyboardEventHandler, useEffect import { get, isEqual } from 'lodash'; import { i18n } from '@kbn/i18n'; import { keyCodes, EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EventEmitter } from 'events'; import { Vis } from 'src/legacy/core_plugins/visualizations/public'; import { DefaultEditorNavBar, OptionTab } from './navbar'; @@ -40,6 +41,7 @@ interface DefaultEditorSideBarProps { uiState: PersistedState; vis: Vis; isLinkedSearch: boolean; + eventEmitter: EventEmitter; savedSearch?: SavedSearch; } @@ -50,14 +52,17 @@ function DefaultEditorSideBar({ uiState, vis, isLinkedSearch, + eventEmitter, savedSearch, }: DefaultEditorSideBarProps) { const [selectedTab, setSelectedTab] = useState(optionTabs[0].name); const [isDirty, setDirty] = useState(false); - const [state, dispatch] = useEditorReducer(vis); + const [state, dispatch] = useEditorReducer(vis, eventEmitter); const { formState, setTouched, setValidity, resetValidity } = useEditorFormState(); - const responseAggs = useMemo(() => state.aggs.getResponseAggs(), [state.aggs]); + const responseAggs = useMemo(() => (state.data.aggs ? state.data.aggs.getResponseAggs() : []), [ + state.data.aggs, + ]); const metricSchemas = getSchemasByGroup(vis.type.schemas.all || [], AggGroupNames.Metrics).map( s => s.name ); @@ -90,17 +95,20 @@ function DefaultEditorSideBar({ const applyChanges = useCallback(() => { if (formState.invalid || !isDirty) { setTouched(true); - return; } - vis.setCurrentState(state); - vis.updateState(); - vis.emit('dirtyStateChange', { + vis.setState({ + ...vis.serialize(), + params: state.params, + data: { aggs: state.data.aggs ? (state.data.aggs.aggs.map(agg => agg.toJSON()) as any) : [] }, + }); + eventEmitter.emit('updateVis'); + eventEmitter.emit('dirtyStateChange', { isDirty: false, }); setTouched(false); - }, [vis, state, formState.invalid, setTouched, isDirty]); + }, [vis, state, formState.invalid, setTouched, isDirty, eventEmitter]); const onSubmit: KeyboardEventHandler = useCallback( event => { @@ -122,18 +130,22 @@ function DefaultEditorSideBar({ resetValidity(); } }; - vis.on('dirtyStateChange', changeHandler); + eventEmitter.on('dirtyStateChange', changeHandler); - return () => vis.off('dirtyStateChange', changeHandler); - }, [resetValidity, vis]); + return () => { + eventEmitter.off('dirtyStateChange', changeHandler); + }; + }, [resetValidity, eventEmitter]); // subscribe on external vis changes using browser history, for example press back button useEffect(() => { const resetHandler = () => dispatch(discardChanges(vis)); - vis.on('updateEditor', resetHandler); + eventEmitter.on('updateEditor', resetHandler); - return () => vis.off('updateEditor', resetHandler); - }, [dispatch, vis]); + return () => { + eventEmitter.off('updateEditor', resetHandler); + }; + }, [dispatch, vis, eventEmitter]); const dataTabProps = { dispatch, @@ -147,7 +159,7 @@ function DefaultEditorSideBar({ }; const optionTabProps = { - aggs: state.aggs, + aggs: state.data.aggs!, hasHistogramAgg, stateParams: state.params, vis, @@ -173,7 +185,12 @@ function DefaultEditorSideBar({ onKeyDownCapture={onSubmit} > {vis.type.requiresSearch && ( - + )} {optionTabs.length > 1 && ( diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx index 876404851aed4..575ad5ae2a95c 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx @@ -18,6 +18,7 @@ */ import React, { useCallback, useState } from 'react'; +import { EventEmitter } from 'events'; import { EuiButton, EuiButtonEmpty, @@ -39,23 +40,24 @@ import { SavedSearch } from '../../../../../../plugins/discover/public'; interface LinkedSearchProps { savedSearch: SavedSearch; - vis: Vis; + eventEmitter: EventEmitter; } interface SidebarTitleProps { isLinkedSearch: boolean; savedSearch?: SavedSearch; vis: Vis; + eventEmitter: EventEmitter; } -export function LinkedSearch({ savedSearch, vis }: LinkedSearchProps) { +export function LinkedSearch({ savedSearch, eventEmitter }: LinkedSearchProps) { const [showPopover, setShowPopover] = useState(false); const closePopover = useCallback(() => setShowPopover(false), []); const onClickButtonLink = useCallback(() => setShowPopover(v => !v), []); const onClickUnlikFromSavedSearch = useCallback(() => { setShowPopover(false); - vis.emit('unlinkFromSavedSearch'); - }, [vis]); + eventEmitter.emit('unlinkFromSavedSearch'); + }, [eventEmitter]); const linkButtonAriaLabel = i18n.translate( 'visDefaultEditor.sidebar.savedSearch.linkButtonAriaLabel', @@ -151,20 +153,20 @@ export function LinkedSearch({ savedSearch, vis }: LinkedSearchProps) { ); } -function SidebarTitle({ savedSearch, vis, isLinkedSearch }: SidebarTitleProps) { +function SidebarTitle({ savedSearch, vis, isLinkedSearch, eventEmitter }: SidebarTitleProps) { return isLinkedSearch && savedSearch ? ( - + ) : vis.type.options.showIndexSelection ? (

- {vis.indexPattern.title} + {vis.data.indexPattern!.title}

) : ( diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/index.ts b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/index.ts index 6383ac866dcfc..11cbc3f93e9d3 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/index.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/index.ts @@ -17,20 +17,23 @@ * under the License. */ -import { useEffect, useReducer, useCallback } from 'react'; -import { isEqual } from 'lodash'; +import { useReducer, useCallback } from 'react'; +import { EventEmitter } from 'events'; -import { Vis, VisState, VisParams } from 'src/legacy/core_plugins/visualizations/public'; -import { createEditorStateReducer, initEditorState } from './reducers'; +import { Vis } from 'src/legacy/core_plugins/visualizations/public'; +import { createEditorStateReducer, initEditorState, EditorVisState } from './reducers'; import { EditorStateActionTypes } from './constants'; -import { EditorAction, updateStateParams } from './actions'; +import { EditorAction } from './actions'; import { useKibana } from '../../../../../../../plugins/kibana_react/public'; import { VisDefaultEditorKibanaServices } from '../../../types'; export * from './editor_form_state'; export * from './actions'; -export function useEditorReducer(vis: Vis): [VisState, React.Dispatch] { +export function useEditorReducer( + vis: Vis, + eventEmitter: EventEmitter +): [EditorVisState, React.Dispatch] { const { services } = useKibana(); const [state, dispatch] = useReducer( createEditorStateReducer(services.data.search), @@ -38,28 +41,15 @@ export function useEditorReducer(vis: Vis): [VisState, React.Dispatch { - const handleVisUpdate = (params: VisParams) => { - if (!isEqual(params, state.params)) { - dispatch(updateStateParams(params)); - } - }; - - // fires when visualization state changes, and we need to copy changes to editorState - vis.on('updateEditorStateParams', handleVisUpdate); - - return () => vis.off('updateEditorStateParams', handleVisUpdate); - }, [vis, state.params]); - const wrappedDispatch = useCallback( (action: EditorAction) => { dispatch(action); - vis.emit('dirtyStateChange', { + eventEmitter.emit('dirtyStateChange', { isDirty: action.type !== EditorStateActionTypes.DISCARD_CHANGES, }); }, - [vis] + [eventEmitter] ); return [state, wrappedDispatch]; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/reducers.ts b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/reducers.ts index 67220fd9fd91b..6e5bec7c69c90 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/reducers.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/reducers.ts @@ -19,35 +19,45 @@ import { cloneDeep } from 'lodash'; -import { Vis, VisState } from 'src/legacy/core_plugins/visualizations/public'; +import { Vis } from 'src/legacy/core_plugins/visualizations/public'; import { AggGroupNames, DataPublicPluginStart } from '../../../../../../../plugins/data/public'; import { EditorStateActionTypes } from './constants'; import { getEnabledMetricAggsCount } from '../../agg_group_helper'; import { EditorAction } from './actions'; function initEditorState(vis: Vis) { - return vis.copyCurrentState(true); + return { + ...vis.clone(), + }; } +export type EditorVisState = Pick; + const createEditorStateReducer = ({ aggs: { createAggConfigs }, -}: DataPublicPluginStart['search']) => (state: VisState, action: EditorAction): VisState => { +}: DataPublicPluginStart['search']) => ( + state: EditorVisState, + action: EditorAction +): EditorVisState => { switch (action.type) { case EditorStateActionTypes.ADD_NEW_AGG: { const { schema } = action.payload; const defaultConfig = - !state.aggs.aggs.find(agg => agg.schema === schema.name) && schema.defaults + !state.data.aggs!.aggs.find(agg => agg.schema === schema.name) && schema.defaults ? (schema as any).defaults.slice(0, schema.max) : { schema: schema.name }; - const aggConfig = state.aggs.createAggConfig(defaultConfig, { + const aggConfig = state.data.aggs!.createAggConfig(defaultConfig, { addToAggConfigs: false, }); aggConfig.brandNew = true; - const newAggs = [...state.aggs.aggs, aggConfig]; + const newAggs = [...state.data.aggs!.aggs, aggConfig]; return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs), + data: { + ...state.data, + aggs: createAggConfigs(state.data.indexPattern!, newAggs), + }, }; } @@ -58,7 +68,7 @@ const createEditorStateReducer = ({ case EditorStateActionTypes.CHANGE_AGG_TYPE: { const { aggId, value } = action.payload; - const newAggs = state.aggs.aggs.map(agg => { + const newAggs = state.data.aggs!.aggs.map(agg => { if (agg.id === aggId) { agg.type = value; @@ -70,14 +80,17 @@ const createEditorStateReducer = ({ return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs), + data: { + ...state.data, + aggs: createAggConfigs(state.data.indexPattern!, newAggs), + }, }; } case EditorStateActionTypes.SET_AGG_PARAM_VALUE: { const { aggId, paramName, value } = action.payload; - const newAggs = state.aggs.aggs.map(agg => { + const newAggs = state.data.aggs!.aggs.map(agg => { if (agg.id === aggId) { const parsedAgg = agg.toJSON(); @@ -95,7 +108,10 @@ const createEditorStateReducer = ({ return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs), + data: { + ...state.data, + aggs: createAggConfigs(state.data.indexPattern!, newAggs), + }, }; } @@ -113,7 +129,7 @@ const createEditorStateReducer = ({ case EditorStateActionTypes.REMOVE_AGG: { let isMetric = false; - const newAggs = state.aggs.aggs.filter(({ id, schema }) => { + const newAggs = state.data.aggs!.aggs.filter(({ id, schema }) => { if (id === action.payload.aggId) { const schemaDef = action.payload.schemas.find(s => s.name === schema); if (schemaDef && schemaDef.group === AggGroupNames.Metrics) { @@ -136,26 +152,36 @@ const createEditorStateReducer = ({ return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs), + data: { + ...state.data, + aggs: createAggConfigs(state.data.indexPattern!, newAggs), + }, }; } case EditorStateActionTypes.REORDER_AGGS: { const { sourceAgg, destinationAgg } = action.payload; - const destinationIndex = state.aggs.aggs.indexOf(destinationAgg); - const newAggs = [...state.aggs.aggs]; - newAggs.splice(destinationIndex, 0, newAggs.splice(state.aggs.aggs.indexOf(sourceAgg), 1)[0]); + const destinationIndex = state.data.aggs!.aggs.indexOf(destinationAgg); + const newAggs = [...state.data.aggs!.aggs]; + newAggs.splice( + destinationIndex, + 0, + newAggs.splice(state.data.aggs!.aggs.indexOf(sourceAgg), 1)[0] + ); return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs), + data: { + ...state.data, + aggs: createAggConfigs(state.data.indexPattern!, newAggs), + }, }; } case EditorStateActionTypes.TOGGLE_ENABLED_AGG: { const { aggId, enabled } = action.payload; - const newAggs = state.aggs.aggs.map(agg => { + const newAggs = state.data.aggs!.aggs.map(agg => { if (agg.id === aggId) { const parsedAgg = agg.toJSON(); @@ -170,7 +196,10 @@ const createEditorStateReducer = ({ return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs), + data: { + ...state.data, + aggs: createAggConfigs(state.data.indexPattern!, newAggs), + }, }; } diff --git a/src/legacy/core_plugins/vis_default_editor/public/default_editor.tsx b/src/legacy/core_plugins/vis_default_editor/public/default_editor.tsx index fa3213d244e7e..b504dfd6a55e9 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/default_editor.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/default_editor.tsx @@ -20,10 +20,6 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; import { EditorRenderProps } from '../../kibana/public/visualize/np_ready/types'; -import { - VisualizeEmbeddableContract as VisualizeEmbeddable, - VisualizeEmbeddableFactoryContract as VisualizeEmbeddableFactory, -} from '../../visualizations/public/'; import { PanelsContainer, Panel } from '../../../../plugins/kibana_react/public'; import './vis_type_agg_filter'; @@ -32,68 +28,44 @@ import { DefaultEditorControllerState } from './default_editor_controller'; import { getInitialWidth } from './editor_size'; function DefaultEditor({ - embeddable, - savedObj, + vis, uiState, timeRange, filters, - appState, optionTabs, query, + embeddableHandler, + eventEmitter, linked, + savedSearch, }: DefaultEditorControllerState & Omit) { const visRef = useRef(null); - const visHandler = useRef(null); const [isCollapsed, setIsCollapsed] = useState(false); - const [factory, setFactory] = useState(null); - const { vis, savedSearch } = savedObj; const onClickCollapse = useCallback(() => { setIsCollapsed(value => !value); }, []); useEffect(() => { - async function visualize() { - if (!visRef.current || (!visHandler.current && factory)) { - return; - } - - if (!visHandler.current) { - const embeddableFactory = embeddable.getEmbeddableFactory( - 'visualization' - ) as VisualizeEmbeddableFactory; - setFactory(embeddableFactory); - - visHandler.current = (await embeddableFactory.createFromObject(savedObj, { - // should be look through createFromObject interface again because of "id" param - id: '', - uiState, - appState, - timeRange, - filters, - query, - })) as VisualizeEmbeddable; - - visHandler.current.render(visRef.current); - } else { - visHandler.current.updateInput({ - timeRange, - filters, - query, - }); - } + if (!visRef.current) { + return; } - visualize(); - }, [uiState, savedObj, timeRange, filters, appState, query, factory, embeddable]); + embeddableHandler.render(visRef.current); + setTimeout(() => { + eventEmitter.emit('apply'); + }); + + return () => embeddableHandler.destroy(); + }, [embeddableHandler, eventEmitter]); useEffect(() => { - return () => { - if (visHandler.current) { - visHandler.current.destroy(); - } - }; - }, []); + embeddableHandler.updateInput({ + timeRange, + filters, + query, + }); + }, [embeddableHandler, timeRange, filters, query]); const editorInitialWidth = getInitialWidth(vis.type.editorConfig.defaultSize); @@ -120,6 +92,7 @@ function DefaultEditor({ uiState={uiState} isLinkedSearch={linked} savedSearch={savedSearch} + eventEmitter={eventEmitter} /> diff --git a/src/legacy/core_plugins/vis_default_editor/public/default_editor_controller.tsx b/src/legacy/core_plugins/vis_default_editor/public/default_editor_controller.tsx index db910604eddd1..13fcabd799959 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/default_editor_controller.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/default_editor_controller.tsx @@ -21,18 +21,22 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; +import { EventEmitter } from 'events'; import { EditorRenderProps } from 'src/legacy/core_plugins/kibana/public/visualize/np_ready/types'; -import { VisSavedObject } from 'src/legacy/core_plugins/visualizations/public/'; +import { Vis } from 'src/legacy/core_plugins/visualizations/public/'; import { Storage } from '../../../../plugins/kibana_utils/public'; import { KibanaContextProvider } from '../../../../plugins/kibana_react/public'; import { DefaultEditor } from './default_editor'; import { DefaultEditorDataTab, OptionTab } from './components/sidebar'; +import { VisualizeEmbeddable } from '../../visualizations/public/np_ready/public/embeddable'; const localStorage = new Storage(window.localStorage); export interface DefaultEditorControllerState { - savedObj: VisSavedObject; + vis: Vis; + eventEmitter: EventEmitter; + embeddableHandler: VisualizeEmbeddable; optionTabs: OptionTab[]; } @@ -40,9 +44,9 @@ class DefaultEditorController { private el: HTMLElement; private state: DefaultEditorControllerState; - constructor(el: HTMLElement, savedObj: VisSavedObject) { + constructor(el: HTMLElement, vis: Vis, eventEmitter: EventEmitter, embeddableHandler: any) { this.el = el; - const { type: visType } = savedObj.vis; + const { type: visType } = vis; const optionTabs = [ ...(visType.schemas.buckets || visType.schemas.metrics @@ -71,8 +75,10 @@ class DefaultEditorController { ]; this.state = { - savedObj, + vis, optionTabs, + eventEmitter, + embeddableHandler, }; } diff --git a/src/legacy/core_plugins/vis_default_editor/public/vis_options_props.tsx b/src/legacy/core_plugins/vis_default_editor/public/vis_options_props.tsx index 2e8f20946c73a..3239e871a2465 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/vis_options_props.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/vis_options_props.tsx @@ -17,8 +17,8 @@ * under the License. */ +import { PersistedState } from 'src/plugins/visualizations/public'; import { IAggConfigs } from 'src/plugins/data/public'; -import { PersistedState } from '../../../../plugins/visualizations/public'; import { Vis } from '../../visualizations/public'; export interface VisOptionsProps { diff --git a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.test.tsx b/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.test.tsx index 6a466c9cd0211..7ba4fe017522d 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.test.tsx +++ b/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.test.tsx @@ -20,12 +20,12 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { Vis } from 'src/legacy/core_plugins/visualizations/public'; import { MetricVisComponent, MetricVisComponentProps } from './metric_vis_component'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { npStart } from 'ui/new_platform'; import { fieldFormats } from '../../../../../plugins/data/public'; import { identity } from 'lodash'; +import { ExprVis } from '../../../visualizations/public/np_ready/public/expressions/vis'; jest.mock('ui/new_platform'); @@ -37,7 +37,7 @@ const baseVisData = { } as any; describe('MetricVisComponent', function() { - const vis: Vis = { + const vis: ExprVis = { params: { metric: { colorSchema: 'Green to Red', @@ -57,7 +57,7 @@ describe('MetricVisComponent', function() { const getComponent = (propOverrides: Partial = {} as Partial) => { const props: Props = { vis, - visParams: vis.params, + visParams: vis.params as any, visData: baseVisData, renderComplete: jest.fn(), ...propOverrides, diff --git a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.tsx b/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.tsx index a93bb618da31f..175458497a05e 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.tsx +++ b/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.tsx @@ -27,12 +27,13 @@ import { FieldFormatsContentType, IFieldFormat } from '../../../../../plugins/da import { KibanaDatatable } from '../../../../../plugins/expressions/public'; import { getHeatmapColors } from '../../../../../plugins/charts/public'; import { VisParams, MetricVisMetric } from '../types'; -import { SchemaConfig, Vis } from '../../../visualizations/public'; +import { SchemaConfig } from '../../../visualizations/public'; +import { ExprVis } from '../../../visualizations/public/np_ready/public/expressions/vis'; export interface MetricVisComponentProps { visParams: VisParams; visData: Input; - vis: Vis; + vis: ExprVis; renderComplete: () => void; } diff --git a/src/legacy/core_plugins/vis_type_metric/public/metric_vis_type.test.ts b/src/legacy/core_plugins/vis_type_metric/public/metric_vis_type.test.ts index cce5864aa50a1..c0bfa47bff502 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/metric_vis_type.test.ts +++ b/src/legacy/core_plugins/vis_type_metric/public/metric_vis_type.test.ts @@ -61,11 +61,22 @@ describe('metric_vis - createMetricVisTypeDefinition', () => { labelTemplate: 'ip[{{value}}]', }); + const searchSource = { + getField: (name: string) => { + if (name === 'index') { + return stubIndexPattern; + } + }, + }; + // TODO: remove when Vis is converted to typescript. Only importing Vis as type // @ts-ignore - vis = visualizationsStart.createVis(stubIndexPattern, { + vis = visualizationsStart.createVis('metric', { type: 'metric', - aggs: [{ id: '1', type: 'top_hits', schema: 'metric', params: { field: 'ip' } }], + data: { + searchSource, + aggs: [{ id: '1', type: 'top_hits', schema: 'metric', params: { field: 'ip' } }], + }, }); vis.params.dimensions = { diff --git a/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table.js b/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table.js index 8edef2ea16353..211b79e915038 100644 --- a/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table.js +++ b/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table.js @@ -50,57 +50,73 @@ describe('Table Vis - AggTable Directive', function() { const tabifiedData = {}; const init = () => { - const vis1 = visualizationsStart.createVis(indexPattern, 'table'); - tabifiedData.metricOnly = tabifyAggResponse(vis1.aggs, metricOnly); + const searchSource = { + getField: name => { + if (name === 'index') { + return indexPattern; + } + }, + }; + const vis1 = visualizationsStart.createVis('table', { + type: 'table', + data: { searchSource, aggs: [] }, + }); + tabifiedData.metricOnly = tabifyAggResponse(vis1.data.aggs, metricOnly); - const vis2 = visualizationsStart.createVis(indexPattern, { + const vis2 = visualizationsStart.createVis('table', { type: 'table', params: { showMetricsAtAllLevels: true, }, - aggs: [ - { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, - { type: 'terms', schema: 'bucket', params: { field: 'extension' } }, - { type: 'terms', schema: 'bucket', params: { field: 'geo.src' } }, - { type: 'terms', schema: 'bucket', params: { field: 'machine.os' } }, - ], + data: { + aggs: [ + { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, + { type: 'terms', schema: 'bucket', params: { field: 'extension' } }, + { type: 'terms', schema: 'bucket', params: { field: 'geo.src' } }, + { type: 'terms', schema: 'bucket', params: { field: 'machine.os' } }, + ], + searchSource, + }, }); - vis2.aggs.aggs.forEach(function(agg, i) { + vis2.data.aggs.aggs.forEach(function(agg, i) { agg.id = 'agg_' + (i + 1); }); - tabifiedData.threeTermBuckets = tabifyAggResponse(vis2.aggs, threeTermBuckets, { + tabifiedData.threeTermBuckets = tabifyAggResponse(vis2.data.aggs, threeTermBuckets, { metricsAtAllLevels: true, }); - const vis3 = visualizationsStart.createVis(indexPattern, { + const vis3 = visualizationsStart.createVis('table', { type: 'table', - aggs: [ - { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, - { type: 'min', schema: 'metric', params: { field: '@timestamp' } }, - { type: 'terms', schema: 'bucket', params: { field: 'extension' } }, - { - type: 'date_histogram', - schema: 'bucket', - params: { field: '@timestamp', interval: 'd' }, - }, - { - type: 'derivative', - schema: 'metric', - params: { metricAgg: 'custom', customMetric: { id: '5-orderAgg', type: 'count' } }, - }, - { - type: 'top_hits', - schema: 'metric', - params: { field: 'bytes', aggregate: { val: 'min' }, size: 1 }, - }, - ], + data: { + aggs: [ + { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, + { type: 'min', schema: 'metric', params: { field: '@timestamp' } }, + { type: 'terms', schema: 'bucket', params: { field: 'extension' } }, + { + type: 'date_histogram', + schema: 'bucket', + params: { field: '@timestamp', interval: 'd' }, + }, + { + type: 'derivative', + schema: 'metric', + params: { metricAgg: 'custom', customMetric: { id: '5-orderAgg', type: 'count' } }, + }, + { + type: 'top_hits', + schema: 'metric', + params: { field: 'bytes', aggregate: { val: 'min' }, size: 1 }, + }, + ], + searchSource, + }, }); - vis3.aggs.aggs.forEach(function(agg, i) { + vis3.data.aggs.aggs.forEach(function(agg, i) { agg.id = 'agg_' + (i + 1); }); tabifiedData.oneTermOneHistogramBucketWithTwoMetricsOneTopHitOneDerivative = tabifyAggResponse( - vis3.aggs, + vis3.data.aggs, oneTermOneHistogramBucketWithTwoMetricsOneTopHitOneDerivative ); }; diff --git a/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table_group.js b/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table_group.js index 89900d2144030..77f817e44ba79 100644 --- a/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table_group.js +++ b/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table_group.js @@ -38,22 +38,35 @@ describe('Table Vis - AggTableGroup Directive', function() { const tabifiedData = {}; const init = () => { - const vis1 = visualizationsStart.createVis(indexPattern, 'table'); - tabifiedData.metricOnly = tabifyAggResponse(vis1.aggs, metricOnly); + const searchSource = { + getField: name => { + if (name === 'index') { + return indexPattern; + } + }, + }; + const vis1 = visualizationsStart.createVis('table', { + type: 'table', + data: { searchSource, aggs: [] }, + }); + tabifiedData.metricOnly = tabifyAggResponse(vis1.data.aggs, metricOnly); - const vis2 = visualizationsStart.createVis(indexPattern, { + const vis2 = visualizationsStart.createVis('pie', { type: 'pie', - aggs: [ - { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, - { type: 'terms', schema: 'split', params: { field: 'extension' } }, - { type: 'terms', schema: 'segment', params: { field: 'geo.src' } }, - { type: 'terms', schema: 'segment', params: { field: 'machine.os' } }, - ], + data: { + aggs: [ + { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, + { type: 'terms', schema: 'split', params: { field: 'extension' } }, + { type: 'terms', schema: 'segment', params: { field: 'geo.src' } }, + { type: 'terms', schema: 'segment', params: { field: 'machine.os' } }, + ], + searchSource, + }, }); - vis2.aggs.aggs.forEach(function(agg, i) { + vis2.data.aggs.aggs.forEach(function(agg, i) { agg.id = 'agg_' + (i + 1); }); - tabifiedData.threeTermBuckets = tabifyAggResponse(vis2.aggs, threeTermBuckets); + tabifiedData.threeTermBuckets = tabifyAggResponse(vis2.data.aggs, threeTermBuckets); }; const initLocalAngular = () => { diff --git a/src/legacy/core_plugins/vis_type_table/public/table_vis_controller.test.ts b/src/legacy/core_plugins/vis_type_table/public/table_vis_controller.test.ts index 327a47093f535..ad56607e9296c 100644 --- a/src/legacy/core_plugins/vis_type_table/public/table_vis_controller.test.ts +++ b/src/legacy/core_plugins/vis_type_table/public/table_vis_controller.test.ts @@ -118,20 +118,22 @@ describe('Table Vis - Controller', () => { return ({ type: tableVisTypeDefinition, params: Object.assign({}, tableVisTypeDefinition.visConfig.defaults, params), - aggs: createAggConfigs(stubIndexPattern, [ - { type: 'count', schema: 'metric' }, - { - type: 'range', - schema: 'bucket', - params: { - field: 'bytes', - ranges: [ - { from: 0, to: 1000 }, - { from: 1000, to: 2000 }, - ], + data: { + aggs: createAggConfigs(stubIndexPattern, [ + { type: 'count', schema: 'metric' }, + { + type: 'range', + schema: 'bucket', + params: { + field: 'bytes', + ranges: [ + { from: 0, to: 1000 }, + { from: 1000, to: 2000 }, + ], + }, }, - }, - ]), + ]), + }, } as unknown) as Vis; } @@ -151,11 +153,11 @@ describe('Table Vis - Controller', () => { // basically a parameterized beforeEach function initController(vis: Vis) { - vis.aggs.aggs.forEach((agg: IAggConfig, i: number) => { + vis.data.aggs!.aggs.forEach((agg: IAggConfig, i: number) => { agg.id = 'agg_' + (i + 1); }); - tabifiedResponse = tabifyAggResponse(vis.aggs, oneRangeBucket); + tabifiedResponse = tabifyAggResponse(vis.data.aggs!, oneRangeBucket); $rootScope.vis = vis; $rootScope.visParams = vis.params; $rootScope.uiState = { diff --git a/src/legacy/core_plugins/vis_type_table/public/vis_controller.ts b/src/legacy/core_plugins/vis_type_table/public/vis_controller.ts index 2d27a99bdd8af..2feaad9f4e6b6 100644 --- a/src/legacy/core_plugins/vis_type_table/public/vis_controller.ts +++ b/src/legacy/core_plugins/vis_type_table/public/vis_controller.ts @@ -19,12 +19,12 @@ import angular, { IModule, auto, IRootScopeService, IScope, ICompileService } from 'angular'; import $ from 'jquery'; -import { isEqual } from 'lodash'; -import { Vis, VisParams } from '../../visualizations/public'; +import { VisParams } from '../../visualizations/public'; import { npStart } from './legacy_imports'; import { getAngularModule } from './get_inner_angular'; import { initTableVisLegacyModule } from './table_vis_legacy_module'; +import { ExprVis } from '../../visualizations/public/np_ready/public/expressions/vis'; const innerAngularName = 'kibana/table_vis'; @@ -32,12 +32,12 @@ export class TableVisualizationController { private tableVisModule: IModule | undefined; private injector: auto.IInjectorService | undefined; el: JQuery; - vis: Vis; + vis: ExprVis; $rootScope: IRootScopeService | null = null; $scope: (IScope & { [key: string]: any }) | undefined; $compile: ICompileService | undefined; - constructor(domeElement: Element, vis: Vis) { + constructor(domeElement: Element, vis: ExprVis) { this.el = $(domeElement); this.vis = vis; } @@ -60,7 +60,7 @@ export class TableVisualizationController { } } - async render(esResponse: object, visParams: VisParams, status: { [key: string]: boolean }) { + async render(esResponse: object, visParams: VisParams) { this.initLocalAngular(); return new Promise(async (resolve, reject) => { @@ -77,15 +77,10 @@ export class TableVisualizationController { this.$scope.visState = { params: visParams }; this.$scope.esResponse = esResponse; - if (!isEqual(this.$scope.visParams, visParams)) { - this.vis.emit('updateEditorStateParams', visParams); - } - this.$scope.visParams = visParams; this.$scope.renderComplete = resolve; this.$scope.renderFailed = reject; this.$scope.resize = Date.now(); - this.$scope.updateStatus = status; this.$scope.$apply(); }; @@ -93,7 +88,7 @@ export class TableVisualizationController { this.$scope = this.$rootScope.$new(); this.$scope.uiState = this.vis.getUiState(); updateScope(); - this.el.find('div').append(this.$compile(this.vis.type.visConfig.template)(this.$scope)); + this.el.find('div').append(this.$compile(this.vis.type!.visConfig.template)(this.$scope)); this.$scope.$apply(); } else { updateScope(); diff --git a/src/legacy/core_plugins/vis_type_tagcloud/public/components/__tests__/tag_cloud_visualization.js b/src/legacy/core_plugins/vis_type_tagcloud/public/components/__tests__/tag_cloud_visualization.js index 3091b3340cd6d..6f54744a2f508 100644 --- a/src/legacy/core_plugins/vis_type_tagcloud/public/components/__tests__/tag_cloud_visualization.js +++ b/src/legacy/core_plugins/vis_type_tagcloud/public/components/__tests__/tag_cloud_visualization.js @@ -19,7 +19,6 @@ import expect from '@kbn/expect'; import ngMock from 'ng_mock'; -import LogstashIndexPatternStubProvider from 'fixtures/stubbed_logstash_index_pattern'; import { start as visualizationsStart } from '../../../../../core_plugins/visualizations/public/np_ready/public/legacy'; import { ImageComparator } from 'test_utils/image_comparator'; import { createTagCloudVisualization } from '../tag_cloud_visualization'; @@ -36,7 +35,6 @@ const PIXEL_DIFF = 64; describe('TagCloudVisualizationTest', function() { let domNode; - let indexPattern; let vis; let imageComparator; @@ -66,22 +64,18 @@ describe('TagCloudVisualizationTest', function() { }); beforeEach(ngMock.module('kibana')); - beforeEach( - ngMock.inject(Private => { - indexPattern = Private(LogstashIndexPatternStubProvider); - }) - ); describe('TagCloudVisualization - basics', function() { beforeEach(async function() { setupDOM('512px', '512px'); imageComparator = new ImageComparator(); - vis = visualizationsStart.createVis(indexPattern, { + vis = visualizationsStart.createVis('tagcloud', { type: 'tagcloud', params: { bucket: { accessor: 0, format: {} }, metric: { accessor: 0, format: {} }, }, + data: {}, }); }); diff --git a/src/legacy/core_plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js b/src/legacy/core_plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js index 114643c9a74e0..04f447bf78d50 100644 --- a/src/legacy/core_plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js +++ b/src/legacy/core_plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js @@ -79,17 +79,10 @@ export function createTagCloudVisualization({ colors }) { render(