From c22dbb17641b5ea9a9ae0742e623f7b5c53ffbfc Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Mon, 23 Mar 2020 10:29:33 -0400 Subject: [PATCH 01/12] [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 02/12] 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 03/12] [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 04/12] [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 05/12] =?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 06/12] 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 07/12] [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 08/12] [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 09/12] [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 10/12] 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 11/12] 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 12/12] [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 = ({ <> - - + +