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/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/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/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/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 { { - 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_timeseries/public/visualizations/constants/chart.js b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/constants/chart.js index 2e7eae438de12..d5ecbaa2ade06 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/constants/chart.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/constants/chart.js @@ -32,6 +32,7 @@ export const GRID_LINE_CONFIG = { export const X_ACCESSOR_INDEX = 0; export const STACK_ACCESSORS = [0]; export const Y_ACCESSOR_INDEXES = [1]; +export const Y0_ACCESSOR_INDEXES = [2]; export const STACKED_OPTIONS = { NONE: 'none', diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/area_decorator.js b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/area_decorator.js index 923024ff690a4..0afe773266a61 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/area_decorator.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/area_decorator.js @@ -21,7 +21,7 @@ import React from 'react'; import { ScaleType, AreaSeries } from '@elastic/charts'; import { getAreaStyles } from '../utils/series_styles'; import { ChartsEntities } from '../model/charts'; -import { X_ACCESSOR_INDEX, Y_ACCESSOR_INDEXES } from '../../../constants'; +import { X_ACCESSOR_INDEX, Y_ACCESSOR_INDEXES, Y0_ACCESSOR_INDEXES } from '../../../constants'; export function AreaSeriesDecorator({ seriesId, @@ -40,6 +40,8 @@ export function AreaSeriesDecorator({ enableHistogramMode, useDefaultGroupDomain, sortIndex, + y1AccessorFormat, + y0AccessorFormat, }) { const id = seriesId; const groupId = seriesGroupId; @@ -54,6 +56,9 @@ export function AreaSeriesDecorator({ hideInLegend, xAccessor: X_ACCESSOR_INDEX, yAccessors: Y_ACCESSOR_INDEXES, + y0Accessors: lines.mode === 'band' ? Y0_ACCESSOR_INDEXES : undefined, + y1AccessorFormat, + y0AccessorFormat, stackAccessors, stackAsPercentage, xScaleType, diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/bar_decorator.js b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/bar_decorator.js index 6d2cd7b8dd935..c979920caac6d 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/bar_decorator.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/bar_decorator.js @@ -21,7 +21,7 @@ import React from 'react'; import { ScaleType, BarSeries } from '@elastic/charts'; import { getBarStyles } from '../utils/series_styles'; import { ChartsEntities } from '../model/charts'; -import { X_ACCESSOR_INDEX, Y_ACCESSOR_INDEXES } from '../../../constants'; +import { X_ACCESSOR_INDEX, Y_ACCESSOR_INDEXES, Y0_ACCESSOR_INDEXES } from '../../../constants'; export function BarSeriesDecorator({ seriesId, @@ -39,6 +39,8 @@ export function BarSeriesDecorator({ enableHistogramMode, useDefaultGroupDomain, sortIndex, + y1AccessorFormat, + y0AccessorFormat, }) { const id = seriesId; const groupId = seriesGroupId; @@ -53,6 +55,9 @@ export function BarSeriesDecorator({ hideInLegend, xAccessor: X_ACCESSOR_INDEX, yAccessors: Y_ACCESSOR_INDEXES, + y0Accessors: bars.mode === 'band' ? Y0_ACCESSOR_INDEXES : undefined, + y1AccessorFormat, + y0AccessorFormat, stackAccessors, stackAsPercentage, xScaleType, diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js index 5673f560214c7..bec76433eb8ac 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js @@ -156,6 +156,8 @@ export const TimeSeries = ({ stack, points, useDefaultGroupDomain, + y1AccessorFormat, + y0AccessorFormat, }, sortIndex ) => { @@ -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/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/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/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/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/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], ]); 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', ]); }); 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 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 diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.tsx deleted file mode 100644 index 77f0b64ba0fb1..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.tsx +++ /dev/null @@ -1,74 +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 { - EuiButton, - EuiPanel, - EuiFlexGroup, - EuiFlexItem, - EuiTitle, - EuiText, - EuiSpacer -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { invalidLicenseMessage } from '../../../../../../../plugins/apm/common/service_map'; -import { useKibanaUrl } from '../../../hooks/useKibanaUrl'; - -export function PlatinumLicensePrompt() { - // Set the height to give it some top margin - const flexGroupStyle = { height: '60vh' }; - const flexItemStyle = { width: 600, textAlign: 'center' as const }; - - const licensePageUrl = useKibanaUrl( - '/app/kibana', - '/management/elasticsearch/license_management/home' - ); - - return ( - - - - - - -

- {i18n.translate('xpack.apm.serviceMap.licensePromptTitle', { - defaultMessage: 'Service maps is available in Platinum.' - })} -

-
- - -

{invalidLicenseMessage}

-
- - - {i18n.translate('xpack.apm.serviceMap.licensePromptButtonText', { - defaultMessage: 'Start 30-day Platinum trial' - })} - -
-
-
-
- ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index 5770771e01905..4974553f6ca93 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -4,21 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import theme from '@elastic/eui/dist/eui_theme_light.json'; import React from 'react'; -import { isValidPlatinumLicense } from '../../../../../../../plugins/apm/common/service_map'; +import { + invalidLicenseMessage, + isValidPlatinumLicense +} from '../../../../../../../plugins/apm/common/service_map'; import { useFetcher } from '../../../hooks/useFetcher'; import { useLicense } from '../../../hooks/useLicense'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { callApmApi } from '../../../services/rest/createCallApmApi'; -import { BetaBadge } from './BetaBadge'; +import { LicensePrompt } from '../../shared/LicensePrompt'; import { Controls } from './Controls'; import { Cytoscape } from './Cytoscape'; import { cytoscapeDivStyle } from './cytoscapeOptions'; import { EmptyBanner } from './EmptyBanner'; -import { PlatinumLicensePrompt } from './PlatinumLicensePrompt'; import { Popover } from './Popover'; import { useRefDimensions } from './useRefDimensions'; +import { BetaBadge } from './BetaBadge'; interface ServiceMapProps { serviceName?: string; @@ -74,6 +78,18 @@ export function ServiceMap({ serviceName }: ServiceMapProps) {
) : ( - + + + + + ); } diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx new file mode 100644 index 0000000000000..48a0288f11ae5 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { ElasticDocsLink } from '../../../../../shared/Links/ElasticDocsLink'; + +interface Props { + label: string; +} +export const Documentation = ({ label }: Props) => ( + + {label} + +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx index 69fecf25f5143..1c253b2fa8bff 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx @@ -16,12 +16,11 @@ import { import { i18n } from '@kbn/i18n'; import { isEmpty } from 'lodash'; import React from 'react'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { FilterOptions } from '../../../../../../../../../../plugins/apm/server/routes/settings/custom_link'; +import { FilterOptions } from '../../../../../../../../../../plugins/apm/common/custom_link_filter_options'; import { DEFAULT_OPTION, - Filters, - filterSelectOptions, + FilterKeyValue, + FILTER_SELECT_OPTIONS, getSelectOptions } from './helper'; @@ -29,10 +28,10 @@ export const FiltersSection = ({ filters, onChangeFilters }: { - filters: Filters; - onChangeFilters: (filters: Filters) => void; + filters: FilterKeyValue[]; + onChangeFilters: (filters: FilterKeyValue[]) => void; }) => { - const onChangeFilter = (filter: Filters[0], idx: number) => { + const onChangeFilter = (filter: FilterKeyValue, idx: number) => { const newFilters = [...filters]; newFilters[idx] = filter; onChangeFilters(newFilters); @@ -40,7 +39,8 @@ export const FiltersSection = ({ const onRemoveFilter = (idx: number) => { // remove without mutating original array - const newFilters = [...filters].splice(idx, 1); + const newFilters = [...filters]; + newFilters.splice(idx, 1); // if there is only one item left it should not be removed // but reset to empty @@ -68,12 +68,12 @@ export const FiltersSection = ({ - + {i18n.translate( 'xpack.apm.settings.customizeUI.customLink.flyout.filters.subtitle', { defaultMessage: - 'Add additional values within the same field by comma separating values.' + 'Use the filter options to scope them to only appear for specific services.' } )} @@ -83,12 +83,12 @@ export const FiltersSection = ({ {filters.map((filter, idx) => { const [key, value] = filter; const filterId = `filter-${idx}`; - const selectOptions = getSelectOptions(filters, idx); + const selectOptions = getSelectOptions(filters, key); return ( onRemoveFilter(idx)} - disabled={!key && filters.length === 1} + disabled={!value && !key && filters.length === 1} /> @@ -139,7 +140,7 @@ export const FiltersSection = ({ ); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx new file mode 100644 index 0000000000000..9b487cf916089 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { LinkPreview } from '../CustomLinkFlyout/LinkPreview'; +import { render, getNodeText, getByTestId } from '@testing-library/react'; + +describe('LinkPreview', () => { + const getElementValue = (container: HTMLElement, id: string) => + getNodeText( + ((getByTestId(container, id) as HTMLDivElement) + .children as HTMLCollection)[0] as HTMLDivElement + ); + + it('shows label and url default values', () => { + const { container } = render( + + ); + expect(getElementValue(container, 'preview-label')).toEqual('Elastic.co'); + expect(getElementValue(container, 'preview-url')).toEqual( + 'https://www.elastic.co' + ); + }); + + it('shows label and url values', () => { + const { container } = render( + + ); + expect(getElementValue(container, 'preview-label')).toEqual('foo'); + expect( + (getByTestId(container, 'preview-link') as HTMLAnchorElement).text + ).toEqual('https://baz.co'); + }); + + it('shows warning when couldnt replace context variables', () => { + const { container } = render( + + ); + expect(getElementValue(container, 'preview-label')).toEqual('foo'); + expect( + (getByTestId(container, 'preview-link') as HTMLAnchorElement).text + ).toEqual('https://baz.co?service.name={{invalid}'); + expect(getByTestId(container, 'preview-warning')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx new file mode 100644 index 0000000000000..0ad3455ab271f --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import { + EuiPanel, + EuiText, + EuiSpacer, + EuiLink, + EuiToolTip, + EuiIcon, + EuiFlexGroup, + EuiFlexItem +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { debounce } from 'lodash'; +import { Transaction } from '../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { callApmApi } from '../../../../../../services/rest/createCallApmApi'; +import { + FilterKeyValue, + convertFiltersToObject, + replaceTemplateVariables +} from './helper'; + +interface Props { + label: string; + url: string; + filters: FilterKeyValue[]; +} + +const fetchTransaction = debounce( + async ( + filters: FilterKeyValue[], + callback: (transaction: Transaction) => void + ) => { + const transaction = await callApmApi({ + pathname: '/api/apm/settings/custom_links/transaction', + params: { query: convertFiltersToObject(filters) } + }); + callback(transaction); + }, + 1000 +); + +const getTextColor = (value?: string) => (value ? 'default' : 'subdued'); + +export const LinkPreview = ({ label, url, filters }: Props) => { + const [transaction, setTransaction] = useState(); + + useEffect(() => { + fetchTransaction(filters, setTransaction); + }, [filters]); + + const { formattedUrl, error } = replaceTemplateVariables(url, transaction); + + return ( + + + {label + ? label + : i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.default.label', + { defaultMessage: 'Elastic.co' } + )} + + + + {url ? ( + + {formattedUrl} + + ) : ( + i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.default.url', + { defaultMessage: 'https://www.elastic.co' } + ) + )} + + + + + + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.linkPreview.descrition', + { + defaultMessage: + 'Test your link with values from an example transaction document based on the filters above.' + } + )} + + + + + {error && ( + + + + )} + + + + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx index 89f55a6c682ca..8bcebc2aea09e 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx @@ -13,11 +13,12 @@ import { import { i18n } from '@kbn/i18n'; import React from 'react'; import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { Documentation } from './Documentation'; interface InputField { name: keyof CustomLink; label: string; - helpText: string; + helpText: string | React.ReactNode; placeholder: string; onChange: (value: string) => void; value?: string; @@ -69,13 +70,25 @@ export const LinkSection = ({ defaultMessage: 'URL' } ), - helpText: i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.flyout.link.url.helpText', - { - defaultMessage: - 'Add fieldname variables to your URL to apply values e.g. {sample}. TODO: Learn more in the docs.', - values: { sample: '{{trace.id}}' } - } + helpText: ( + <> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.url.helpText', + { + defaultMessage: + 'Add field name variables to your URL to apply values e.g. {sample}.', + values: { sample: '{{trace.id}}' } + } + )}{' '} + + ), placeholder: i18n.translate( 'xpack.apm.settings.customizeUI.customLink.flyout.link.url.placeholder', @@ -125,7 +138,7 @@ export const LinkSection = ({ fullWidth value={field.value} onChange={e => field.onChange(e.target.value)} - aria-label={field.name} + data-test-subj={field.name} /> ); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.test.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.test.ts new file mode 100644 index 0000000000000..ac01ee48f2fe5 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.test.ts @@ -0,0 +1,205 @@ +/* + * 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 { + convertFiltersToArray, + convertFiltersToObject, + getSelectOptions, + replaceTemplateVariables +} from '../CustomLinkFlyout/helper'; +import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { Transaction } from '../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; + +describe('Custom link helper', () => { + describe('convertFiltersToArray', () => { + it('returns array of tuple when custom link not defined', () => { + expect(convertFiltersToArray()).toEqual([['', '']]); + }); + it('returns filters as array', () => { + expect( + convertFiltersToArray({ + 'service.name': 'foo', + 'transaction.type': 'bar' + } as CustomLink) + ).toEqual([ + ['service.name', 'foo'], + ['transaction.type', 'bar'] + ]); + }); + it('returns empty when no filter is added', () => { + expect( + convertFiltersToArray({ + label: 'foo', + url: 'bar' + } as CustomLink) + ).toEqual([['', '']]); + }); + }); + + describe('convertFiltersToObject', () => { + it('returns undefined when any filter is added', () => { + expect(convertFiltersToObject([['', '']])).toBeUndefined(); + }); + it('removes uncompleted filters', () => { + expect( + convertFiltersToObject([ + ['service.name', ''], + ['', 'foo'], + ['transaction.type', 'bar'] + ]) + ).toEqual({ 'transaction.type': ['bar'] }); + }); + it('splits the value by comma', () => { + expect( + convertFiltersToObject([ + ['service.name', 'foo'], + ['service.environment', 'foo, bar'], + ['transaction.type', 'foo, '], + ['transaction.name', 'foo,'] + ]) + ).toEqual({ + 'service.name': ['foo'], + 'service.environment': ['foo', 'bar'], + 'transaction.type': ['foo'], + 'transaction.name': ['foo'] + }); + }); + }); + + describe('getSelectOptions', () => { + it('returns all available options when no filters were selected', () => { + expect( + getSelectOptions( + [ + ['', ''], + ['', ''], + ['', ''], + ['', ''] + ], + '' + ) + ).toEqual([ + { value: 'DEFAULT', text: 'Select field...' }, + { value: 'service.name', text: 'service.name' }, + { value: 'service.environment', text: 'service.environment' }, + { value: 'transaction.type', text: 'transaction.type' }, + { value: 'transaction.name', text: 'transaction.name' } + ]); + }); + it('removes item added in another filter', () => { + expect( + getSelectOptions( + [ + ['service.name', 'foo'], + ['', ''], + ['', ''], + ['', ''] + ], + '' + ) + ).toEqual([ + { value: 'DEFAULT', text: 'Select field...' }, + { value: 'service.environment', text: 'service.environment' }, + { value: 'transaction.type', text: 'transaction.type' }, + { value: 'transaction.name', text: 'transaction.name' } + ]); + }); + it('removes item added in another filter but keep the current selected', () => { + expect( + getSelectOptions( + [ + ['service.name', 'foo'], + ['transaction.name', 'bar'], + ['', ''], + ['', ''] + ], + 'transaction.name' + ) + ).toEqual([ + { value: 'DEFAULT', text: 'Select field...' }, + { value: 'service.environment', text: 'service.environment' }, + { value: 'transaction.type', text: 'transaction.type' }, + { value: 'transaction.name', text: 'transaction.name' } + ]); + }); + it('returns empty when all option were selected', () => { + expect( + getSelectOptions( + [ + ['service.name', 'foo'], + ['transaction.name', 'bar'], + ['service.environment', 'baz'], + ['transaction.type', 'qux'] + ], + '' + ) + ).toEqual([{ value: 'DEFAULT', text: 'Select field...' }]); + }); + }); + + describe('replaceTemplateVariables', () => { + const transaction = ({ + service: { name: 'foo' }, + trace: { id: '123' } + } as unknown) as Transaction; + + it('replaces template variables', () => { + expect( + replaceTemplateVariables( + 'https://elastic.co?service.name={{service.name}}&trace.id={{trace.id}}', + transaction + ) + ).toEqual({ + error: undefined, + formattedUrl: 'https://elastic.co?service.name=foo&trace.id=123' + }); + }); + + it('returns error when transaction is not defined', () => { + const expectedResult = { + error: + "We couldn't find a matching transaction document based on the defined filters.", + formattedUrl: 'https://elastic.co?service.name=&trace.id=' + }; + expect( + replaceTemplateVariables( + 'https://elastic.co?service.name={{service.name}}&trace.id={{trace.id}}' + ) + ).toEqual(expectedResult); + expect( + replaceTemplateVariables( + 'https://elastic.co?service.name={{service.name}}&trace.id={{trace.id}}', + ({} as unknown) as Transaction + ) + ).toEqual(expectedResult); + }); + + it('returns error when could not replace variables', () => { + expect( + replaceTemplateVariables( + 'https://elastic.co?service.name={{service.nam}}&trace.id={{trace.i}}', + transaction + ) + ).toEqual({ + error: + "We couldn't find a value match for {{service.nam}}, {{trace.i}} in the example transaction document.", + formattedUrl: 'https://elastic.co?service.name=&trace.id=' + }); + }); + + it('returns error when variable is invalid', () => { + expect( + replaceTemplateVariables( + 'https://elastic.co?service.name={{service.name}', + transaction + ) + ).toEqual({ + error: + "We couldn't find an example transaction document due to invalid variable(s) defined.", + formattedUrl: 'https://elastic.co?service.name={{service.name}' + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts index bb86a251594ab..df99c82c71b70 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts @@ -4,15 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import { isEmpty, pick } from 'lodash'; +import Mustache from 'mustache'; +import { isEmpty, pick, get } from 'lodash'; +import { Transaction } from '../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; import { FilterOptions, - filterOptions - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../../../../../../plugins/apm/server/routes/settings/custom_link'; + FILTER_OPTIONS +} from '../../../../../../../../../../plugins/apm/common/custom_link_filter_options'; import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; -export type Filters = Array<[keyof FilterOptions | '', string]>; +type FilterKey = keyof FilterOptions | ''; +type FilterValue = string; +export type FilterKeyValue = [FilterKey, FilterValue]; interface FilterSelectOption { value: 'DEFAULT' | keyof FilterOptions; @@ -33,9 +36,13 @@ interface FilterSelectOption { * results: [['service.name', 'opbeans-java'],['transaction.type', 'request']] * @param customLink */ -export const convertFiltersToArray = (customLink?: CustomLink): Filters => { +export const convertFiltersToArray = ( + customLink?: CustomLink +): FilterKeyValue[] => { if (customLink) { - const filters = Object.entries(pick(customLink, filterOptions)) as Filters; + const filters = Object.entries( + pick(customLink, FILTER_OPTIONS) + ) as FilterKeyValue[]; if (!isEmpty(filters)) { return filters; } @@ -54,9 +61,18 @@ export const convertFiltersToArray = (customLink?: CustomLink): Filters => { * } * @param filters */ -export const convertFiltersToObject = (filters: Filters) => { +export const convertFiltersToObject = (filters: FilterKeyValue[]) => { const convertedFilters = Object.fromEntries( - filters.filter(([key, value]) => !isEmpty(key) && !isEmpty(value)) + filters + .filter(([key, value]) => !isEmpty(key) && !isEmpty(value)) + .map(([key, value]) => [ + key, + // Splits the value by comma, removes whitespace from both ends and filters out empty values + value + .split(',') + .map(v => v.trim()) + .filter(v => v) + ]) ); if (!isEmpty(convertedFilters)) { return convertedFilters; @@ -71,9 +87,9 @@ export const DEFAULT_OPTION: FilterSelectOption = { ) }; -export const filterSelectOptions: FilterSelectOption[] = [ +export const FILTER_SELECT_OPTIONS: FilterSelectOption[] = [ DEFAULT_OPTION, - ...filterOptions.map(filter => ({ + ...FILTER_OPTIONS.map(filter => ({ value: filter as keyof FilterOptions, text: filter })) @@ -83,14 +99,76 @@ export const filterSelectOptions: FilterSelectOption[] = [ * Returns the options available, removing filters already added, but keeping the selected filter. * * @param filters - * @param idx + * @param selectedKey */ -export const getSelectOptions = (filters: Filters, idx: number) => { - return filterSelectOptions.filter(option => { - const indexUsedFilter = filters.findIndex( - filter => filter[0] === option.value +export const getSelectOptions = ( + filters: FilterKeyValue[], + selectedKey: FilterKey +) => { + return FILTER_SELECT_OPTIONS.filter( + ({ value }) => + !filters.some( + ([filterKey]) => filterKey === value && filterKey !== selectedKey + ) + ); +}; + +const getInvalidTemplateVariables = ( + template: string, + transaction: Transaction +) => { + return (Mustache.parse(template) as Array<[string, string]>) + .filter(([type]) => type === 'name') + .map(([, value]) => value) + .filter(templateVar => get(transaction, templateVar) == null); +}; + +const validateUrl = (url: string, transaction?: Transaction) => { + if (!transaction || isEmpty(transaction)) { + return i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.preview.transaction.notFound', + { + defaultMessage: + "We couldn't find a matching transaction document based on the defined filters." + } + ); + } + try { + const invalidVariables = getInvalidTemplateVariables(url, transaction); + if (!isEmpty(invalidVariables)) { + return i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.preview.contextVariable.noMatch', + { + defaultMessage: + "We couldn't find a value match for {variables} in the example transaction document.", + values: { + variables: invalidVariables + .map(variable => `{{${variable}}}`) + .join(', ') + } + } + ); + } + } catch (e) { + return i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.preview.contextVariable.invalid', + { + defaultMessage: + "We couldn't find an example transaction document due to invalid variable(s) defined." + } ); - // Filter out all items already added, besides the one selected in the current filter. - return indexUsedFilter === -1 || idx === indexUsedFilter; - }); + } +}; + +export const replaceTemplateVariables = ( + url: string, + transaction?: Transaction +) => { + const error = validateUrl(url, transaction); + try { + return { formattedUrl: Mustache.render(url, transaction), error }; + } catch (e) { + // errors will be caught on validateUrl function + return { formattedUrl: url, error }; + } }; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx index 88358c888160b..68755bad5f652 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx @@ -21,6 +21,8 @@ import { FlyoutFooter } from './FlyoutFooter'; import { LinkSection } from './LinkSection'; import { saveCustomLink } from './saveCustomLink'; import { convertFiltersToArray, convertFiltersToObject } from './helper'; +import { LinkPreview } from './LinkPreview'; +import { Documentation } from './Documentation'; interface Props { onClose: () => void; @@ -87,9 +89,17 @@ export const CustomLinkFlyout = ({ 'xpack.apm.settings.customizeUI.customLink.flyout.label', { defaultMessage: - 'Links will be available in the context of transaction details throughout the APM app. You can create an unlimited number of links and use the filter options to scope them to only appear for specific services. You can refer to dynamic variables by using any of the transaction metadata to fill in your URLs. TODO: Learn more about it in the docs.' + 'Links will be available in the context of transaction details throughout the APM app. You can create an unlimited number of links. You can refer to dynamic variables by using any of the transaction metadata to fill in your URLs. More information, including examples, are available in the' } - )} + )}{' '} +

@@ -105,6 +115,10 @@ export const CustomLinkFlyout = ({ + + + + { + let callApmApiSpy: Function; + beforeAll(() => { + callApmApiSpy = spyOn(apmApi, 'callApmApi').and.returnValue({}); + }); + afterAll(() => { + jest.resetAllMocks(); + }); + const goldLicense = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'gold', + status: 'active', + type: 'gold', + uid: '1' + } + }); describe('empty prompt', () => { beforeAll(() => { spyOn(hooks, 'useFetcher').and.returnValue({ @@ -44,14 +64,20 @@ describe('CustomLink', () => { jest.clearAllMocks(); }); it('shows when no link is available', () => { - const component = render(); + const component = render( + + + + ); expectTextsInDocument(component, ['No links found.']); }); it('opens flyout when click to create new link', () => { const { queryByText, getByText } = render( - - - + + + + + ); expect(queryByText('Create link')).not.toBeInTheDocument(); act(() => { @@ -75,9 +101,11 @@ describe('CustomLink', () => { it('shows a table with all custom link', () => { const component = render( - - - + + + + + ); expectTextsInDocument(component, [ 'label 1', @@ -89,9 +117,11 @@ describe('CustomLink', () => { it('checks if create custom link button is available and working', () => { const { queryByText, getByText } = render( - - - + + + + + ); expect(queryByText('Create link')).not.toBeInTheDocument(); act(() => { @@ -103,10 +133,8 @@ describe('CustomLink', () => { describe('Flyout', () => { const refetch = jest.fn(); - let callApmApiSpy: Function; let saveCustomLinkSpy: Function; beforeAll(() => { - callApmApiSpy = spyOn(apmApi, 'callApmApi'); saveCustomLinkSpy = spyOn(saveCustomLink, 'saveCustomLink'); spyOn(hooks, 'useFetcher').and.returnValue({ data, @@ -120,9 +148,11 @@ describe('CustomLink', () => { const openFlyout = () => { const component = render( - - - + + + + + ); expect(component.queryByText('Create link')).not.toBeInTheDocument(); act(() => { @@ -134,13 +164,13 @@ describe('CustomLink', () => { it('creates a custom link', async () => { const component = openFlyout(); - const labelInput = component.getByLabelText('label'); + const labelInput = component.getByTestId('label'); act(() => { fireEvent.change(labelInput, { target: { value: 'foo' } }); }); - const urlInput = component.getByLabelText('url'); + const urlInput = component.getByTestId('url'); act(() => { fireEvent.change(urlInput, { target: { value: 'bar' } @@ -154,9 +184,11 @@ describe('CustomLink', () => { it('deletes a custom link', async () => { const component = render( - - - + + + + + ); expect(component.queryByText('Create link')).not.toBeInTheDocument(); const editButtons = component.getAllByLabelText('Edit'); @@ -204,9 +236,7 @@ describe('CustomLink', () => { if (addNewFilter) { addFilterField(component, 1); } - const field = component.getByLabelText( - fieldName - ) as HTMLSelectElement; + const field = component.getByTestId(fieldName) as HTMLSelectElement; const optionsAvailable = Object.values(field) .map(option => (option as HTMLOptionElement).text) .filter(option => option); @@ -248,4 +278,93 @@ describe('CustomLink', () => { }); }); }); + + describe('invalid license', () => { + beforeAll(() => { + spyOn(hooks, 'useFetcher').and.returnValue({ + data: [], + status: 'success' + }); + }); + it('shows license prompt when user has a basic license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'basic', + status: 'active', + type: 'basic', + uid: '1' + } + }); + const component = render( + + + + + + ); + expectTextsInDocument(component, ['Start free 30-day trial']); + }); + it('shows license prompt when user has an invalid gold license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'gold', + status: 'invalid', + type: 'gold', + uid: '1' + } + }); + const component = render( + + + + + + ); + expectTextsInDocument(component, ['Start free 30-day trial']); + }); + it('shows license prompt when user has an invalid trial license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'trial', + status: 'invalid', + type: 'trial', + uid: '1' + } + }); + const component = render( + + + + + + ); + expectTextsInDocument(component, ['Start free 30-day trial']); + }); + it('doesnt show license prompt when user has a trial license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'trial', + status: 'active', + type: 'trial', + uid: '1' + } + }); + const component = render( + + + + + + ); + expectTextsNotInDocument(component, ['Start free 30-day trial']); + }); + }); }); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx index bc1882c8c2785..a4985d4410699 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx @@ -7,6 +7,8 @@ import { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEmpty } from 'lodash'; import React, { useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useLicense } from '../../../../../hooks/useLicense'; import { CustomLink } from '../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; import { useFetcher, FETCH_STATUS } from '../../../../../hooks/useFetcher'; import { CustomLinkFlyout } from './CustomLinkFlyout'; @@ -14,8 +16,12 @@ import { CustomLinkTable } from './CustomLinkTable'; import { EmptyPrompt } from './EmptyPrompt'; import { Title } from './Title'; import { CreateCustomLinkButton } from './CreateCustomLinkButton'; +import { LicensePrompt } from '../../../../shared/LicensePrompt'; export const CustomLinkOverview = () => { + const license = useLicense(); + const hasValidLicense = license?.isActive && license?.hasAtLeast('gold'); + const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); const [customLinkSelected, setCustomLinkSelected] = useState< CustomLink | undefined @@ -65,7 +71,7 @@ export const CustomLinkOverview = () => { </EuiFlexItem> - {!showEmptyPrompt && ( + {hasValidLicense && !showEmptyPrompt && ( <EuiFlexItem> <EuiFlexGroup alignItems="center" justifyContent="flexEnd"> <EuiFlexItem grow={false}> @@ -77,13 +83,24 @@ export const CustomLinkOverview = () => { </EuiFlexGroup> <EuiSpacer size="m" /> - - {showEmptyPrompt ? ( - <EmptyPrompt onCreateCustomLinkClick={onCreateCustomLinkClick} /> + {hasValidLicense ? ( + showEmptyPrompt ? ( + <EmptyPrompt onCreateCustomLinkClick={onCreateCustomLinkClick} /> + ) : ( + <CustomLinkTable + items={customLinks} + onCustomLinkSelected={setCustomLinkSelected} + /> + ) ) : ( - <CustomLinkTable - items={customLinks} - onCustomLinkSelected={setCustomLinkSelected} + <LicensePrompt + text={i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.license.text', + { + defaultMessage: + "To create custom links, you must be subscribed to an Elastic Gold license or above. With it, you'll have the ability to create custom links to improve your workflow when analyzing your services." + } + )} /> )} </EuiPanel> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.stories.tsx b/x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx similarity index 79% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.stories.tsx rename to x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx index 80281c1a0a8fc..010bba7677f00 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.stories.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx @@ -6,13 +6,13 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; -import { PlatinumLicensePrompt } from './PlatinumLicensePrompt'; import { ApmPluginContext, ApmPluginContextValue } from '../../../context/ApmPluginContext'; +import { LicensePrompt } from '.'; -storiesOf('app/ServiceMap/PlatinumLicensePrompt', module).add( +storiesOf('app/LicensePrompt', module).add( 'example', () => { const contextMock = ({ @@ -21,7 +21,7 @@ storiesOf('app/ServiceMap/PlatinumLicensePrompt', module).add( return ( <ApmPluginContext.Provider value={contextMock}> - <PlatinumLicensePrompt /> + <LicensePrompt text="To create Feature name, you must be subscribed to an Elastic X license or above." /> </ApmPluginContext.Provider> ); }, diff --git a/x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/index.tsx new file mode 100644 index 0000000000000..d2afefb83a568 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/index.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButton, EuiEmptyPrompt, EuiPanel } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { useKibanaUrl } from '../../../hooks/useKibanaUrl'; + +interface Props { + text: string; + showBetaBadge?: boolean; +} + +export const LicensePrompt = ({ text, showBetaBadge = false }: Props) => { + const licensePageUrl = useKibanaUrl( + '/app/kibana', + '/management/elasticsearch/license_management/home' + ); + + const renderLicenseBody = ( + <EuiEmptyPrompt + iconType="iInCircle" + iconColor="subdued" + title={ + <h2> + {i18n.translate('xpack.apm.license.title', { + defaultMessage: 'Start free 30-day trial' + })} + </h2> + } + body={<p>{text}</p>} + actions={ + <EuiButton fill={true} href={licensePageUrl}> + {i18n.translate('xpack.apm.license.button', { + defaultMessage: 'Start trial' + })} + </EuiButton> + } + /> + ); + + const renderWithBetaBadge = ( + <EuiPanel + betaBadgeLabel={i18n.translate('xpack.apm.license.betaBadge', { + defaultMessage: 'Beta' + })} + betaBadgeTooltipContent={i18n.translate( + 'xpack.apm.license.betaTooltipMessage', + { + defaultMessage: + 'This feature is currently in beta. If you encounter any bugs or have feedback, please open an issue or visit our discussion forum.' + } + )} + > + {renderLicenseBody} + </EuiPanel> + ); + + return <>{showBetaBadge ? renderWithBetaBadge : renderLicenseBody}</>; +}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx index 0e0c318ad3299..9fcab049e224f 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx @@ -9,7 +9,7 @@ import { EuiLink, EuiLinkAnchorProps } from '@elastic/eui'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; // union type constisting of valid guide sections that we link to -type DocsSection = '/apm/get-started' | '/x-pack' | '/apm/server'; +type DocsSection = '/apm/get-started' | '/x-pack' | '/apm/server' | '/kibana'; interface Props extends EuiLinkAnchorProps { section: DocsSection; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/LoadingStatePrompt.tsx b/x-pack/legacy/plugins/apm/public/components/shared/LoadingStatePrompt.tsx index e1cf07c03dee9..8a87de976f5ed 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/LoadingStatePrompt.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/LoadingStatePrompt.tsx @@ -11,7 +11,7 @@ export function LoadingStatePrompt() { return ( <EuiFlexGroup justifyContent="spaceAround"> <EuiFlexItem grow={false}> - <EuiLoadingSpinner size="l" /> + <EuiLoadingSpinner size="l" data-test-subj="loading-spinner" /> </EuiFlexItem> </EuiFlexGroup> ); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.test.tsx new file mode 100644 index 0000000000000..99789ca2ecdf5 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { CustomLink } from '../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { CustomLinkPopover } from './CustomLinkPopover'; +import { expectTextsInDocument } from '../../../../utils/testHelpers'; + +describe('CustomLinkPopover', () => { + const customLinks = [ + { id: '1', label: 'foo', url: 'http://elastic.co' }, + { + id: '2', + label: 'bar', + url: 'http://elastic.co?service.name={{service.name}}' + } + ] as CustomLink[]; + const transaction = ({ + service: { name: 'foo.bar' } + } as unknown) as Transaction; + it('renders popover', () => { + const component = render( + <CustomLinkPopover + customLinks={customLinks} + transaction={transaction} + onCreateCustomLinkClick={jest.fn()} + onClose={jest.fn()} + /> + ); + expectTextsInDocument(component, ['CUSTOM LINKS', 'Create', 'foo', 'bar']); + }); + + it('closes popover', () => { + const handleCloseMock = jest.fn(); + const { getByText } = render( + <CustomLinkPopover + customLinks={customLinks} + transaction={transaction} + onCreateCustomLinkClick={jest.fn()} + onClose={handleCloseMock} + /> + ); + expect(handleCloseMock).not.toHaveBeenCalled(); + act(() => { + fireEvent.click(getByText('CUSTOM LINKS')); + }); + expect(handleCloseMock).toHaveBeenCalled(); + }); + + it('opens flyout to create new custom link', () => { + const handleCreateCustomLinkClickMock = jest.fn(); + const { getByText } = render( + <CustomLinkPopover + customLinks={customLinks} + transaction={transaction} + onCreateCustomLinkClick={handleCreateCustomLinkClickMock} + onClose={jest.fn()} + /> + ); + expect(handleCreateCustomLinkClickMock).not.toHaveBeenCalled(); + act(() => { + fireEvent.click(getByText('Create')); + }); + expect(handleCreateCustomLinkClickMock).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx new file mode 100644 index 0000000000000..ee4aa25606a0c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { + EuiPopoverTitle, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { CustomLink } from '../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { CustomLinkSection } from './CustomLinkSection'; +import { ManageCustomLink } from './ManageCustomLink'; +import { px } from '../../../../style/variables'; + +const ScrollableContainer = styled.div` + max-height: ${px(535)}; + overflow: scroll; +`; + +export const CustomLinkPopover = ({ + customLinks, + onCreateCustomLinkClick, + onClose, + transaction +}: { + customLinks: CustomLink[]; + onCreateCustomLinkClick: () => void; + onClose: () => void; + transaction: Transaction; +}) => { + return ( + <> + <EuiPopoverTitle> + <EuiFlexGroup> + <EuiFlexItem style={{ alignItems: 'flex-start' }}> + <EuiButtonEmpty + color="text" + size="xs" + onClick={onClose} + iconType="arrowLeft" + style={{ fontWeight: 'bold' }} + flush="left" + > + {i18n.translate( + 'xpack.apm.transactionActionMenu.customLink.popover.title', + { + defaultMessage: 'CUSTOM LINKS' + } + )} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem> + <ManageCustomLink + onCreateCustomLinkClick={onCreateCustomLinkClick} + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPopoverTitle> + <ScrollableContainer> + <CustomLinkSection + customLinks={customLinks} + transaction={transaction} + /> + </ScrollableContainer> + </> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.test.tsx new file mode 100644 index 0000000000000..4e52c302c6025 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { CustomLink } from '../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { CustomLinkSection } from './CustomLinkSection'; +import { + expectTextsInDocument, + expectTextsNotInDocument +} from '../../../../utils/testHelpers'; +import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; + +describe('CustomLinkSection', () => { + const customLinks = [ + { id: '1', label: 'foo', url: 'http://elastic.co' }, + { + id: '2', + label: 'bar', + url: 'http://elastic.co?service.name={{service.name}}' + } + ] as CustomLink[]; + const transaction = ({ + service: { name: 'foo.bar' } + } as unknown) as Transaction; + it('shows links', () => { + const component = render( + <CustomLinkSection customLinks={customLinks} transaction={transaction} /> + ); + expectTextsInDocument(component, ['foo', 'bar']); + }); + + it('doesnt show any links', () => { + const component = render( + <CustomLinkSection customLinks={[]} transaction={transaction} /> + ); + expectTextsNotInDocument(component, ['foo', 'bar']); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx new file mode 100644 index 0000000000000..601405dda6ece --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import Mustache from 'mustache'; +import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { CustomLink } from '../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { + SectionLinks, + SectionLink +} from '../../../../../../../../plugins/observability/public'; + +export const CustomLinkSection = ({ + customLinks, + transaction +}: { + customLinks: CustomLink[]; + transaction: Transaction; +}) => ( + <SectionLinks> + {customLinks.map(link => { + let href = link.url; + try { + href = Mustache.render(link.url, transaction); + } catch (e) { + // ignores any error that happens + } + return ( + <SectionLink + key={link.id} + label={link.label} + href={href} + target="_blank" + /> + ); + })} + </SectionLinks> +); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.test.tsx new file mode 100644 index 0000000000000..9e7df53b0882f --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.test.tsx @@ -0,0 +1,50 @@ +/* + * 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 from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { ManageCustomLink } from './ManageCustomLink'; +import { + expectTextsInDocument, + expectTextsNotInDocument +} from '../../../../utils/testHelpers'; + +describe('ManageCustomLink', () => { + it('renders with create button', () => { + const component = render( + <ManageCustomLink onCreateCustomLinkClick={jest.fn()} /> + ); + expect( + component.getByLabelText('Custom links settings page') + ).toBeInTheDocument(); + expectTextsInDocument(component, ['Create']); + }); + it('renders without create button', () => { + const component = render( + <ManageCustomLink + onCreateCustomLinkClick={jest.fn()} + showCreateCustomLinkButton={false} + /> + ); + expect( + component.getByLabelText('Custom links settings page') + ).toBeInTheDocument(); + expectTextsNotInDocument(component, ['Create']); + }); + it('opens flyout to create new custom link', () => { + const handleCreateCustomLinkClickMock = jest.fn(); + const { getByText } = render( + <ManageCustomLink + onCreateCustomLinkClick={handleCreateCustomLinkClickMock} + /> + ); + expect(handleCreateCustomLinkClickMock).not.toHaveBeenCalled(); + act(() => { + fireEvent.click(getByText('Create')); + }); + expect(handleCreateCustomLinkClickMock).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx new file mode 100644 index 0000000000000..fa9f8b2f07c53 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + EuiButtonEmpty, + EuiIcon +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { APMLink } from '../../Links/apm/APMLink'; + +export const ManageCustomLink = ({ + onCreateCustomLinkClick, + showCreateCustomLinkButton = true +}: { + onCreateCustomLinkClick: () => void; + showCreateCustomLinkButton?: boolean; +}) => ( + <EuiFlexGroup> + <EuiFlexItem> + <EuiFlexGroup justifyContent="flexEnd" gutterSize="none"> + <EuiFlexItem grow={false} style={{ justifyContent: 'center' }}> + <EuiToolTip + position="top" + content={i18n.translate('xpack.apm.customLink.buttom.manage', { + defaultMessage: 'Manage custom links' + })} + > + <APMLink path={`/settings/customize-ui`}> + <EuiIcon + type="gear" + color="text" + aria-label="Custom links settings page" + /> + </APMLink> + </EuiToolTip> + </EuiFlexItem> + {showCreateCustomLinkButton && ( + <EuiFlexItem grow={false}> + <EuiButtonEmpty + iconType="plusInCircle" + size="xs" + onClick={onCreateCustomLinkClick} + > + {i18n.translate('xpack.apm.customLink.buttom.create.title', { + defaultMessage: 'Create' + })} + </EuiButtonEmpty> + </EuiFlexItem> + )} + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> +); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.test.tsx new file mode 100644 index 0000000000000..ba9c7eee8792b --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.test.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { CustomLink } from '.'; +import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { FETCH_STATUS } from '../../../../hooks/useFetcher'; +import { + expectTextsInDocument, + expectTextsNotInDocument +} from '../../../../utils/testHelpers'; +import { CustomLink as CustomLinkType } from '../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; + +describe('Custom links', () => { + it('shows empty message when no custom link is available', () => { + const component = render( + <CustomLink + customLinks={[]} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={jest.fn()} + status={FETCH_STATUS.SUCCESS} + /> + ); + + expectTextsInDocument(component, [ + 'No custom links found. Set up your own custom links i.e. a link to a specific Dashboard or external link.' + ]); + expectTextsNotInDocument(component, ['Create']); + }); + + it('shows loading while custom links are fetched', () => { + const { getByTestId } = render( + <CustomLink + customLinks={[]} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={jest.fn()} + status={FETCH_STATUS.LOADING} + /> + ); + expect(getByTestId('loading-spinner')).toBeInTheDocument(); + }); + + it('shows first 3 custom links available', () => { + const customLinks = [ + { id: '1', label: 'foo', url: 'foo' }, + { id: '2', label: 'bar', url: 'bar' }, + { id: '3', label: 'baz', url: 'baz' }, + { id: '4', label: 'qux', url: 'qux' } + ] as CustomLinkType[]; + const component = render( + <CustomLink + customLinks={customLinks} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={jest.fn()} + status={FETCH_STATUS.SUCCESS} + /> + ); + expectTextsInDocument(component, ['foo', 'bar', 'baz']); + expectTextsNotInDocument(component, ['qux']); + }); + + it('clicks on See more button', () => { + const customLinks = [ + { id: '1', label: 'foo', url: 'foo' }, + { id: '2', label: 'bar', url: 'bar' }, + { id: '3', label: 'baz', url: 'baz' }, + { id: '4', label: 'qux', url: 'qux' } + ] as CustomLinkType[]; + const onSeeMoreClickMock = jest.fn(); + const component = render( + <CustomLink + customLinks={customLinks} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={onSeeMoreClickMock} + status={FETCH_STATUS.SUCCESS} + /> + ); + expect(onSeeMoreClickMock).not.toHaveBeenCalled(); + act(() => { + fireEvent.click(component.getByText('See more')); + }); + expect(onSeeMoreClickMock).toHaveBeenCalled(); + }); + + describe('create custom link buttons', () => { + it('shows create button below empty message', () => { + const component = render( + <CustomLink + customLinks={[]} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={jest.fn()} + status={FETCH_STATUS.SUCCESS} + /> + ); + + expectTextsInDocument(component, ['Create custom link']); + expectTextsNotInDocument(component, ['Create']); + }); + it('shows create button besides the title', () => { + const customLinks = [ + { id: '1', label: 'foo', url: 'foo' }, + { id: '2', label: 'bar', url: 'bar' }, + { id: '3', label: 'baz', url: 'baz' }, + { id: '4', label: 'qux', url: 'qux' } + ] as CustomLinkType[]; + const component = render( + <CustomLink + customLinks={customLinks} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={jest.fn()} + status={FETCH_STATUS.SUCCESS} + /> + ); + expectTextsInDocument(component, ['Create']); + expectTextsNotInDocument(component, ['Create custom link']); + }); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx new file mode 100644 index 0000000000000..9280f8e71bf9e --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { + EuiText, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiButtonEmpty +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import { isEmpty } from 'lodash'; +import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { CustomLink as CustomLinkType } from '../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { + ActionMenuDivider, + SectionSubtitle +} from '../../../../../../../../plugins/observability/public'; +import { CustomLinkSection } from './CustomLinkSection'; +import { ManageCustomLink } from './ManageCustomLink'; +import { FETCH_STATUS } from '../../../../hooks/useFetcher'; +import { LoadingStatePrompt } from '../../LoadingStatePrompt'; +import { px } from '../../../../style/variables'; + +const SeeMoreButton = styled.button<{ show: boolean }>` + display: ${props => (props.show ? 'flex' : 'none')}; + align-items: center; + width: 100%; + justify-content: space-between; + &:hover { + text-decoration: underline; + } +`; + +export const CustomLink = ({ + customLinks, + status, + onCreateCustomLinkClick, + onSeeMoreClick, + transaction +}: { + customLinks: CustomLinkType[]; + status: FETCH_STATUS; + onCreateCustomLinkClick: () => void; + onSeeMoreClick: () => void; + transaction: Transaction; +}) => { + const renderEmptyPrompt = ( + <> + <EuiText size="xs" grow={false} style={{ width: px(300) }}> + {i18n.translate('xpack.apm.customLink.empty', { + defaultMessage: + 'No custom links found. Set up your own custom links i.e. a link to a specific Dashboard or external link.' + })} + </EuiText> + <EuiSpacer size="s" /> + <EuiButtonEmpty + iconType="plusInCircle" + size="xs" + onClick={onCreateCustomLinkClick} + > + {i18n.translate('xpack.apm.customLink.buttom.create', { + defaultMessage: 'Create custom link' + })} + </EuiButtonEmpty> + </> + ); + + const renderCustomLinkBottomSection = isEmpty(customLinks) ? ( + renderEmptyPrompt + ) : ( + <SeeMoreButton onClick={onSeeMoreClick} show={customLinks.length > 3}> + <EuiText size="s"> + {i18n.translate('xpack.apm.transactionActionMenu.customLink.seeMore', { + defaultMessage: 'See more' + })} + </EuiText> + <EuiIcon type="arrowRight" /> + </SeeMoreButton> + ); + + return ( + <> + <ActionMenuDivider /> + <EuiFlexGroup> + <EuiFlexItem style={{ justifyContent: 'center' }}> + <EuiText size={'s'} grow={false}> + <h5> + {i18n.translate( + 'xpack.apm.transactionActionMenu.customLink.section', + { + defaultMessage: 'Custom Links' + } + )} + </h5> + </EuiText> + </EuiFlexItem> + <EuiFlexItem> + <ManageCustomLink + onCreateCustomLinkClick={onCreateCustomLinkClick} + showCreateCustomLinkButton={!!customLinks.length} + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer size="s" /> + <SectionSubtitle> + {i18n.translate('xpack.apm.transactionActionMenu.customLink.subtitle', { + defaultMessage: 'Links will open in a new window.' + })} + </SectionSubtitle> + <CustomLinkSection + customLinks={customLinks.slice(0, 3)} + transaction={transaction} + /> + <EuiSpacer size="s" /> + {status === FETCH_STATUS.LOADING ? ( + <LoadingStatePrompt /> + ) : ( + renderCustomLinkBottomSection + )} + </> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx index dd022626807d0..e3c412f40ba3a 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx @@ -6,7 +6,10 @@ import { EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { FunctionComponent, useState } from 'react'; +import React, { FunctionComponent, useMemo, useState } from 'react'; +import { FilterOptions } from '../../../../../../../plugins/apm/common/custom_link_filter_options'; +import { CustomLink as CustomLinkType } from '../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { Transaction } from '../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; import { ActionMenu, ActionMenuDivider, @@ -16,11 +19,16 @@ import { SectionSubtitle, SectionTitle } from '../../../../../../../plugins/observability/public'; -import { Transaction } from '../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; +import { useFetcher } from '../../../hooks/useFetcher'; import { useLocation } from '../../../hooks/useLocation'; import { useUrlParams } from '../../../hooks/useUrlParams'; +import { CustomLinkFlyout } from '../../app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout'; +import { CustomLink } from './CustomLink'; +import { CustomLinkPopover } from './CustomLink/CustomLinkPopover'; import { getSections } from './sections'; +import { useLicense } from '../../../hooks/useLicense'; +import { px } from '../../../style/variables'; interface Props { readonly transaction: Transaction; @@ -37,11 +45,36 @@ const ActionMenuButton = ({ onClick }: { onClick: () => void }) => ( export const TransactionActionMenu: FunctionComponent<Props> = ({ transaction }: Props) => { + const license = useLicense(); + const hasValidLicense = license?.isActive && license?.hasAtLeast('gold'); + const { core } = useApmPluginContext(); const location = useLocation(); const { urlParams } = useUrlParams(); - const [isOpen, setIsOpen] = useState(false); + const [isActionPopoverOpen, setIsActionPopoverOpen] = useState(false); + const [isCustomLinksPopoverOpen, setIsCustomLinksPopoverOpen] = useState( + false + ); + const [isCustomLinkFlyoutOpen, setIsCustomLinkFlyoutOpen] = useState(false); + + const filters: FilterOptions = useMemo( + () => ({ + 'service.name': transaction?.service.name, + 'service.environment': transaction?.service.environment, + 'transaction.name': transaction?.transaction.name, + 'transaction.type': transaction?.transaction.type + }), + [transaction] + ); + const { data: customLinks = [], status, refetch } = useFetcher( + callApmApi => + callApmApi({ + pathname: '/api/apm/settings/custom_links', + params: { query: filters } + }), + [filters] + ); const sections = getSections({ transaction, @@ -50,39 +83,92 @@ export const TransactionActionMenu: FunctionComponent<Props> = ({ urlParams }); + const toggleCustomLinkFlyout = () => { + setIsCustomLinkFlyoutOpen(isOpen => !isOpen); + }; + + const toggleCustomLinkPopover = () => { + setIsCustomLinksPopoverOpen(isOpen => !isOpen); + }; + return ( - <ActionMenu - id="transactionActionMenu" - closePopover={() => setIsOpen(false)} - isOpen={isOpen} - anchorPosition="downRight" - button={<ActionMenuButton onClick={() => setIsOpen(!isOpen)} />} - > - {sections.map((section, idx) => { - const isLastSection = idx !== sections.length - 1; - return ( - <div key={idx}> - {section.map(item => ( - <Section key={item.key}> - {item.title && <SectionTitle>{item.title}</SectionTitle>} - {item.subtitle && ( - <SectionSubtitle>{item.subtitle}</SectionSubtitle> - )} - <SectionLinks> - {item.actions.map(action => ( - <SectionLink - key={action.key} - label={action.label} - href={action.href} - /> - ))} - </SectionLinks> - </Section> - ))} - {isLastSection && <ActionMenuDivider />} - </div> - ); - })} - </ActionMenu> + <> + {isCustomLinkFlyoutOpen && ( + <CustomLinkFlyout + customLinkSelected={{ ...filters } as CustomLinkType} + onClose={toggleCustomLinkFlyout} + onSave={() => { + toggleCustomLinkFlyout(); + refetch(); + }} + onDelete={() => { + toggleCustomLinkFlyout(); + refetch(); + }} + /> + )} + <ActionMenu + id="transactionActionMenu" + closePopover={() => { + setIsActionPopoverOpen(false); + setIsCustomLinksPopoverOpen(false); + }} + isOpen={isActionPopoverOpen} + anchorPosition="downRight" + button={ + <ActionMenuButton onClick={() => setIsActionPopoverOpen(true)} /> + } + > + <div style={{ maxHeight: px(600) }}> + {isCustomLinksPopoverOpen ? ( + <CustomLinkPopover + customLinks={customLinks.slice(3, customLinks.length)} + onCreateCustomLinkClick={toggleCustomLinkFlyout} + onClose={toggleCustomLinkPopover} + transaction={transaction} + /> + ) : ( + <> + {sections.map((section, idx) => { + const isLastSection = idx !== sections.length - 1; + return ( + <div key={idx}> + {section.map(item => ( + <Section key={item.key}> + {item.title && ( + <SectionTitle>{item.title}</SectionTitle> + )} + {item.subtitle && ( + <SectionSubtitle>{item.subtitle}</SectionSubtitle> + )} + <SectionLinks> + {item.actions.map(action => ( + <SectionLink + key={action.key} + label={action.label} + href={action.href} + /> + ))} + </SectionLinks> + </Section> + ))} + {isLastSection && <ActionMenuDivider />} + </div> + ); + })} + {hasValidLicense && ( + <CustomLink + customLinks={customLinks} + status={status} + onCreateCustomLinkClick={toggleCustomLinkFlyout} + onSeeMoreClick={toggleCustomLinkPopover} + transaction={transaction} + /> + )} + </> + )} + </div> + </ActionMenu> + </> ); }; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx index ac3616e8c134c..9094662e34914 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx @@ -5,11 +5,18 @@ */ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, act } from '@testing-library/react'; import { TransactionActionMenu } from '../TransactionActionMenu'; import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; import * as Transactions from './mockData'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; +import { + MockApmPluginContextWrapper, + expectTextsNotInDocument, + expectTextsInDocument +} from '../../../../utils/testHelpers'; +import * as hooks from '../../../../hooks/useFetcher'; +import { LicenseContext } from '../../../../context/LicenseContext'; +import { License } from '../../../../../../../../plugins/licensing/common/license'; const renderTransaction = async (transaction: Record<string, any>) => { const rendered = render( @@ -23,6 +30,15 @@ const renderTransaction = async (transaction: Record<string, any>) => { }; describe('TransactionActionMenu component', () => { + beforeAll(() => { + spyOn(hooks, 'useFetcher').and.returnValue({ + data: [], + status: 'success' + }); + }); + afterAll(() => { + jest.clearAllMocks(); + }); it('should always render the discover link', async () => { const { queryByText } = await renderTransaction( Transactions.transactionWithMinimalData @@ -124,4 +140,115 @@ describe('TransactionActionMenu component', () => { expect(container).toMatchSnapshot(); }); + + describe('Custom links', () => { + it('doesnt show custom links when license is not valid', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'gold', + status: 'invalid', + type: 'gold', + uid: '1' + } + }); + const component = render( + <LicenseContext.Provider value={license}> + <MockApmPluginContextWrapper> + <TransactionActionMenu + transaction={ + Transactions.transactionWithMinimalData as Transaction + } + /> + </MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); + act(() => { + fireEvent.click(component.getByText('Actions')); + }); + expectTextsNotInDocument(component, ['Custom Links']); + }); + it('doesnt show custom links when basic license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'basic', + status: 'active', + type: 'basic', + uid: '1' + } + }); + const component = render( + <LicenseContext.Provider value={license}> + <MockApmPluginContextWrapper> + <TransactionActionMenu + transaction={ + Transactions.transactionWithMinimalData as Transaction + } + /> + </MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); + act(() => { + fireEvent.click(component.getByText('Actions')); + }); + expectTextsNotInDocument(component, ['Custom Links']); + }); + it('shows custom links when trial license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'trial', + status: 'active', + type: 'trial', + uid: '1' + } + }); + const component = render( + <LicenseContext.Provider value={license}> + <MockApmPluginContextWrapper> + <TransactionActionMenu + transaction={ + Transactions.transactionWithMinimalData as Transaction + } + /> + </MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); + act(() => { + fireEvent.click(component.getByText('Actions')); + }); + expectTextsInDocument(component, ['Custom Links']); + }); + it('shows custom links when gold license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'gold', + status: 'active', + type: 'gold', + uid: '1' + } + }); + const component = render( + <LicenseContext.Provider value={license}> + <MockApmPluginContextWrapper> + <TransactionActionMenu + transaction={ + Transactions.transactionWithMinimalData as Transaction + } + /> + </MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); + act(() => { + fireEvent.click(component.getByText('Actions')); + }); + expectTextsInDocument(component, ['Custom Links']); + }); + }); }); 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: { 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' ? ( <EuiContextMenuItem data-test-subj="cases-bulk-close-button" + disabled={selectedCaseIds.length === 0} key={i18n.BULK_ACTION_CLOSE_SELECTED} - icon="magnet" + icon="folderCheck" onClick={() => { closePopover(); updateCaseStatus('closed'); @@ -39,8 +40,9 @@ export const getBulkItems = ({ ) : ( <EuiContextMenuItem data-test-subj="cases-bulk-open-button" + disabled={selectedCaseIds.length === 0} key={i18n.BULK_ACTION_OPEN_SELECTED} - icon="magnet" + icon="folderExclamation" onClick={() => { 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<CaseProps>(({ 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<CaseProps>(({ 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), }, ]; 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<HTMLInputElement>; type EventArg = Event | React.MouseEvent<HTMLButtonElement>; -export const AnomalyThresholdSlider: React.FC<AnomalyThresholdSliderProps> = ({ field }) => { +export const AnomalyThresholdSlider: React.FC<AnomalyThresholdSliderProps> = ({ + describedByIds = [], + field, +}) => { const threshold = field.value as number; const onThresholdChange = useCallback( (event: EventArg) => { @@ -26,7 +30,12 @@ export const AnomalyThresholdSlider: React.FC<AnomalyThresholdSliderProps> = ({ ); return ( - <EuiFormRow label={field.label} fullWidth> + <EuiFormRow + fullWidth + label={field.label} + data-test-subj="anomalyThresholdSlider" + describedByIds={describedByIds} + > <EuiFlexGrid columns={2}> <EuiFlexItem> <EuiRange diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx index 627fa21cc2f61..bc32162c2660b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx @@ -20,10 +20,11 @@ const JobDisplay = ({ title, description }: { title: string; description: string ); interface MlJobSelectProps { + describedByIds: string[]; field: FieldHook; } -export const MlJobSelect: React.FC<MlJobSelectProps> = ({ field }) => { +export const MlJobSelect: React.FC<MlJobSelectProps> = ({ 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<MlJobSelectProps> = ({ field }) => { })); return ( - <EuiFormRow fullWidth label={field.label} isInvalid={isInvalid} error={errorMessage}> + <EuiFormRow + fullWidth + label={field.label} + isInvalid={isInvalid} + error={errorMessage} + data-test-subj="mlJobSelect" + describedByIds={describedByIds} + > <EuiFlexGroup> <EuiFlexItem> <EuiSuperSelect diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx index 229ccde54ecab..219b3d6dc4d58 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx @@ -5,19 +5,58 @@ */ import React, { useCallback } from 'react'; -import { EuiCard, EuiFlexGrid, EuiFlexItem, EuiIcon, EuiFormRow } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiCard, + EuiFlexGrid, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiLink, + EuiText, +} from '@elastic/eui'; import { FieldHook } from '../../../../../shared_imports'; import { RuleType } from '../../../../../containers/detection_engine/rules/types'; import * as i18n from './translations'; import { isMlRule } from '../../helpers'; +const MlCardDescription = ({ hasValidLicense = false }: { hasValidLicense?: boolean }) => ( + <EuiText size="s"> + {hasValidLicense ? ( + i18n.ML_TYPE_DESCRIPTION + ) : ( + <FormattedMessage + id="xpack.siem.detectionEngine.createRule.stepDefineRule.ruleTypeField.mlTypeDisabledDescription" + defaultMessage="Access to ML requires a {subscriptionsLink}." + values={{ + subscriptionsLink: ( + <EuiLink href="https://www.elastic.co/subscriptions" target="_blank"> + <FormattedMessage + id="xpack.siem.components.stepDefineRule.ruleTypeField.subscriptionsLink" + defaultMessage="Platinum subscription" + /> + </EuiLink> + ), + }} + /> + )} + </EuiText> +); + interface SelectRuleTypeProps { + describedByIds?: string[]; field: FieldHook; - isReadOnly: boolean; + hasValidLicense?: boolean; + isReadOnly?: boolean; } -export const SelectRuleType: React.FC<SelectRuleTypeProps> = ({ field, isReadOnly = false }) => { +export const SelectRuleType: React.FC<SelectRuleTypeProps> = ({ + 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<SelectRuleTypeProps> = ({ field, isReadOnl ); const setMl = useCallback(() => setType('machine_learning'), [setType]); const setQuery = useCallback(() => setType('query'), [setType]); - const license = true; // TODO + const mlCardDisabled = isReadOnly || !hasValidLicense; return ( - <EuiFormRow label={field.label} fullWidth> + <EuiFormRow + fullWidth + data-test-subj="selectRuleType" + describedByIds={describedByIds} + label={field.label} + > <EuiFlexGrid columns={4}> <EuiFlexItem> <EuiCard @@ -47,11 +91,11 @@ export const SelectRuleType: React.FC<SelectRuleTypeProps> = ({ field, isReadOnl <EuiFlexItem> <EuiCard title={i18n.ML_TYPE_TITLE} - description={license ? i18n.ML_TYPE_DESCRIPTION : i18n.ML_TYPE_DISABLED_DESCRIPTION} - isDisabled={!license} + description={<MlCardDescription hasValidLicense={hasValidLicense} />} icon={<EuiIcon size="l" type="machineLearningApp" />} + 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<StepDefineRuleProps> = ({ 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<StepDefineRuleProps> = ({ path="ruleType" component={SelectRuleType} componentProps={{ + describedByIds: ['detectionEngineStepDefineRuleType'], + hasValidLicense: mlCapabilities.isPlatinumOrTrialLicense, isReadOnly: isUpdateView, }} /> @@ -220,7 +224,6 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ component={QueryBarDefineRule} componentProps={{ browserFields, - loading: indexPatternLoadingQueryBar, idAria: 'detectionEngineStepDefineRuleQueryBar', indexPattern: indexPatternQueryBar, isDisabled: isLoading, @@ -234,8 +237,20 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ </EuiFormRow> <EuiFormRow fullWidth style={{ display: localIsMlRule ? 'flex' : 'none' }}> <> - <UseField path="machineLearningJobId" component={MlJobSelect} /> - <UseField path="anomalyThreshold" component={AnomalyThresholdSlider} /> + <UseField + path="machineLearningJobId" + component={MlJobSelect} + componentProps={{ + describedByIds: ['detectionEngineStepDefineRulemachineLearningJobId'], + }} + /> + <UseField + path="anomalyThreshold" + component={AnomalyThresholdSlider} + componentProps={{ + describedByIds: ['detectionEngineStepDefineRuleAnomalyThreshold'], + }} + /> </> </EuiFormRow> <FormDataProvider pathsToWatch={['index', 'ruleType']}> diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/authentications_query_tab_body.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/authentications_query_tab_body.tsx index fb083b7a7da2f..5a6759fd07221 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/authentications_query_tab_body.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/authentications_query_tab_body.tsx @@ -25,15 +25,15 @@ const AuthenticationTableManage = manageQuery(AuthenticationTable); const ID = 'authenticationsOverTimeQuery'; const authStackByOptions: MatrixHistogramOption[] = [ { - text: 'event.type', - value: 'event.type', + text: 'event.outcome', + value: 'event.outcome', }, ]; -const DEFAULT_STACK_BY = 'event.type'; +const DEFAULT_STACK_BY = 'event.outcome'; enum AuthMatrixDataGroup { - authSuccess = 'authentication_success', - authFailure = 'authentication_failure', + authSuccess = 'success', + authFailure = 'failure', } export const authMatrixDataMappingFields: MatrixHistogramMappingTypes = { diff --git a/x-pack/legacy/plugins/siem/server/lib/authentications/query.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/authentications/query.dsl.ts index 333cc79fadabc..b9ed88e91f87d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/authentications/query.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/authentications/query.dsl.ts @@ -70,7 +70,7 @@ export const buildQuery = ({ failures: { filter: { term: { - 'event.type': 'authentication_failure', + 'event.outcome': 'failure', }, }, aggs: { @@ -86,7 +86,7 @@ export const buildQuery = ({ successes: { filter: { term: { - 'event.type': 'authentication_success', + 'event.outcome': 'success', }, }, aggs: { diff --git a/x-pack/legacy/plugins/siem/server/lib/kpi_hosts/mock.ts b/x-pack/legacy/plugins/siem/server/lib/kpi_hosts/mock.ts index b82a540900bd0..ed9fbf0ba0646 100644 --- a/x-pack/legacy/plugins/siem/server/lib/kpi_hosts/mock.ts +++ b/x-pack/legacy/plugins/siem/server/lib/kpi_hosts/mock.ts @@ -356,15 +356,15 @@ export const mockKpiHostDetailsUniqueIpsQuery = [ ]; const mockAuthAggs = { - authentication_success: { filter: { term: { 'event.type': 'authentication_success' } } }, + authentication_success: { filter: { term: { 'event.outcome': 'success' } } }, authentication_success_histogram: { auto_date_histogram: { field: '@timestamp', buckets: '6' }, - aggs: { count: { filter: { term: { 'event.type': 'authentication_success' } } } }, + aggs: { count: { filter: { term: { 'event.outcome': 'success' } } } }, }, - authentication_failure: { filter: { term: { 'event.type': 'authentication_failure' } } }, + authentication_failure: { filter: { term: { 'event.outcome': 'failure' } } }, authentication_failure_histogram: { auto_date_histogram: { field: '@timestamp', buckets: '6' }, - aggs: { count: { filter: { term: { 'event.type': 'authentication_failure' } } } }, + aggs: { count: { filter: { term: { 'event.outcome': 'failure' } } } }, }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/kpi_hosts/query_authentication.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/kpi_hosts/query_authentication.dsl.ts index 5734aa6ee88cc..0b7803d007194 100644 --- a/x-pack/legacy/plugins/siem/server/lib/kpi_hosts/query_authentication.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/kpi_hosts/query_authentication.dsl.ts @@ -49,7 +49,7 @@ export const buildAuthQuery = ({ authentication_success: { filter: { term: { - 'event.type': 'authentication_success', + 'event.outcome': 'success', }, }, }, @@ -62,7 +62,7 @@ export const buildAuthQuery = ({ count: { filter: { term: { - 'event.type': 'authentication_success', + 'event.outcome': 'success', }, }, }, @@ -71,7 +71,7 @@ export const buildAuthQuery = ({ authentication_failure: { filter: { term: { - 'event.type': 'authentication_failure', + 'event.outcome': 'failure', }, }, }, @@ -84,7 +84,7 @@ export const buildAuthQuery = ({ count: { filter: { term: { - 'event.type': 'authentication_failure', + 'event.outcome': 'failure', }, }, }, diff --git a/x-pack/legacy/plugins/siem/server/lib/matrix_histogram/query.authentications_over_time.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/matrix_histogram/query.authentications_over_time.dsl.ts index ccf0d235abdd3..34a3804f974de 100644 --- a/x-pack/legacy/plugins/siem/server/lib/matrix_histogram/query.authentications_over_time.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/matrix_histogram/query.authentications_over_time.dsl.ts @@ -13,10 +13,21 @@ export const buildAuthenticationsOverTimeQuery = ({ sourceConfiguration: { fields: { timestamp }, }, - stackByField = 'event.type', + stackByField = 'event.outcome', }: MatrixHistogramRequestOptions) => { const filter = [ ...createQueryFilterClauses(filterQuery), + { + bool: { + must: [ + { + term: { + 'event.category': 'authentication', + }, + }, + ], + }, + }, { range: { [timestamp]: { @@ -45,7 +56,7 @@ export const buildAuthenticationsOverTimeQuery = ({ eventActionGroup: { terms: { field: stackByField, - include: ['authentication_success', 'authentication_failure'], + include: ['success', 'failure'], order: { _count: 'desc', }, diff --git a/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.ts b/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.ts index 7f1919fbea684..f30629789b4ed 100644 --- a/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.ts +++ b/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; import { i18n } from '@kbn/i18n'; import { AlertType } from '../../common'; import { AlertNavigationHandler } from './types'; @@ -36,7 +35,7 @@ export class AlertNavigationRegistry { public registerDefault(consumer: string, handler: AlertNavigationHandler) { if (this.hasDefaultHandler(consumer)) { - throw Boom.badRequest( + throw new Error( i18n.translate('xpack.alerting.alertNavigationRegistry.register.duplicateDefaultError', { defaultMessage: 'Default Navigation within "{consumer}" is already registered.', values: { @@ -54,7 +53,7 @@ export class AlertNavigationRegistry { public register(consumer: string, alertType: AlertType, handler: AlertNavigationHandler) { if (this.hasTypedHandler(consumer, alertType)) { - throw Boom.badRequest( + throw new Error( i18n.translate('xpack.alerting.alertNavigationRegistry.register.duplicateNavigationError', { defaultMessage: 'Navigation for Alert type "{alertType}" within "{consumer}" is already registered.', @@ -78,7 +77,7 @@ export class AlertNavigationRegistry { return (consumerHandlers.get(alertType.id) ?? consumerHandlers.get(DEFAULT_HANDLER))!; } - throw Boom.badRequest( + throw new Error( i18n.translate('xpack.alerting.alertNavigationRegistry.get.missingNavigationError', { defaultMessage: 'Navigation for Alert type "{alertType}" within "{consumer}" is not registered.', diff --git a/x-pack/plugins/apm/common/custom_link_filter_options.ts b/x-pack/plugins/apm/common/custom_link_filter_options.ts new file mode 100644 index 0000000000000..32b19ad60a646 --- /dev/null +++ b/x-pack/plugins/apm/common/custom_link_filter_options.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as t from 'io-ts'; +import { + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_TYPE, + TRANSACTION_NAME +} from './elasticsearch_fieldnames'; + +export const FilterOptionsRt = t.partial({ + [SERVICE_NAME]: t.union([t.string, t.array(t.string)]), + [SERVICE_ENVIRONMENT]: t.union([t.string, t.array(t.string)]), + [TRANSACTION_NAME]: t.union([t.string, t.array(t.string)]), + [TRANSACTION_TYPE]: t.union([t.string, t.array(t.string)]) +}); + +export type FilterOptions = t.TypeOf<typeof FilterOptionsRt>; + +export const FILTER_OPTIONS: ReadonlyArray<keyof FilterOptions> = [ + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_TYPE, + TRANSACTION_NAME +] as const; diff --git a/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts b/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts index 0a0da332e73ae..cc01c990bf985 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts @@ -18,6 +18,7 @@ export type Mappings = scaling_factor?: number; ignore_malformed?: boolean; coerce?: boolean; + fields?: Record<string, Mappings>; }; export async function createOrUpdateIndex({ diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/get_transaction.test.ts.snap b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/get_transaction.test.ts.snap new file mode 100644 index 0000000000000..16a270fd6d25b --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/get_transaction.test.ts.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`custom link get transaction fetches with all filter 1`] = ` +Object { + "body": Object { + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "term": Object { + "service.environment": "bar", + }, + }, + Object { + "term": Object { + "transaction.type": "qux", + }, + }, + Object { + "term": Object { + "transaction.name": "baz", + }, + }, + ], + }, + }, + }, + "index": "myIndex", + "size": 1, + "terminateAfter": 1, +} +`; + +exports[`custom link get transaction fetches without filter 1`] = ` +Object { + "body": Object { + "query": Object { + "bool": Object { + "filter": Array [], + }, + }, + }, + "index": "myIndex", + "size": 1, + "terminateAfter": 1, +} +`; + +exports[`custom link get transaction removes not listed filters from query 1`] = ` +Object { + "body": Object { + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + ], + }, + }, + }, + "index": "myIndex", + "size": 1, + "terminateAfter": 1, +} +`; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap index b3819ace40d6c..bb8f6dcb22902 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap @@ -8,6 +8,13 @@ Object { "filter": Array [], }, }, + "sort": Array [ + Object { + "label.keyword": Object { + "order": "asc", + }, + }, + ], }, "index": "myIndex", "size": 500, @@ -69,6 +76,13 @@ Object { ], }, }, + "sort": Array [ + Object { + "label.keyword": Object { + "order": "asc", + }, + }, + ], }, "index": "myIndex", "size": 500, diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/get_transaction.test.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/get_transaction.test.ts new file mode 100644 index 0000000000000..4fc22298a476c --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/get_transaction.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 { + inspectSearchParams, + SearchParamsMock +} from '../../../../../../../legacy/plugins/apm/public/utils/testHelpers'; +import { getTransaction } from '../get_transaction'; +import { Setup } from '../../../helpers/setup_request'; +import { + SERVICE_NAME, + TRANSACTION_TYPE, + SERVICE_ENVIRONMENT, + TRANSACTION_NAME +} from '../../../../../common/elasticsearch_fieldnames'; + +describe('custom link get transaction', () => { + let mock: SearchParamsMock; + it('removes not listed filters from query', async () => { + mock = await inspectSearchParams(setup => + getTransaction({ + setup: (setup as unknown) as Setup, + // @ts-ignore ignoring the _debug is not part of filter options + filters: { _debug: true, [SERVICE_NAME]: 'foo' } + }) + ); + + expect(mock.params).toMatchSnapshot(); + }); + it('fetches without filter', async () => { + mock = await inspectSearchParams(setup => + getTransaction({ + setup: (setup as unknown) as Setup + }) + ); + + expect(mock.params).toMatchSnapshot(); + }); + it('fetches with all filter', async () => { + mock = await inspectSearchParams(setup => + getTransaction({ + setup: (setup as unknown) as Setup, + filters: { + [SERVICE_NAME]: 'foo', + [SERVICE_ENVIRONMENT]: 'bar', + [TRANSACTION_NAME]: 'baz', + [TRANSACTION_TYPE]: 'qux' + } + }) + ); + + expect(mock.params).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts index cdb3cff616030..1583e15bdecd5 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts @@ -31,7 +31,13 @@ const mappings: Mappings = { type: 'date' }, label: { - type: 'text' + type: 'text', + fields: { + // Adding keyword type to be able to sort by label alphabetically + keyword: { + type: 'keyword' + } + } }, url: { type: 'keyword' diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts index 809fe2050a072..5dce371e4f307 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts @@ -5,7 +5,7 @@ */ import { pick } from 'lodash'; -import { filterOptions } from '../../../routes/settings/custom_link'; +import { FILTER_OPTIONS } from '../../../../common/custom_link_filter_options'; import { APMIndexDocumentParams } from '../../helpers/es_client'; import { Setup } from '../../helpers/setup_request'; import { CustomLink } from './custom_link_types'; @@ -28,7 +28,7 @@ export async function createOrUpdateCustomLink({ '@timestamp': Date.now(), label: customLink.label, url: customLink.url, - ...pick(customLink, filterOptions) + ...pick(customLink, FILTER_OPTIONS) } }; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts index 60b97712713a9..edb9eb35b9029 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import * as t from 'io-ts'; -import { FilterOptions } from '../../../routes/settings/custom_link'; +import { FilterOptions } from '../../../../common/custom_link_filter_options'; export type CustomLink = { id?: string; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts new file mode 100644 index 0000000000000..396a7cb29f014 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts @@ -0,0 +1,38 @@ +/* + * 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 { pick } from 'lodash'; +import { + FilterOptions, + FILTER_OPTIONS +} from '../../../../common/custom_link_filter_options'; +import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; +import { Setup } from '../../helpers/setup_request'; + +export async function getTransaction({ + setup, + filters = {} +}: { + setup: Setup; + filters?: FilterOptions; +}) { + const { client, indices } = setup; + + const esFilters = Object.entries(pick(filters, FILTER_OPTIONS)).map( + ([key, value]) => { + return { term: { [key]: value } }; + } + ); + + const params = { + terminateAfter: 1, + index: indices['apm_oss.transactionIndices'], + size: 1, + body: { query: { bool: { filter: esFilters } } } + }; + const resp = await client.search<Transaction>(params); + return resp.hits.hits[0]?._source; +} diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts index e6052da73b0db..67956ef3a60ce 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { FilterOptions } from '../../../../common/custom_link_filter_options'; import { Setup } from '../../helpers/setup_request'; import { CustomLink } from './custom_link_types'; -import { FilterOptions } from '../../../routes/settings/custom_link'; export async function listCustomLinks({ setup, @@ -37,7 +37,14 @@ export async function listCustomLinks({ bool: { filter: esFilters } - } + }, + sort: [ + { + 'label.keyword': { + order: 'asc' + } + } + ] } }; const resp = await internalClient.search<CustomLink>(params); diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 34f0536a90b4d..50a794067bfad 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -63,7 +63,8 @@ import { createCustomLinkRoute, updateCustomLinkRoute, deleteCustomLinkRoute, - listCustomLinksRoute + listCustomLinksRoute, + customLinkTransactionRoute } from './settings/custom_link'; const createApmApi = () => { @@ -138,7 +139,8 @@ const createApmApi = () => { .add(createCustomLinkRoute) .add(updateCustomLinkRoute) .add(deleteCustomLinkRoute) - .add(listCustomLinksRoute); + .add(listCustomLinksRoute) + .add(customLinkTransactionRoute); return api; }; diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link.ts b/x-pack/plugins/apm/server/routes/settings/custom_link.ts index 5988d7f85b186..e11c1df9d4b16 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link.ts +++ b/x-pack/plugins/apm/server/routes/settings/custom_link.ts @@ -4,33 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ import * as t from 'io-ts'; -import { - SERVICE_NAME, - SERVICE_ENVIRONMENT, - TRANSACTION_NAME, - TRANSACTION_TYPE -} from '../../../common/elasticsearch_fieldnames'; +import { FilterOptionsRt } from '../../../common/custom_link_filter_options'; import { createRoute } from '../create_route'; import { setupRequest } from '../../lib/helpers/setup_request'; import { createOrUpdateCustomLink } from '../../lib/settings/custom_link/create_or_update_custom_link'; import { deleteCustomLink } from '../../lib/settings/custom_link/delete_custom_link'; import { listCustomLinks } from '../../lib/settings/custom_link/list_custom_links'; +import { getTransaction } from '../../lib/settings/custom_link/get_transaction'; -const FilterOptionsRt = t.partial({ - [SERVICE_NAME]: t.string, - [SERVICE_ENVIRONMENT]: t.string, - [TRANSACTION_NAME]: t.string, - [TRANSACTION_TYPE]: t.string -}); - -export type FilterOptions = t.TypeOf<typeof FilterOptionsRt>; - -export const filterOptions: Array<keyof FilterOptions> = [ - SERVICE_NAME, - SERVICE_ENVIRONMENT, - TRANSACTION_TYPE, - TRANSACTION_NAME -]; +export const customLinkTransactionRoute = createRoute(core => ({ + path: '/api/apm/settings/custom_links/transaction', + params: { + query: FilterOptionsRt + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { params } = context; + return await getTransaction({ setup, filters: params.query }); + } +})); export const listCustomLinksRoute = createRoute(core => ({ path: '/api/apm/settings/custom_links', diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/service.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/service.ts index 09020ce61c6e4..3ef852ebf6dd6 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/fields/service.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/service.ts @@ -6,6 +6,7 @@ export interface Service { name: string; + environment?: string; framework?: { name: string; version: string; 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 ( <core.i18n.Context> <EuiErrorBoundary> - <ReduxStoreProvider store={store}> - <ReduxStateContextProvider> - <ApolloProvider client={libs.apolloClient}> - <ApolloClientContext.Provider value={libs.apolloClient}> - <EuiThemeProvider darkMode={darkMode}> - <HistoryContext.Provider value={history}> - <Router history={history} /> - </HistoryContext.Provider> - </EuiThemeProvider> - </ApolloClientContext.Provider> - </ApolloProvider> - </ReduxStateContextProvider> - </ReduxStoreProvider> + <TriggersActionsProvider triggersActionsUI={triggersActionsUI}> + <ReduxStoreProvider store={store}> + <ReduxStateContextProvider> + <ApolloProvider client={libs.apolloClient}> + <ApolloClientContext.Provider value={libs.apolloClient}> + <EuiThemeProvider darkMode={darkMode}> + <HistoryContext.Provider value={history}> + <Router history={history} /> + </HistoryContext.Provider> + </EuiThemeProvider> + </ApolloClientContext.Provider> + </ApolloProvider> + </ReduxStateContextProvider> + </ReduxStoreProvider> + </TriggersActionsProvider> </EuiErrorBoundary> </core.i18n.Context> ); 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 [ + <EuiContextMenuItem icon="bell" key="createLink" onClick={() => setFlyoutVisible(true)}> + <FormattedMessage + id="xpack.infra.alerting.createAlertButton" + defaultMessage="Create alert" + /> + </EuiContextMenuItem>, + <EuiContextMenuItem + icon="tableOfContents" + key="manageLink" + href={kibana.services?.application?.getUrlForApp( + 'kibana#/management/kibana/triggersActions/alerts' + )} + > + <FormattedMessage id="xpack.infra.alerting.manageAlerts" defaultMessage="Manage Alerts" /> + </EuiContextMenuItem>, + ]; + }, [kibana.services]); + + return ( + <> + <EuiPopover + button={ + <EuiButtonEmpty iconSide={'right'} iconType={'arrowDown'} onClick={openPopover}> + <FormattedMessage id="xpack.infra.alerting.alertsButton" defaultMessage="Alerts" /> + </EuiButtonEmpty> + } + isOpen={popoverOpen} + closePopover={closePopover} + > + <EuiContextMenuPanel items={menuItems} /> + </EuiPopover> + <AlertFlyout setVisible={setFlyoutVisible} visible={flyoutVisible} /> + </> + ); +}; 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<MetricsExplorerOptions>; + series?: MetricsExplorerSeries; + setVisible: React.Dispatch<React.SetStateAction<boolean>>; +} + +export const AlertFlyout = (props: Props) => { + const { triggersActionsUI } = useContext(TriggerActionsContext); + const { services } = useKibana(); + + return ( + <> + {triggersActionsUI && ( + <AlertsContextProvider + value={{ + metadata: { + currentOptions: props.options, + series: props.series, + }, + toastNotifications: services.notifications?.toasts, + http: services.http, + actionTypeRegistry: triggersActionsUI.actionTypeRegistry, + alertTypeRegistry: triggersActionsUI.alertTypeRegistry, + }} + > + <AlertAdd + addFlyoutVisible={props.visible!} + setAddFlyoutVisibility={props.setVisible} + alertTypeId={METRIC_THRESHOLD_ALERT_TYPE_ID} + canChangeTrigger={false} + consumer={'metrics'} + /> + </AlertsContextProvider> + )} + </> + ); +}; 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<MetricsExplorerOptions>; + series?: MetricsExplorerSeries; +} + +interface Props { + errors: IErrorObject[]; + alertParams: { + criteria: MetricExpression[]; + groupBy?: string; + filterQuery?: string; + }; + alertsContext: AlertsContextValue<AlertContextMeta>; + 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> = props => { + const { setAlertParams, alertParams, errors, alertsContext } = props; + const { source, createDerivedIndexPattern } = useSource({ sourceId: 'default' }); + const [timeSize, setTimeSize] = useState<number | undefined>(1); + const [timeUnit, setTimeUnit] = useState<TimeUnit>('s'); + + const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ + createDerivedIndexPattern, + ]); + + const options = useMemo<MetricsExplorerOptions>(() => { + if (alertsContext.metadata?.currentOptions?.metrics) { + return alertsContext.metadata.currentOptions as MetricsExplorerOptions; + } else { + return { + metrics: [], + aggregation: 'avg', + }; + } + }, [alertsContext.metadata]); + + const defaultExpression = useMemo<MetricExpression>( + () => ({ + 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 ( + <> + <EuiSpacer size={'m'} /> + <EuiText size="xs"> + <h4> + <FormattedMessage + id="xpack.infra.metrics.alertFlyout.conditions" + defaultMessage="Conditions" + /> + </h4> + </EuiText> + <EuiSpacer size={'xs'} /> + {alertParams.criteria && + alertParams.criteria.map((e, idx) => { + return ( + <ExpressionRow + canDelete={alertParams.criteria.length > 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 || {}} + /> + ); + })} + + <ForLastExpression + timeWindowSize={timeSize} + timeWindowUnit={timeUnit} + errors={emptyError} + onChangeWindowSize={updateTimeSize} + onChangeWindowUnit={updateTimeUnit} + /> + + <div> + <EuiButtonEmpty + color={'primary'} + iconSide={'left'} + flush={'left'} + iconType={'plusInCircleFilled'} + onClick={addExpression} + > + <FormattedMessage + id="xpack.infra.metrics.alertFlyout.addCondition" + defaultMessage="Add condition" + /> + </EuiButtonEmpty> + </div> + + <EuiSpacer size={'m'} /> + + <EuiFormRow + label={i18n.translate('xpack.infra.metrics.alertFlyout.filterLabel', { + defaultMessage: 'Filter', + })} + helpText={i18n.translate('xpack.infra.metrics.alertFlyout.filterHelpText', { + defaultMessage: 'Filter help text', + })} + fullWidth + compressed + > + <MetricsExplorerKueryBar + derivedIndexPattern={derivedIndexPattern} + onSubmit={onFilterQuerySubmit} + value={alertParams.filterQuery} + /> + </EuiFormRow> + + <EuiSpacer size={'m'} /> + + {alertsContext.metadata && ( + <EuiFormRow + label={i18n.translate('xpack.infra.metrics.alertFlyout.createAlertPerText', { + defaultMessage: 'Create alert per', + })} + helpText={i18n.translate('xpack.infra.metrics.alertFlyout.createAlertPerHelpText', { + defaultMessage: 'Create alert help text', + })} + fullWidth + compressed + > + <MetricsExplorerGroupBy + onChange={onGroupByChange} + fields={derivedIndexPattern.fields} + options={{ + ...options, + groupBy: alertParams.groupBy || undefined, + }} + /> + </EuiFormRow> + )} + </> + ); +}; + +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<ExpressionRowProps> = 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 ( + <> + <EuiFlexGroup gutterSize="xs"> + <EuiFlexItem grow> + <StyledExpressionRow> + <StyledExpression> + <WhenExpression + customAggTypesOptions={aggregationType} + aggType={aggType} + onChangeSelectedAggType={updateAggType} + /> + </StyledExpression> + {aggType !== 'count' && ( + <StyledExpression> + <OfExpression + customAggTypesOptions={aggregationType} + aggField={metric} + fields={fields.map(f => ({ + normalizedType: f.type, + name: f.name, + }))} + aggType={aggType} + errors={errors} + onChangeSelectedAggField={updateMetric} + /> + </StyledExpression> + )} + <StyledExpression> + <ThresholdExpression + thresholdComparator={comparator || '>'} + threshold={threshold} + onChangeSelectedThresholdComparator={updateComparator} + onChangeSelectedThreshold={updateThreshold} + errors={errors} + /> + </StyledExpression> + </StyledExpressionRow> + </EuiFlexItem> + {canDelete && ( + <EuiFlexItem grow={false}> + <EuiButtonIcon + aria-label={i18n.translate('xpack.infra.metrics.alertFlyout.removeCondition', { + defaultMessage: 'Remove condition', + })} + color={'danger'} + iconType={'trash'} + onClick={() => remove(expressionId)} + /> + </EuiFlexItem> + )} + </EuiFlexGroup> + <EuiSpacer size={'s'} /> + </> + ); +}; + +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<Props> = ({ 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<Props> = ({ ] : []; - 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<Props> = ({ {actionLabel} </EuiButtonEmpty> ); + return ( - <EuiPopover - closePopover={handleClose} - id={`${series.id}-popover`} - button={button} - isOpen={isPopoverOpen} - panelPaddingSize="none" - > - <EuiContextMenu initialPanelId={0} panels={panels} /> - </EuiPopover> + <> + <EuiPopover + closePopover={handleClose} + id={`${series.id}-popover`} + button={button} + isOpen={isPopoverOpen} + panelPaddingSize="none" + > + <EuiContextMenu initialPanelId={0} panels={panels} /> + <AlertFlyout + series={series} + options={options} + setVisible={setFlyoutVisible} + visible={flyoutVisible} + /> + </EuiPopover> + </> ); }; 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<string>(value || ''); const [isValid, setValidation] = useState<boolean>(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 ( <WithKueryAutocompletion indexPattern={filteredDerivedIndexPattern}> @@ -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 ( <Toolbar> <EuiFlexGroup alignItems="center"> 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<Props> = ({ 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<Props> = ({ }; return ( - <ActionMenu - closePopover={closePopover} - id={`${node.pathId}-popover`} - isOpen={isPopoverOpen} - button={children!} - anchorPosition={popoverPosition} - > - <div style={{ maxWidth: 300 }} data-test-subj="nodeContextMenu"> - <Section> - <SectionTitle> - <FormattedMessage - id="xpack.infra.nodeContextMenu.title" - defaultMessage="{inventoryName} details" - values={{ inventoryName: inventoryModel.singularDisplayName }} - /> - </SectionTitle> - {inventoryId.label && ( - <SectionSubtitle> - <div style={{ wordBreak: 'break-all' }}> - <FormattedMessage - id="xpack.infra.nodeContextMenu.description" - defaultMessage="View details for {label} {value}" - values={{ label: inventoryId.label, value: inventoryId.value }} - /> - </div> - </SectionSubtitle> - )} - <SectionLinks> - <SectionLink data-test-subj="viewLogsContextMenuItem" {...nodeLogsMenuItem} /> - <SectionLink {...nodeDetailMenuItem} /> - <SectionLink data-test-subj="viewApmTracesContextMenuItem" {...apmTracesMenuItem} /> - <SectionLink {...uptimeMenuItem} /> - </SectionLinks> - </Section> - </div> - </ActionMenu> + <> + <ActionMenu + closePopover={closePopover} + id={`${node.pathId}-popover`} + isOpen={isPopoverOpen} + button={children!} + anchorPosition={popoverPosition} + > + <div style={{ maxWidth: 300 }} data-test-subj="nodeContextMenu"> + <Section> + <SectionTitle> + <FormattedMessage + id="xpack.infra.nodeContextMenu.title" + defaultMessage="{inventoryName} details" + values={{ inventoryName: inventoryModel.singularDisplayName }} + /> + </SectionTitle> + {inventoryId.label && ( + <SectionSubtitle> + <div style={{ wordBreak: 'break-all' }}> + <FormattedMessage + id="xpack.infra.nodeContextMenu.description" + defaultMessage="View details for {label} {value}" + values={{ label: inventoryId.label, value: inventoryId.value }} + /> + </div> + </SectionSubtitle> + )} + <SectionLinks> + <SectionLink data-test-subj="viewLogsContextMenuItem" {...nodeLogsMenuItem} /> + <SectionLink {...nodeDetailMenuItem} /> + <SectionLink data-test-subj="viewApmTracesContextMenuItem" {...apmTracesMenuItem} /> + <SectionLink {...uptimeMenuItem} /> + </SectionLinks> + </Section> + </div> + </ActionMenu> + <AlertFlyout + options={{ filterQuery: `${nodeType}: ${node.id}` }} + setVisible={setFlyoutVisible} + visible={flyoutVisible} + /> + </> ); }; 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 ( <Source.Provider sourceId="default"> <ColumnarPage> @@ -59,31 +62,38 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { defaultMessage: 'Metrics', })} > - <RoutedTabs - tabs={[ - { - app: 'metrics', - title: i18n.translate('xpack.infra.homePage.inventoryTabTitle', { - defaultMessage: 'Inventory', - }), - pathname: '/inventory', - }, - { - app: 'metrics', - title: i18n.translate('xpack.infra.homePage.metricsExplorerTabTitle', { - defaultMessage: 'Metrics Explorer', - }), - pathname: '/explorer', - }, - { - app: 'metrics', - title: i18n.translate('xpack.infra.homePage.settingsTabTitle', { - defaultMessage: 'Settings', - }), - pathname: '/settings', - }, - ]} - /> + <EuiFlexGroup gutterSize={'none'} alignItems={'center'}> + <EuiFlexItem> + <RoutedTabs + tabs={[ + { + app: 'metrics', + title: i18n.translate('xpack.infra.homePage.inventoryTabTitle', { + defaultMessage: 'Inventory', + }), + pathname: '/inventory', + }, + { + app: 'metrics', + title: i18n.translate('xpack.infra.homePage.metricsExplorerTabTitle', { + defaultMessage: 'Metrics Explorer', + }), + pathname: '/explorer', + }, + { + app: 'metrics', + title: i18n.translate('xpack.infra.homePage.settingsTabTitle', { + defaultMessage: 'Settings', + }), + pathname: '/settings', + }, + ]} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <AlertDropdown /> + </EuiFlexItem> + </EuiFlexGroup> </AppNavigation> <Switch> 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<ContextProps>({ + triggersActionsUI: null, +}); + +interface Props { + triggersActionsUI: TriggersAndActionsUIPublicPluginSetup; +} + +export const TriggersActionsProvider: React.FC<Props> = props => { + return ( + <TriggerActionsContext.Provider + value={{ + triggersActionsUI: props.triggersActionsUI, + }} + > + {props.children} + </TriggerActionsContext.Provider> + ); +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index a6b9b70feede2..feaa404ae960a 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -17,7 +17,7 @@ const alertInstances = new Map(); const services = { callCluster(_: string, { body }: any) { - const metric = body.query.bool.filter[1].exists.field; + const metric = body.query.bool.filter[1]?.exists.field; if (body.aggs.groupings) { if (body.aggs.groupings.composite.after) { return mocks.compositeEndResponse; @@ -228,6 +228,7 @@ describe('The metric threshold alert type', () => { comparator, threshold, aggType: 'count', + metric: undefined, }, ], }, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index 8c509c017cf20..778889ba0c7a5 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -63,6 +63,12 @@ export const getElasticsearchMetricQuery = ( groupBy?: string, filterQuery?: string ) => { + if (aggType === 'count' && metric) { + throw new Error('Cannot aggregate document count with a metric'); + } + if (aggType !== 'count' && !metric) { + throw new Error('Can only aggregate without a metric if using the document count aggregator'); + } const interval = `${timeSize}${timeUnit}`; const aggregations = @@ -108,25 +114,32 @@ export const getElasticsearchMetricQuery = ( } : baseAggs; + const rangeFilters = [ + { + range: { + '@timestamp': { + gte: `now-${interval}`, + }, + }, + }, + ]; + + const metricFieldFilters = metric + ? [ + { + exists: { + field: metric, + }, + }, + ] + : []; + const parsedFilterQuery = getParsedFilterQuery(filterQuery); return { query: { bool: { - filter: [ - { - range: { - '@timestamp': { - gte: `now-${interval}`, - }, - }, - }, - { - exists: { - field: metric, - }, - }, - ], + filter: [...rangeFilters, ...metricFieldFilters], ...parsedFilterQuery, }, }, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index 501d7549e1712..ed3a9b2f4fe36 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -17,22 +17,44 @@ export async function registerMetricThresholdAlertType(alertingPlugin: PluginSet } const alertUUID = uuid.v4(); + const baseCriterion = { + threshold: schema.arrayOf(schema.number()), + comparator: schema.oneOf([ + schema.literal('>'), + schema.literal('<'), + schema.literal('>='), + schema.literal('<='), + schema.literal('between'), + ]), + timeUnit: schema.string(), + timeSize: schema.number(), + indexPattern: schema.string(), + }; + + const nonCountCriterion = schema.object({ + ...baseCriterion, + metric: schema.string(), + aggType: schema.oneOf([ + schema.literal('avg'), + schema.literal('min'), + schema.literal('max'), + schema.literal('rate'), + schema.literal('cardinality'), + ]), + }); + + const countCriterion = schema.object({ + ...baseCriterion, + aggType: schema.literal('count'), + metric: schema.never(), + }); + alertingPlugin.registerType({ id: METRIC_THRESHOLD_ALERT_TYPE_ID, name: 'Metric Alert - Threshold', validate: { params: schema.object({ - criteria: schema.arrayOf( - schema.object({ - threshold: schema.arrayOf(schema.number()), - comparator: schema.string(), - aggType: schema.string(), - metric: schema.string(), - timeUnit: schema.string(), - timeSize: schema.number(), - indexPattern: schema.string(), - }) - ), + criteria: schema.arrayOf(schema.oneOf([countCriterion, nonCountCriterion])), groupBy: schema.maybe(schema.string()), filterQuery: schema.maybe(schema.string()), }), diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts index 07739c9d81bc4..557a071ec9175 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts @@ -25,12 +25,22 @@ export enum AlertStates { export type TimeUnit = 's' | 'm' | 'h' | 'd'; -export interface MetricExpressionParams { - aggType: MetricsExplorerAggregation; - metric: string; +interface BaseMetricExpressionParams { timeSize: number; timeUnit: TimeUnit; indexPattern: string; threshold: number[]; comparator: Comparator; } + +interface NonCountMetricExpressionParams extends BaseMetricExpressionParams { + aggType: Exclude<MetricsExplorerAggregation, 'count'>; + metric: string; +} + +interface CountMetricExpressionParams extends BaseMetricExpressionParams { + aggType: 'count'; + metric: never; +} + +export type MetricExpressionParams = NonCountMetricExpressionParams | CountMetricExpressionParams; diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 6269c11fca896..8c3e0c066f411 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -23,45 +23,16 @@ type MlDependencies = MlSetupDependencies & MlStartDependencies; interface AppProps { coreStart: CoreStart; deps: MlDependencies; - appMountParams: AppMountParameters; } const localStorage = new Storage(window.localStorage); -const App: FC<AppProps> = ({ coreStart, deps, appMountParams }) => { - setDependencyCache({ - indexPatterns: deps.data.indexPatterns, - timefilter: deps.data.query.timefilter, - fieldFormats: deps.data.fieldFormats, - autocomplete: deps.data.autocomplete, - config: coreStart.uiSettings!, - chrome: coreStart.chrome!, - docLinks: coreStart.docLinks!, - toastNotifications: coreStart.notifications.toasts, - overlays: coreStart.overlays, - recentlyAccessed: coreStart.chrome!.recentlyAccessed, - basePath: coreStart.http.basePath, - savedObjectsClient: coreStart.savedObjects.client, - application: coreStart.application, - http: coreStart.http, - security: deps.security, - urlGenerators: deps.share.urlGenerators, - }); - - const mlLicense = setLicenseCache(deps.licensing); - - appMountParams.onAppLeave(actions => { - mlLicense.unsubscribe(); - clearCache(); - return actions.default(); - }); - +const App: FC<AppProps> = ({ coreStart, deps }) => { const pageDeps = { indexPatterns: deps.data.indexPatterns, config: coreStart.uiSettings!, setBreadcrumbs: coreStart.chrome!.setBreadcrumbs, }; - const services = { appName: 'ML', data: deps.data, @@ -85,10 +56,34 @@ export const renderApp = ( deps: MlDependencies, appMountParams: AppMountParameters ) => { - ReactDOM.render( - <App coreStart={coreStart} deps={deps} appMountParams={appMountParams} />, - appMountParams.element - ); + setDependencyCache({ + indexPatterns: deps.data.indexPatterns, + timefilter: deps.data.query.timefilter, + fieldFormats: deps.data.fieldFormats, + autocomplete: deps.data.autocomplete, + config: coreStart.uiSettings!, + chrome: coreStart.chrome!, + docLinks: coreStart.docLinks!, + toastNotifications: coreStart.notifications.toasts, + overlays: coreStart.overlays, + recentlyAccessed: coreStart.chrome!.recentlyAccessed, + basePath: coreStart.http.basePath, + savedObjectsClient: coreStart.savedObjects.client, + application: coreStart.application, + http: coreStart.http, + security: deps.security, + urlGenerators: deps.share.urlGenerators, + }); - return () => ReactDOM.unmountComponentAtNode(appMountParams.element); + const mlLicense = setLicenseCache(deps.licensing); + + appMountParams.onAppLeave(actions => actions.default()); + + ReactDOM.render(<App coreStart={coreStart} deps={deps} />, appMountParams.element); + + return () => { + mlLicense.unsubscribe(); + clearCache(); + ReactDOM.unmountComponentAtNode(appMountParams.element); + }; }; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js index aec57e0d33cdd..29c79458fe431 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js @@ -31,6 +31,7 @@ import { withKibana } from '../../../../../../../../../src/plugins/kibana_react/ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { collapseLiteralStrings } from '../../../../../../shared_imports'; +import { DATAFEED_STATE } from '../../../../../../common/constants/states'; export class EditJobFlyoutUI extends Component { _initialJobFormState = null; @@ -41,6 +42,7 @@ export class EditJobFlyoutUI extends Component { this.state = { job: {}, hasDatafeed: false, + datafeedRunning: false, isFlyoutVisible: false, isConfirmationModalVisible: false, jobDescription: '', @@ -157,10 +159,12 @@ export class EditJobFlyoutUI extends Component { extractJob(job, hasDatafeed) { this.extractInitialJobFormState(job, hasDatafeed); + const datafeedRunning = hasDatafeed && job.datafeed_config.state !== DATAFEED_STATE.STOPPED; this.setState({ job, hasDatafeed, + datafeedRunning, jobModelMemoryLimitValidationError: '', jobGroupsValidationError: '', ...cloneDeep(this._initialJobFormState), @@ -283,6 +287,7 @@ export class EditJobFlyoutUI extends Component { jobModelMemoryLimitValidationError, isValidJobDetails, isValidJobCustomUrls, + datafeedRunning, } = this.state; const tabs = [ @@ -293,6 +298,7 @@ export class EditJobFlyoutUI extends Component { }), content: ( <JobDetails + datafeedRunning={datafeedRunning} jobDescription={jobDescription} jobGroups={jobGroups} jobModelMemoryLimit={jobModelMemoryLimit} @@ -328,6 +334,7 @@ export class EditJobFlyoutUI extends Component { datafeedScrollSize={datafeedScrollSize} jobBucketSpan={jobBucketSpan} setDatafeed={this.setDatafeed} + datafeedRunning={datafeedRunning} /> ), }, diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/datafeed.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/datafeed.js index 096a03621d422..3d81b767021a0 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/datafeed.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/datafeed.js @@ -7,7 +7,14 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { EuiFieldText, EuiForm, EuiFormRow, EuiSpacer, EuiFieldNumber } from '@elastic/eui'; +import { + EuiFieldText, + EuiForm, + EuiFormRow, + EuiSpacer, + EuiFieldNumber, + EuiCallOut, +} from '@elastic/eui'; import { calculateDatafeedFrequencyDefaultSeconds } from '../../../../../../../common/util/job_utils'; import { getNewJobDefaults } from '../../../../../services/ml_server_info'; @@ -72,9 +79,21 @@ export class Datafeed extends Component { render() { const { query, queryDelay, frequency, scrollSize, defaults } = this.state; + const { datafeedRunning } = this.props; return ( <React.Fragment> <EuiSpacer size="m" /> + {datafeedRunning && ( + <> + <EuiCallOut color="warning"> + <FormattedMessage + id="xpack.ml.jobsList.editJobFlyout.datafeed.readOnlyCalloutText" + defaultMessage="Datafeed settings cannot be edited while the datafeed is running. Please stop the job if you wish to edit these settings." + /> + </EuiCallOut> + <EuiSpacer size="l" /> + </> + )} <EuiForm> <EuiFormRow label={ @@ -90,6 +109,7 @@ export class Datafeed extends Component { value={query} onChange={this.onQueryChange} height="200px" + readOnly={datafeedRunning} /> </EuiFormRow> <EuiFormRow @@ -104,6 +124,7 @@ export class Datafeed extends Component { value={queryDelay} placeholder={defaults.queryDelay} onChange={this.onQueryDelayChange} + disabled={datafeedRunning} /> </EuiFormRow> <EuiFormRow @@ -118,6 +139,7 @@ export class Datafeed extends Component { value={frequency} placeholder={defaults.frequency} onChange={this.onFrequencyChange} + disabled={datafeedRunning} /> </EuiFormRow> <EuiFormRow @@ -132,6 +154,7 @@ export class Datafeed extends Component { value={scrollSize} placeholder={defaults.scrollSize} onChange={this.onScrollSizeChange} + disabled={datafeedRunning} /> </EuiFormRow> </EuiForm> @@ -140,6 +163,7 @@ export class Datafeed extends Component { } } Datafeed.propTypes = { + datafeedRunning: PropTypes.bool.isRequired, datafeedQuery: PropTypes.string.isRequired, datafeedQueryDelay: PropTypes.string.isRequired, datafeedFrequency: PropTypes.string.isRequired, diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/job_details.js index a609d6a7c3fba..672fd8cefaaba 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/job_details.js @@ -105,6 +105,7 @@ export class JobDetails extends Component { mmlValidationError, groupsValidationError, } = this.state; + const { datafeedRunning } = this.props; return ( <React.Fragment> <EuiSpacer size="m" /> @@ -152,6 +153,14 @@ export class JobDetails extends Component { defaultMessage="Model memory limit" /> } + helpText={ + datafeedRunning ? ( + <FormattedMessage + id="xpack.ml.jobsList.editJobFlyout.jobDetails.modelMemoryLimitLabelHelp" + defaultMessage="Model memory limit cannot be edited while the datafeed is running." + /> + ) : null + } isInvalid={mmlValidationError !== ''} error={mmlValidationError} > @@ -160,6 +169,7 @@ export class JobDetails extends Component { onChange={this.onMmlChange} isInvalid={mmlValidationError !== ''} error={mmlValidationError} + disabled={datafeedRunning} /> </EuiFormRow> </EuiForm> @@ -168,6 +178,7 @@ export class JobDetails extends Component { } } JobDetails.propTypes = { + datafeedRunning: PropTypes.bool.isRequired, jobDescription: PropTypes.string.isRequired, jobGroups: PropTypes.array.isRequired, jobModelMemoryLimit: PropTypes.string.isRequired, diff --git a/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts b/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts index ee49da6538460..466e70197e3d1 100644 --- a/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts @@ -24,7 +24,7 @@ export const datafeedConfigSchema = schema.object({ }) ), frequency: schema.maybe(schema.string()), - indices: schema.arrayOf(schema.string()), + indices: schema.maybe(schema.arrayOf(schema.string())), indexes: schema.maybe(schema.arrayOf(schema.string())), job_id: schema.maybe(schema.string()), query: schema.maybe(schema.any()), 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, }; }); 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..836740d0a547f 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<IClusterClient>; @@ -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); @@ -142,4 +225,56 @@ describe('API Keys', () => { ); }); }); + + describe('invalidateAsInternalUser()', () => { + it('returns null when security feature is disabled', async () => { + mockLicense.isEnabled.mockReturnValue(false); + const result = await apiKeys.invalidateAsInternalUser({ id: '123' }); + expect(result).toBeNull(); + expect(mockClusterClient.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('calls callCluster with proper parameters', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockClusterClient.callAsInternalUser.mockResolvedValueOnce({ + invalidated_api_keys: ['api-key-id-1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + const result = await apiKeys.invalidateAsInternalUser({ id: '123' }); + expect(result).toEqual({ + invalidated_api_keys: ['api-key-id-1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.invalidateAPIKey', { + body: { + id: '123', + }, + }); + }); + + it('Only passes id as a parameter', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockClusterClient.callAsInternalUser.mockResolvedValueOnce({ + invalidated_api_keys: ['api-key-id-1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + const result = await apiKeys.invalidateAsInternalUser({ + id: '123', + name: 'abc', + } as any); + expect(result).toEqual({ + invalidated_api_keys: ['api-key-id-1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.invalidateAPIKey', { + body: { + id: '123', + }, + }); + }); + }); }); diff --git a/x-pack/plugins/security/server/authentication/api_keys.ts b/x-pack/plugins/security/server/authentication/api_keys.ts index 2b1a93d907471..9df7219cec334 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,31 +155,89 @@ 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. * @param params The params to invalidate an API key. */ - async invalidate( - request: KibanaRequest, - params: InvalidateAPIKeyParams - ): Promise<InvalidateAPIKeyResult | null> { + async invalidate(request: KibanaRequest, params: InvalidateAPIKeyParams) { if (!this.license.isEnabled()) { return null; } - this.logger.debug('Trying to invalidate an API key'); + this.logger.debug('Trying to invalidate an API key as current user'); - // User needs `manage_api_key` privilege to use this API let result: InvalidateAPIKeyResult; try { - result = (await this.clusterClient + // User needs `manage_api_key` privilege to use this API + result = await this.clusterClient .asScoped(request) .callAsCurrentUser('shield.invalidateAPIKey', { body: { id: params.id, }, - })) as InvalidateAPIKeyResult; + }); + this.logger.debug('API key was invalidated successfully as current user'); + } catch (e) { + this.logger.error(`Failed to invalidate API key as current user: ${e.message}`); + throw e; + } + + return result; + } + + /** + * Tries to invalidate an API key by using the internal user. + * @param params The params to invalidate an API key. + */ + async invalidateAsInternalUser(params: InvalidateAPIKeyParams) { + if (!this.license.isEnabled()) { + return null; + } + + this.logger.debug('Trying to invalidate an API key'); + + let result: InvalidateAPIKeyResult; + try { + // Internal user needs `cluster:admin/xpack/security/api_key/invalidate` privilege to use this API + result = await this.clusterClient.callAsInternalUser('shield.invalidateAPIKey', { + body: { + id: params.id, + }, + }); this.logger.debug('API key was invalidated successfully'); } catch (e) { this.logger.error(`Failed to invalidate API key: ${e.message}`); @@ -164,4 +246,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..43892753f0d3f 100644 --- a/x-pack/plugins/security/server/authentication/index.mock.ts +++ b/x-pack/plugins/security/server/authentication/index.mock.ts @@ -13,7 +13,9 @@ export const authenticationMock = { isProviderEnabled: jest.fn(), createAPIKey: jest.fn(), getCurrentUser: jest.fn(), + grantAPIKeyAsInternalUser: jest.fn(), invalidateAPIKey: jest.fn(), + invalidateAPIKeyAsInternalUser: jest.fn(), isAuthenticated: jest.fn(), getSessionInfo: jest.fn(), }), diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 30929ba98d33b..21e5f18bc0282 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -33,7 +33,7 @@ import { import { AuthenticatedUser } from '../../common/model'; import { ConfigType, createConfig$ } from '../config'; import { AuthenticationResult } from './authentication_result'; -import { setupAuthentication } from '.'; +import { Authentication, setupAuthentication } from '.'; import { CreateAPIKeyResult, CreateAPIKeyParams, @@ -369,6 +369,24 @@ describe('setupAuthentication()', () => { }); }); + describe('grantAPIKeyAsInternalUser()', () => { + let grantAPIKeyAsInternalUser: (request: KibanaRequest) => Promise<CreateAPIKeyResult | null>; + 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, @@ -392,4 +410,25 @@ describe('setupAuthentication()', () => { expect(apiKeysInstance.invalidate).toHaveBeenCalledWith(request, params); }); }); + + describe('invalidateAPIKeyAsInternalUser()', () => { + let invalidateAPIKeyAsInternalUser: Authentication['invalidateAPIKeyAsInternalUser']; + + beforeEach(async () => { + invalidateAPIKeyAsInternalUser = (await setupAuthentication(mockSetupAuthenticationParams)) + .invalidateAPIKeyAsInternalUser; + }); + + it('calls invalidateAPIKeyAsInternalUser with given arguments', async () => { + const apiKeysInstance = jest.requireMock('./api_keys').APIKeys.mock.instances[0]; + const params = { + id: '123', + }; + apiKeysInstance.invalidateAsInternalUser.mockResolvedValueOnce({ success: true }); + await expect(invalidateAPIKeyAsInternalUser(params)).resolves.toEqual({ + success: true, + }); + expect(apiKeysInstance.invalidateAsInternalUser).toHaveBeenCalledWith(params); + }); + }); }); diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 1eed53efc6441..c5c72853e68e1 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,8 +173,11 @@ 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), + invalidateAPIKeyAsInternalUser: (params: InvalidateAPIKeyParams) => + apiKeys.invalidateAsInternalUser(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..a011f7e7be11e 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -74,7 +74,9 @@ describe('Security Plugin', () => { "createAPIKey": [Function], "getCurrentUser": [Function], "getSessionInfo": [Function], + "grantAPIKeyAsInternalUser": [Function], "invalidateAPIKey": [Function], + "invalidateAPIKeyAsInternalUser": [Function], "isAuthenticated": [Function], "isProviderEnabled": [Function], "login": [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 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<ReindexSavedObject>; + createReindexOperation( + indexName: string, + opts?: { enqueue?: boolean } + ): Promise<ReindexSavedObject>; /** * 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<ReindexSavedObject>; + resumeReindexOperation( + indexName: string, + opts?: { enqueue?: boolean } + ): Promise<ReindexSavedObject>; + + /** + * 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<ReindexSavedObject>; /** * 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 diff --git a/x-pack/test/accessibility/apps/login_page.ts b/x-pack/test/accessibility/apps/login_page.ts index 5b18b6be9e3a4..8c673bb332d91 100644 --- a/x-pack/test/accessibility/apps/login_page.ts +++ b/x-pack/test/accessibility/apps/login_page.ts @@ -28,14 +28,33 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.security.forceLogout(); }); - it('meets a11y requirements', async () => { + it('login page meets a11y requirements', async () => { await PageObjects.common.navigateToApp('login'); await retry.waitFor( 'login page visible', async () => await testSubjects.exists('loginSubmit') ); + await a11y.testAppSnapshot(); + }); + + it('User can login with a11y requirements', async () => { + await PageObjects.security.login(); + await a11y.testAppSnapshot(); + }); + + it('Wrong credentials message meets a11y requirements', async () => { + await PageObjects.security.loginPage.login('wrong-user', 'wrong-password', { + expectSuccess: false, + }); + await PageObjects.security.loginPage.getErrorMessage(); + await a11y.testAppSnapshot(); + }); + it('Logout message acknowledges a11y requirements', async () => { + await PageObjects.security.login(); + await PageObjects.security.logout(); + await testSubjects.getVisibleText('loginInfoMessage'); await a11y.testAppSnapshot(); }); }); diff --git a/x-pack/test/api_integration/apis/infra/metrics_alerting.ts b/x-pack/test/api_integration/apis/infra/metrics_alerting.ts index 09f5a498ddc00..4f17f9db67483 100644 --- a/x-pack/test/api_integration/apis/infra/metrics_alerting.ts +++ b/x-pack/test/api_integration/apis/infra/metrics_alerting.ts @@ -13,11 +13,13 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function({ getService }: FtrProviderContext) { const client = getService('legacyEs'); const index = 'test-index'; - const baseParams = { - metric: 'test.metric', - timeUnit: 'm', - timeSize: 5, - }; + const getSearchParams = (aggType: string) => + ({ + aggType, + timeUnit: 'm', + timeSize: 5, + ...(aggType !== 'count' ? { metric: 'test.metric' } : {}), + } as MetricExpressionParams); describe('Metrics Threshold Alerts', () => { before(async () => { await client.index({ @@ -30,10 +32,7 @@ export default function({ getService }: FtrProviderContext) { describe('querying the entire infrastructure', () => { for (const aggType of aggs) { it(`should work with the ${aggType} aggregator`, async () => { - const searchBody = getElasticsearchMetricQuery({ - ...baseParams, - aggType, - } as MetricExpressionParams); + const searchBody = getElasticsearchMetricQuery(getSearchParams(aggType)); const result = await client.search({ index, body: searchBody, @@ -44,10 +43,7 @@ export default function({ getService }: FtrProviderContext) { } it('should work with a filterQuery', async () => { const searchBody = getElasticsearchMetricQuery( - { - ...baseParams, - aggType: 'avg', - } as MetricExpressionParams, + getSearchParams('avg'), undefined, '{"bool":{"should":[{"match_phrase":{"agent.hostname":"foo"}}],"minimum_should_match":1}}' ); @@ -62,13 +58,7 @@ export default function({ getService }: FtrProviderContext) { describe('querying with a groupBy parameter', () => { for (const aggType of aggs) { it(`should work with the ${aggType} aggregator`, async () => { - const searchBody = getElasticsearchMetricQuery( - { - ...baseParams, - aggType, - } as MetricExpressionParams, - 'agent.id' - ); + const searchBody = getElasticsearchMetricQuery(getSearchParams(aggType), 'agent.id'); const result = await client.search({ index, body: searchBody, @@ -79,10 +69,7 @@ export default function({ getService }: FtrProviderContext) { } it('should work with a filterQuery', async () => { const searchBody = getElasticsearchMetricQuery( - { - ...baseParams, - aggType: 'avg', - } as MetricExpressionParams, + getSearchParams('avg'), 'agent.id', '{"bool":{"should":[{"match_phrase":{"agent.hostname":"foo"}}],"minimum_should_match":1}}' ); diff --git a/x-pack/test/functional/apps/uptime/locations.ts b/x-pack/test/functional/apps/uptime/locations.ts index 7f6932ab50319..96c7fad89a85d 100644 --- a/x-pack/test/functional/apps/uptime/locations.ts +++ b/x-pack/test/functional/apps/uptime/locations.ts @@ -10,7 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['uptime']); - describe('location', () => { + describe.skip('location', () => { const start = new Date().toISOString(); const end = new Date().toISOString(); diff --git a/x-pack/test/functional/apps/uptime/settings.ts b/x-pack/test/functional/apps/uptime/settings.ts index 0e804dd161c6b..aafb145a1b9b0 100644 --- a/x-pack/test/functional/apps/uptime/settings.ts +++ b/x-pack/test/functional/apps/uptime/settings.ts @@ -16,7 +16,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['uptime']); const es = getService('es'); - describe('uptime settings page', () => { + // Flaky https://github.com/elastic/kibana/issues/60866 + describe.skip('uptime settings page', () => { const settingsPage = () => pageObjects.uptime.settings; beforeEach('navigate to clean app root', async () => { // make 10 checks 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<string[]> => { + 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" + } + } + } +} diff --git a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts index 6c41c2cab801e..2a50c0117eae9 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts @@ -111,11 +111,11 @@ export function TriggersActionsPageProvider({ getService }: FtrProviderContext) const table = await find.byCssSelector('[data-test-subj="alertsList"] table'); const $ = await table.parseDomContent(); const rows = $.findTestSubjects('alert-row').toArray(); - expect(rows.length).not.to.eql(0); + expect(rows.length).to.eql(0); const emptyRow = await find.byCssSelector( '[data-test-subj="alertsList"] table .euiTableRow' ); - expect(await emptyRow.getVisibleText()).not.to.eql('No items found'); + expect(await emptyRow.getVisibleText()).to.eql('No items found'); }); return true; },